mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d31ecc2bc | ||
|
|
890286a08d | ||
|
|
33ff59a75e | ||
|
|
c2bbba6e78 | ||
|
|
86b85e7d8d | ||
|
|
03a7d3a6ee | ||
|
|
a21db49184 | ||
|
|
6dd6da083d | ||
|
|
656921d776 | ||
|
|
fabde0c2bb | ||
|
|
7680f33879 | ||
|
|
199a996b38 | ||
|
|
4728c64d3f | ||
|
|
0fdf96e795 | ||
|
|
04f3bee0c4 | ||
|
|
140cfe445a | ||
|
|
da55f26cac | ||
|
|
e24130c6e0 | ||
|
|
2a0fe12836 | ||
|
|
4c153da14d | ||
|
|
81166df987 | ||
|
|
ef189b00be | ||
|
|
27e56afd12 | ||
|
|
1c342621c0 | ||
|
|
9b7098cb58 | ||
|
|
9b247a6197 | ||
|
|
862d0a8f09 | ||
|
|
417996ade0 | ||
|
|
1c6161432a | ||
|
|
3f667ae4a8 | ||
|
|
4fb6de564c | ||
|
|
975693e96e | ||
|
|
b84233bf07 | ||
|
|
6dec2f9a13 | ||
|
|
c15e184bb8 | ||
|
|
89740dbbfd | ||
|
|
390a165c57 | ||
|
|
f7fb43b974 | ||
|
|
a32080fadf | ||
|
|
55017fbf70 | ||
|
|
7fd8a3a75b | ||
|
|
0f38c2ad82 | ||
|
|
5198a2cc9c | ||
|
|
ca5f0d2306 | ||
|
|
9eefd5a5c4 | ||
|
|
a797caaa8a | ||
|
|
11323be04b | ||
|
|
b1f886ef8d | ||
|
|
14e75df9e5 | ||
|
|
dd88335654 | ||
|
|
5183a6581a | ||
|
|
fb7c812e50 | ||
|
|
e690ac0060 | ||
|
|
c2190ca74f | ||
|
|
157b7cb79f | ||
|
|
0d2b1c8d88 | ||
|
|
788cb57ff1 | ||
|
|
1634a50b6d | ||
|
|
805b403ae3 | ||
|
|
1672bcdadd | ||
|
|
30b1eb2dda | ||
|
|
99ce876d50 | ||
|
|
602502e502 | ||
|
|
30a3924f43 | ||
|
|
d2e6b4c45e | ||
|
|
419efa485f | ||
|
|
3bb4240cb4 | ||
|
|
1677efc00c | ||
|
|
c7b3105f9f | ||
|
|
6fceead233 | ||
|
|
4a109648a6 | ||
|
|
ea139e207c | ||
|
|
7b3c8cf245 | ||
|
|
efbe0bbdff | ||
|
|
8f645d8870 | ||
|
|
97007d17cc | ||
|
|
0dbaa34a8b | ||
|
|
2fc537f9a7 | ||
|
|
71cf0aea74 | ||
|
|
f6a3028356 | ||
|
|
3182070f4c | ||
|
|
097b58c7a9 | ||
|
|
9584d05708 | ||
|
|
b9ac59b336 | ||
|
|
43068db22a | ||
|
|
2b7f1954fc | ||
|
|
3b64599aea | ||
|
|
96fe9a239e | ||
|
|
195807643f | ||
|
|
21256e6d55 | ||
|
|
705fa875d3 | ||
|
|
498384357e | ||
|
|
53938e4cf8 | ||
|
|
e18ffcd48e | ||
|
|
df12f86da9 | ||
|
|
3dba7f2bf7 | ||
|
|
e7efe14336 | ||
|
|
78f2103bcb | ||
|
|
565b0ec425 | ||
|
|
253d72bb4d | ||
|
|
35522e9b28 | ||
|
|
4aa9522825 | ||
|
|
a92b0b0ad5 | ||
|
|
66cdccb0e7 | ||
|
|
2eefa44f8f | ||
|
|
55302dd830 | ||
|
|
924eb87157 | ||
|
|
f546bf3352 | ||
|
|
97553334d4 | ||
|
|
31d12b9888 | ||
|
|
dc2658f63b | ||
|
|
b8b9de78fc | ||
|
|
8ff599b1f2 | ||
|
|
e6f02af973 | ||
|
|
a163b5a76d | ||
|
|
cc37c1c360 | ||
|
|
e78771e0f4 | ||
|
|
288bc44dfc | ||
|
|
d3db3926e3 | ||
|
|
5b87ba0adb | ||
|
|
0b2cea7bb7 | ||
|
|
eab4fc3cd5 | ||
|
|
e2e8cd245c | ||
|
|
80d46f0783 | ||
|
|
3b6b63a273 | ||
|
|
8373ae353c | ||
|
|
994794dbad | ||
|
|
d103da3bc5 | ||
|
|
ee3c62683b | ||
|
|
8ac9d5f101 | ||
|
|
1eaa799b9f | ||
|
|
bb0f2dfb21 | ||
|
|
5409c03d58 | ||
|
|
a941930ea7 | ||
|
|
74572101ac | ||
|
|
7b050cae3f | ||
|
|
d193860668 | ||
|
|
1da0be8529 | ||
|
|
08b4346620 | ||
|
|
4d6d9b1fc4 | ||
|
|
59f9add53c | ||
|
|
1946562247 | ||
|
|
24a65ad80f | ||
|
|
37ea60c20c | ||
|
|
99cbcae249 | ||
|
|
a426042784 | ||
|
|
1cb06a6cb2 | ||
|
|
5236871b0b | ||
|
|
ceb20c9dd6 | ||
|
|
83fa42c179 | ||
|
|
7727b9c347 | ||
|
|
b015c0b226 | ||
|
|
882687818f | ||
|
|
37f5163872 | ||
|
|
df2c81d131 | ||
|
|
236398a823 | ||
|
|
eebe02eef2 | ||
|
|
4b1e5a76e2 | ||
|
|
74292d2ace | ||
|
|
b0b7cad316 | ||
|
|
926887d14e | ||
|
|
2e6bb2d276 | ||
|
|
30656509ac | ||
|
|
66e3f1ad7b | ||
|
|
9aadf2d467 | ||
|
|
ba80e57753 | ||
|
|
50c76f41e3 | ||
|
|
0658d58efd | ||
|
|
76a700c45c | ||
|
|
5ded193ccf | ||
|
|
93420d1fa0 | ||
|
|
add787f625 | ||
|
|
ec2c2aca5e | ||
|
|
1e86c48f48 | ||
|
|
545c2dbf9b | ||
|
|
3cb029147e | ||
|
|
d48cfbba69 | ||
|
|
07b5c0daa3 | ||
|
|
b78c84af2c | ||
|
|
dfaa2df90d | ||
|
|
2e2aaa4ac9 | ||
|
|
3ef0f74110 | ||
|
|
51ba7198d7 | ||
|
|
e10e6a2235 | ||
|
|
17f84cf7d6 | ||
|
|
f7d9b7b297 | ||
|
|
b287d7a4e0 | ||
|
|
d9d1b91fa5 | ||
|
|
a7436edb50 | ||
|
|
89fe2cb3e1 | ||
|
|
0e8e8cc0aa | ||
|
|
6d7cef80a3 | ||
|
|
5a744b73f5 | ||
|
|
b8b53e262a | ||
|
|
3f74ea369d | ||
|
|
28f80da648 | ||
|
|
a7260dfed3 | ||
|
|
5cd1b5eee3 | ||
|
|
9d627c6917 | ||
|
|
66dbcaf432 | ||
|
|
39c39ad4bf | ||
|
|
1ad3822421 | ||
|
|
0929787398 | ||
|
|
756016a433 | ||
|
|
c6e3c332ac | ||
|
|
7c95a6b4b2 | ||
|
|
16d234b11d | ||
|
|
70066fc8be | ||
|
|
d27691adef | ||
|
|
97d64f4054 | ||
|
|
1fe8f24313 | ||
|
|
790231ea18 | ||
|
|
1f24d00ee9 | ||
|
|
3f8ffac11c | ||
|
|
137bee4db0 | ||
|
|
5484dd6074 | ||
|
|
8fd3fe0a3a | ||
|
|
ce93a92dc8 | ||
|
|
3f4ed25bb9 | ||
|
|
02b54ac216 | ||
|
|
4b44fecc69 | ||
|
|
1c082665e8 | ||
|
|
7cec953e9a | ||
|
|
868178fb37 | ||
|
|
8439f4fbc9 | ||
|
|
78644d299f | ||
|
|
0d0134dae5 | ||
|
|
b03d31561c | ||
|
|
0e85408741 | ||
|
|
4107b7e611 | ||
|
|
6cbddf5b8e | ||
|
|
a3f4136ac8 | ||
|
|
6926209e53 | ||
|
|
efc97f3bdb | ||
|
|
f72cff08a1 | ||
|
|
7b98b074f4 | ||
|
|
40b02895f7 | ||
|
|
1f4c1edd99 | ||
|
|
2f320aea43 | ||
|
|
f41696701e | ||
|
|
a9d693d867 | ||
|
|
b92a9cc5a7 | ||
|
|
763cbadaaa | ||
|
|
3cc58e500f | ||
|
|
2a7cb5e1fe | ||
|
|
289d579869 | ||
|
|
067c0e578f | ||
|
|
520c63fd3e | ||
|
|
221e01568c | ||
|
|
b8cff20089 | ||
|
|
c99ec44b92 | ||
|
|
90501af961 | ||
|
|
4c317292d2 | ||
|
|
16cf32ae73 | ||
|
|
1ec9e83c3b | ||
|
|
3b1b96e71f | ||
|
|
2f081de936 | ||
|
|
954534df42 | ||
|
|
60032049ec | ||
|
|
7a63315be5 | ||
|
|
295d49681b | ||
|
|
c93614ec55 | ||
|
|
e8e135431c | ||
|
|
42a487e695 | ||
|
|
2a062dba96 | ||
|
|
f923181857 | ||
|
|
412366f2f6 | ||
|
|
6c57bbd57b | ||
|
|
42cf2cb997 | ||
|
|
e383931af6 | ||
|
|
9827171bf7 |
No files matched your search
@@ -1,12 +1,13 @@
|
||||
name: notify-slack-on-fail-or-recover
|
||||
description: Notify a Slack channel when a workflow fails, or when it recovers after a previous failure.
|
||||
|
||||
inputs:
|
||||
channel:
|
||||
description: Slack channel to send the notification to.
|
||||
required: true
|
||||
type: string
|
||||
slack_webhook:
|
||||
description: Slack incoming webhook URL used to post the notification.
|
||||
required: true
|
||||
type: string
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
name: setup-gcloud
|
||||
description: Authenticate to Google Cloud via workload identity federation and set up the gcloud CLI.
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
|
||||
@@ -27,6 +27,9 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-mise
|
||||
|
||||
- name: Install eas-cli
|
||||
run: npm install --global eas-cli@latest
|
||||
|
||||
- uses: ./.github/internal-actions/setup-gcloud
|
||||
|
||||
- name: Check if worker already exists
|
||||
@@ -38,7 +41,7 @@ jobs:
|
||||
for platform in android ios; do
|
||||
GCP_TARBALL="worker-$platform-sha1-$GITHUB_SHA.tar.gz"
|
||||
|
||||
if ! gsutil stat gs://eas-build-worker-tarballs/$GCP_TARBALL; then
|
||||
if ! gsutil stat "gs://eas-build-worker-tarballs/$GCP_TARBALL"; then
|
||||
echo "worker_exists=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
@@ -61,7 +64,7 @@ jobs:
|
||||
SHOULD_UPLOAD_ARTIFACT="${{ inputs.environment != '' && steps.check_worker_exists.outputs.worker_exists == 'false' }}"
|
||||
|
||||
# Run workflow and capture output (includes run ID)
|
||||
WORKFLOW_OUTPUT=$(npx -y eas-cli@latest workflow:run build-worker.yml \
|
||||
WORKFLOW_OUTPUT=$(eas workflow:run build-worker.yml \
|
||||
--ref ${{ github.sha }} \
|
||||
--input upload_artifact=$SHOULD_UPLOAD_ARTIFACT \
|
||||
--non-interactive \
|
||||
@@ -96,7 +99,7 @@ jobs:
|
||||
SHOULD_UPLOAD_ARTIFACT="${{ inputs.environment != '' && steps.check_worker_exists.outputs.worker_exists == 'false' }}"
|
||||
|
||||
while true; do
|
||||
VIEW_OUTPUT=$(npx -y eas-cli@latest workflow:view "$WORKFLOW_RUN_ID" \
|
||||
VIEW_OUTPUT=$(eas workflow:view "$WORKFLOW_RUN_ID" \
|
||||
--non-interactive \
|
||||
--json)
|
||||
STATUS=$(echo "$VIEW_OUTPUT" | jq -r '.status')
|
||||
@@ -181,7 +184,7 @@ jobs:
|
||||
|
||||
WORKFLOW_RUN_ID="${{ steps.eas_workflow.outputs.workflow_run_id }}"
|
||||
if [ -n "$WORKFLOW_RUN_ID" ]; then
|
||||
npx -y eas-cli@latest workflow:cancel "$WORKFLOW_RUN_ID" --non-interactive
|
||||
eas workflow:cancel "$WORKFLOW_RUN_ID" --non-interactive
|
||||
fi
|
||||
|
||||
- name: Upload worker tarballs to GCP
|
||||
@@ -191,8 +194,8 @@ jobs:
|
||||
run: |
|
||||
IOS_GCP_TARBALL="worker-ios-sha1-$GITHUB_SHA.tar.gz"
|
||||
ANDROID_GCP_TARBALL="worker-android-sha1-$GITHUB_SHA.tar.gz"
|
||||
curl -L "${{ steps.eas_artifacts.outputs.download_ios_url }}" | gsutil cp - gs://eas-build-worker-tarballs/$IOS_GCP_TARBALL
|
||||
curl -L "${{ steps.eas_artifacts.outputs.download_android_url }}" | gsutil cp - gs://eas-build-worker-tarballs/$ANDROID_GCP_TARBALL
|
||||
curl -L "${{ steps.eas_artifacts.outputs.download_ios_url }}" | gsutil cp - "gs://eas-build-worker-tarballs/$IOS_GCP_TARBALL"
|
||||
curl -L "${{ steps.eas_artifacts.outputs.download_android_url }}" | gsutil cp - "gs://eas-build-worker-tarballs/$ANDROID_GCP_TARBALL"
|
||||
|
||||
- name: Promote worker to ${{ inputs.environment }}
|
||||
if: inputs.environment != ''
|
||||
@@ -201,5 +204,5 @@ jobs:
|
||||
GCP_TARBALL="worker-$platform-sha1-$GITHUB_SHA.tar.gz"
|
||||
WORKER_STAGING_TARBALL="worker-$platform-${{ inputs.environment }}.tar.gz"
|
||||
|
||||
gsutil cp gs://eas-build-worker-tarballs/$GCP_TARBALL gs://eas-build-worker-tarballs/$WORKER_STAGING_TARBALL
|
||||
gsutil cp "gs://eas-build-worker-tarballs/$GCP_TARBALL" "gs://eas-build-worker-tarballs/$WORKER_STAGING_TARBALL"
|
||||
done
|
||||
@@ -31,7 +31,9 @@ jobs:
|
||||
script: |
|
||||
core.setFailed('Please add a changelog entry!')
|
||||
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
|
||||
if: always()
|
||||
if: >-
|
||||
always() &&
|
||||
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
|
||||
with:
|
||||
message-id: changelog-entry-check
|
||||
refresh-message-position: true
|
||||
|
||||
@@ -60,9 +60,11 @@ jobs:
|
||||
elif [ "$rest" != "all" ]; then
|
||||
agents=$(printf '%s' "$rest" | tr ' ' ',' | tr -cd 'a-zA-Z0-9,_-')
|
||||
fi
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
echo "agents=$agents" >> "$GITHUB_OUTPUT"
|
||||
echo "route=$route" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "run=true"
|
||||
echo "agents=$agents"
|
||||
echo "route=$route"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Acknowledge
|
||||
if: steps.cmd.outputs.run == 'true'
|
||||
@@ -137,10 +139,10 @@ jobs:
|
||||
AGENTS: ${{ steps.cmd.outputs.agents }}
|
||||
ROUTE: ${{ steps.cmd.outputs.route }}
|
||||
run: |
|
||||
ARGS=""
|
||||
ARGS=()
|
||||
if [ -n "$AGENTS" ]; then
|
||||
ARGS="--agents $AGENTS"
|
||||
ARGS=(--agents "$AGENTS")
|
||||
elif [ "$ROUTE" = "true" ]; then
|
||||
ARGS="--route"
|
||||
ARGS=(--route)
|
||||
fi
|
||||
npx --yes -p "@expo/code-review-cli@$ECR_VERSION" ecr ci $ARGS
|
||||
npx --yes -p "@expo/code-review-cli@$ECR_VERSION" ecr ci "${ARGS[@]}"
|
||||
@@ -61,10 +61,12 @@ jobs:
|
||||
if [ -z "$(printf '%s' "$ids" | tr -d ' ')" ]; then
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"; exit 0
|
||||
fi
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
echo "sub=$sub" >> "$GITHUB_OUTPUT"
|
||||
echo "ids=$ids" >> "$GITHUB_OUTPUT"
|
||||
echo "reason=$reason" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "run=true"
|
||||
echo "sub=$sub"
|
||||
echo "ids=$ids"
|
||||
echo "reason=$reason"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Acknowledge
|
||||
if: steps.cmd.outputs.run == 'true'
|
||||
|
||||
@@ -74,9 +74,11 @@ jobs:
|
||||
else
|
||||
route=true # bare `ai-review` => let the router pick
|
||||
fi
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
echo "agents=$agents" >> "$GITHUB_OUTPUT"
|
||||
echo "route=$route" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "run=true"
|
||||
echo "agents=$agents"
|
||||
echo "route=$route"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# SECURITY: check out the PR's immutable BASE commit, never the PR head or
|
||||
# merge ref — everything on this runner's disk comes from a commit that
|
||||
@@ -172,13 +174,13 @@ jobs:
|
||||
AGENTS: ${{ steps.resolve.outputs.agents }}
|
||||
ROUTE: ${{ steps.resolve.outputs.route }}
|
||||
run: |
|
||||
ARGS=""
|
||||
ARGS=()
|
||||
if [ -n "$AGENTS" ]; then
|
||||
ARGS="--agents $AGENTS"
|
||||
ARGS=(--agents "$AGENTS")
|
||||
elif [ "$ROUTE" = "true" ]; then
|
||||
ARGS="--route"
|
||||
ARGS=(--route)
|
||||
fi
|
||||
npx --yes -p "@expo/code-review-cli@$ECR_VERSION" ecr ci $ARGS
|
||||
npx --yes -p "@expo/code-review-cli@$ECR_VERSION" ecr ci "${ARGS[@]}"
|
||||
|
||||
# Persist the run log (decision, per-agent cost, duration, token/cache
|
||||
# metrics) so cost/latency/cache reuse are observable over time — CI is
|
||||
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
|
||||
jobs:
|
||||
needs-repro:
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-latest
|
||||
if: "${{ contains(github.event.label.name, 'incomplete issue: missing or invalid repro') }}"
|
||||
steps:
|
||||
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
})
|
||||
|
||||
needs-info:
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-latest
|
||||
if: "${{ contains(github.event.label.name, 'incomplete issue: missing info') }}"
|
||||
steps:
|
||||
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
})
|
||||
|
||||
issue-accepted:
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.label.name == 'issue accepted'
|
||||
steps:
|
||||
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
|
||||
@@ -95,7 +95,7 @@ jobs:
|
||||
`})
|
||||
|
||||
question:
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-latest
|
||||
if: "${{ contains(github.event.label.name, 'invalid issue: question') }}"
|
||||
steps:
|
||||
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
})
|
||||
|
||||
feature-request:
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-latest
|
||||
if: "${{ contains(github.event.label.name, 'invalid issue: feature request') }}"
|
||||
steps:
|
||||
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
|
||||
|
||||
+121
-29
@@ -5,6 +5,14 @@ on:
|
||||
|
||||
name: Release workflow
|
||||
|
||||
# Releases push commits to main, so they must never overlap. The group is a
|
||||
# constant rather than per-ref, so two different tags serialize. Queued runs
|
||||
# are never cancelled, because a half-finished release leaves npm, the git
|
||||
# history, and cli-versions.json out of sync.
|
||||
concurrency:
|
||||
group: release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Create a release
|
||||
@@ -37,20 +45,6 @@ jobs:
|
||||
run: yarn build
|
||||
- name: Publish packages to npm
|
||||
run: yarn run -T lerna publish from-package --yes --no-verify-access
|
||||
- name: Verify eas-cli was published as latest on npm
|
||||
run: |
|
||||
expected="$(jq -r .version lerna.json)"
|
||||
for attempt in $(seq 1 10); do
|
||||
published="$(npm view eas-cli@latest version 2>/dev/null || true)"
|
||||
if [ "$published" = "$expected" ]; then
|
||||
echo "Verified eas-cli@$expected is latest on npm."
|
||||
exit 0
|
||||
fi
|
||||
echo "Attempt $attempt: npm latest is '$published', expected '$expected'. Retrying in 15s..."
|
||||
sleep 15
|
||||
done
|
||||
echo "eas-cli@$expected was not published as latest on npm; the publish step failed." >&2
|
||||
exit 1
|
||||
release-changelog:
|
||||
name: Update changelog and publish release
|
||||
runs-on: ubuntu-latest
|
||||
@@ -61,38 +55,136 @@ jobs:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
token: ${{ secrets.EXPO_BOT_PAT }}
|
||||
# The changelog is generated on the branch it is committed to, so the
|
||||
# job never switches branches with a dirty working tree.
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
- uses: ./.github/actions/setup-mise
|
||||
- name: Install dependencies
|
||||
working-directory: ./scripts
|
||||
run: yarn install --immutable
|
||||
- name: Resolve version
|
||||
run: echo "EAS_CLI_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
|
||||
run: echo "EAS_CLI_VERSION=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_ENV"
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config user.email "[email protected]"
|
||||
git config user.name "Expo CI"
|
||||
- name: Check whether the changelog is already updated
|
||||
run: |
|
||||
if grep -qF "## [$EAS_CLI_VERSION](" CHANGELOG.md; then
|
||||
echo "CHANGELOG.md already has an entry for v$EAS_CLI_VERSION."
|
||||
echo "CHANGELOG_ALREADY_UPDATED=true" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "CHANGELOG_ALREADY_UPDATED=false" >> "$GITHUB_ENV"
|
||||
fi
|
||||
- name: Update changelog
|
||||
if: env.CHANGELOG_ALREADY_UPDATED == 'false'
|
||||
working-directory: ./scripts
|
||||
run: yarn release-changelog $EAS_CLI_VERSION > /tmp/current-changelog
|
||||
run: yarn release-changelog "$EAS_CLI_VERSION" > /tmp/current-changelog
|
||||
- name: Reuse the existing changelog entry
|
||||
if: env.CHANGELOG_ALREADY_UPDATED == 'true'
|
||||
run: |
|
||||
{
|
||||
echo "v$EAS_CLI_VERSION"
|
||||
echo
|
||||
awk -v heading="## [$EAS_CLI_VERSION](" '
|
||||
index($0, heading) == 1 { found = 1; next }
|
||||
found && /^## / { exit }
|
||||
found { print }
|
||||
' CHANGELOG.md | sed '/./,$!d'
|
||||
} > /tmp/current-changelog
|
||||
- name: Publish release
|
||||
run: gh release edit v$EAS_CLI_VERSION --draft=false -F /tmp/current-changelog
|
||||
run: gh release edit "v$EAS_CLI_VERSION" --draft=false -F /tmp/current-changelog
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Commit and push
|
||||
- name: Commit and push changelog
|
||||
run: |
|
||||
git fetch
|
||||
git checkout main
|
||||
git add CHANGELOG.md
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changelog changes to commit."
|
||||
else
|
||||
git commit -m "update CHANGELOG.md for v$EAS_CLI_VERSION"
|
||||
git pull --rebase --autostash origin main
|
||||
git push
|
||||
fi
|
||||
bump-cli-versions:
|
||||
name: Gate on npm and bump cli-versions.json
|
||||
runs-on: ubuntu-latest
|
||||
needs: release-changelog
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
token: ${{ secrets.EXPO_BOT_PAT }}
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
- uses: ./.github/actions/setup-mise
|
||||
- name: Resolve version
|
||||
run: echo "EAS_CLI_VERSION=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_ENV"
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config user.email "[email protected]"
|
||||
git config user.name "Expo CI"
|
||||
- name: Verify eas-cli is available on npm
|
||||
run: |
|
||||
availability_timeout_seconds=1800
|
||||
availability_deadline_epoch=$(( $(date +%s) + availability_timeout_seconds ))
|
||||
|
||||
while true; do
|
||||
published="$(npm view "eas-cli@$EAS_CLI_VERSION" version --prefer-online 2>/dev/null || true)"
|
||||
if [ "$published" = "$EAS_CLI_VERSION" ]; then
|
||||
echo "Verified eas-cli@$EAS_CLI_VERSION is available on npm."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
current_epoch=$(date +%s)
|
||||
if [ "$current_epoch" -ge "$availability_deadline_epoch" ]; then
|
||||
break
|
||||
fi
|
||||
|
||||
retry_delay=$((availability_deadline_epoch - current_epoch))
|
||||
if [ "$retry_delay" -gt 10 ]; then
|
||||
retry_delay=10
|
||||
fi
|
||||
echo "eas-cli@$EAS_CLI_VERSION is not available on npm yet. Retrying in ${retry_delay}s."
|
||||
sleep "$retry_delay"
|
||||
done
|
||||
|
||||
echo "eas-cli@$EAS_CLI_VERSION was not available on npm after ${availability_timeout_seconds}s." >&2
|
||||
exit 1
|
||||
- name: Bump STAGING and PRODUCTION
|
||||
run: |
|
||||
git pull --rebase --autostash origin main
|
||||
jq --arg version "$EAS_CLI_VERSION" '.STAGING = $version' \
|
||||
cli-versions.json > cli-versions.json.tmp
|
||||
mv cli-versions.json.tmp cli-versions.json
|
||||
git config --global user.email "[email protected]"
|
||||
git config --global user.name "Expo CI"
|
||||
git add cli-versions.json CHANGELOG.md
|
||||
git commit --allow-empty -m "update CHANGELOG.md and bump STAGING in cli-versions.json"
|
||||
git push
|
||||
jq --arg version "$EAS_CLI_VERSION" '.PRODUCTION = $version' \
|
||||
cli-versions.json > cli-versions.json.tmp
|
||||
mv cli-versions.json.tmp cli-versions.json
|
||||
- name: Commit and push version update
|
||||
run: |
|
||||
git add cli-versions.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "cli-versions.json already points at $EAS_CLI_VERSION."
|
||||
else
|
||||
git commit -m "bump STAGING and PRODUCTION to $EAS_CLI_VERSION in cli-versions.json"
|
||||
git pull --rebase --autostash origin main
|
||||
git push
|
||||
fi
|
||||
- name: Prepare changelog for Slack
|
||||
run: |
|
||||
tail -n +3 /tmp/current-changelog > /tmp/changelog.md
|
||||
awk -v heading="## [$EAS_CLI_VERSION](" '
|
||||
index($0, heading) == 1 { found = 1; next }
|
||||
found && /^## / { exit }
|
||||
found { print }
|
||||
' CHANGELOG.md | sed '/./,$!d' > /tmp/changelog.md
|
||||
npx slackmd /tmp/changelog.md
|
||||
echo 'SLACK_CHANGELOG<<EOF' >> $GITHUB_ENV
|
||||
cat /tmp/changelog.slack.md >> $GITHUB_ENV
|
||||
echo 'EOF' >> $GITHUB_ENV
|
||||
{
|
||||
echo 'SLACK_CHANGELOG<<EOF'
|
||||
cat /tmp/changelog.slack.md
|
||||
echo 'EOF'
|
||||
} >> "$GITHUB_ENV"
|
||||
- name: Slack Notification
|
||||
uses: rtCamp/action-slack-notify@e31e87e03dd19038e411e38ae27cbad084a90661 # v2
|
||||
env:
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
name: test-egress-guard
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
pull_request:
|
||||
paths:
|
||||
- packages/build-tools/resources/egress-guard/**
|
||||
- packages/build-tools/src/steps/utils/localEgressGuard.ts
|
||||
- packages/build-tools/src/steps/utils/__tests__/localEgressGuard*.ts
|
||||
- packages/build-tools/src/steps/functions/startIosSimulator.ts
|
||||
- packages/worker/.eas/workflows/test-egress-guard.yml
|
||||
- .github/workflows/test-egress-guard.yml
|
||||
|
||||
jobs:
|
||||
test-egress-guard:
|
||||
# The simulator tests need macOS with Xcode, which the EAS macOS workers have.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
- uses: ./.github/actions/setup-mise
|
||||
- name: Install eas-cli
|
||||
run: npm install --global eas-cli@latest
|
||||
- name: Run the test-egress-guard EAS workflow on macOS
|
||||
working-directory: packages/worker
|
||||
env:
|
||||
EXPO_TOKEN: ${{ secrets.EXPO_DEV_EXPO_GITHUB_ROBOT_ACCESS_TOKEN }}
|
||||
run: |
|
||||
eas workflow:run test-egress-guard.yml \
|
||||
--ref ${{ github.event.pull_request.head.sha || github.sha }} \
|
||||
--non-interactive \
|
||||
--wait
|
||||
@@ -15,9 +15,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: ['20', '22', '24']
|
||||
node: ['20.18.3', '22', '24']
|
||||
include:
|
||||
- node: '20'
|
||||
- node: '20.18.3'
|
||||
coverage: true
|
||||
name: Test with Node ${{ matrix.node }}
|
||||
steps:
|
||||
@@ -26,6 +26,9 @@ jobs:
|
||||
uses: ./.github/actions/setup-mise
|
||||
env:
|
||||
MISE_NODE_VERSION: ${{ matrix.node }}
|
||||
- name: Install ffmpeg
|
||||
# build-tools recording-recovery tests cut and decode real MP4 files with ffmpeg and ffprobe.
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
env:
|
||||
@@ -60,6 +63,7 @@ jobs:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
- run: yarn lint
|
||||
- run: yarn fmt:check
|
||||
- run: yarn actionlint
|
||||
- name: Install scripts dependencies
|
||||
if: ${{ matrix.coverage }}
|
||||
run: yarn install --immutable
|
||||
|
||||
@@ -42,5 +42,5 @@ jobs:
|
||||
yarn release
|
||||
else
|
||||
echo "Releasing with version $INPUT_VERSION"
|
||||
yarn release $INPUT_VERSION
|
||||
yarn release "$INPUT_VERSION"
|
||||
fi
|
||||
@@ -0,0 +1,90 @@
|
||||
name: Update Appium command list
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * 1' # Every Monday at 06:00 UTC
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- .github/workflows/update-appium-commands.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
generate:
|
||||
runs-on: ubuntu-latest
|
||||
name: Regenerate Appium command list
|
||||
outputs:
|
||||
changed: ${{ steps.diff.outputs.changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup tools
|
||||
uses: ./.github/actions/setup-mise
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Regenerate the Appium command list
|
||||
# The generator installs Appium, XCUITest, and UiAutomator2 on demand,
|
||||
# so it always picks up the latest supported command sets.
|
||||
run: yarn workspace @expo/build-tools generate-appium-commands
|
||||
- name: Check whether the command list changed
|
||||
id: diff
|
||||
run: |
|
||||
if git diff --quiet -- packages/build-tools/src/steps/utils/appiumCommands.generated.ts; then
|
||||
echo 'changed=false' >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo 'changed=true' >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Upload the generated command list
|
||||
if: steps.diff.outputs.changed == 'true'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: appium-command-list
|
||||
path: packages/build-tools/src/steps/utils/appiumCommands.generated.ts
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
publish:
|
||||
needs: generate
|
||||
if: needs.generate.outputs.changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
name: Publish Appium command list update
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Download the generated command list
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: appium-command-list
|
||||
path: packages/build-tools/src/steps/utils
|
||||
- name: Open a pull request
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.EXPO_BOT_PAT }}
|
||||
run: |
|
||||
branch=bot/update-appium-commands
|
||||
title='[build-tools] Update Appium command list'
|
||||
body=$'Automated update of the Appium, XCUITest, and UiAutomator2 command list.\n\nIf this PR adds new commands, typechecking will fail until a curated summary is added for each of them in `packages/build-tools/src/steps/utils/appiumCommandSummary.ts`.'
|
||||
|
||||
gh auth setup-git
|
||||
if git ls-remote --exit-code --heads origin "$branch" >/dev/null; then
|
||||
git fetch origin "refs/heads/$branch:refs/remotes/origin/$branch"
|
||||
fi
|
||||
|
||||
git config user.name 'Expo Bot'
|
||||
git config user.email [email protected]
|
||||
git checkout -B "$branch"
|
||||
git add packages/build-tools/src/steps/utils/appiumCommands.generated.ts
|
||||
git commit -m "$title"
|
||||
git push --force-with-lease origin "$branch"
|
||||
|
||||
if gh pr view "$branch" --json state --jq '.state' 2>/dev/null | grep -q OPEN; then
|
||||
gh pr edit "$branch" --title "$title" --body "$body" --add-reviewer sjchmiela
|
||||
else
|
||||
gh pr create --base main --head "$branch" --title "$title" --body "$body" \
|
||||
--label 'no changelog' --reviewer sjchmiela
|
||||
fi
|
||||
@@ -15,12 +15,15 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-mise
|
||||
|
||||
- name: Install eas-cli
|
||||
run: npm install --global eas-cli@latest
|
||||
|
||||
- name: Create test app
|
||||
run: npx -y create-expo-app@latest ${{ runner.temp }}/test-app --template tabs
|
||||
|
||||
- name: Link to EAS project
|
||||
working-directory: ${{ runner.temp }}/test-app
|
||||
run: npx -y eas-cli@latest init --id 6ae8028d-4e6d-4912-8eb2-87d4e6122880 --force --non-interactive
|
||||
run: eas init --id 6ae8028d-4e6d-4912-8eb2-87d4e6122880 --force --non-interactive
|
||||
|
||||
- name: Add bundle identifiers to app.json
|
||||
working-directory: ${{ runner.temp }}/test-app
|
||||
@@ -30,7 +33,7 @@ jobs:
|
||||
|
||||
- name: Configure EAS Build
|
||||
working-directory: ${{ runner.temp }}/test-app
|
||||
run: npx -y eas-cli@latest build:configure --platform all
|
||||
run: eas build:configure --platform all
|
||||
|
||||
- name: Add system-test profile to eas.json
|
||||
working-directory: ${{ runner.temp }}/test-app
|
||||
@@ -49,7 +52,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BUILD_JSON=$(npx -y eas-cli@latest build --platform ios --profile system-test --non-interactive --no-wait --json)
|
||||
BUILD_JSON=$(eas build --platform ios --profile system-test --non-interactive --no-wait --json)
|
||||
BUILD_ID=$(echo "$BUILD_JSON" | jq -r 'if type=="array" then .[0].id // .[0].build.id // .[0].buildId else .id // .build.id // .buildId end // empty')
|
||||
if [ -z "$BUILD_ID" ]; then
|
||||
echo "Error: could not determine build ID"
|
||||
@@ -59,7 +62,7 @@ jobs:
|
||||
echo "BUILD_ID=$BUILD_ID" >> "$GITHUB_ENV"
|
||||
echo "Resolved BUILD_ID=$BUILD_ID"
|
||||
|
||||
WORKFLOW_JSON=$(npx -y eas-cli@latest workflow:run build-android.yml --non-interactive --no-wait --json)
|
||||
WORKFLOW_JSON=$(eas workflow:run build-android.yml --non-interactive --no-wait --json)
|
||||
WORKFLOW_RUN_ID=$(echo "$WORKFLOW_JSON" | jq -r '.id // .workflowRun.id // .workflowRunId // empty')
|
||||
if [ -z "$WORKFLOW_RUN_ID" ]; then
|
||||
echo "Error: could not determine workflow run ID"
|
||||
@@ -95,8 +98,8 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BUILD_STATUS=$(npx -y eas-cli@latest build:view "$BUILD_ID" --json | jq -r '.status // empty')
|
||||
WORKFLOW_STATUS=$(npx -y eas-cli@latest workflow:view "$WORKFLOW_RUN_ID" --non-interactive --json | jq -r '.status // empty')
|
||||
BUILD_STATUS=$(eas build:view "$BUILD_ID" --json | jq -r '.status // empty')
|
||||
WORKFLOW_STATUS=$(eas workflow:view "$WORKFLOW_RUN_ID" --non-interactive --json | jq -r '.status // empty')
|
||||
|
||||
echo "iOS build status: $BUILD_STATUS"
|
||||
echo "Android workflow status: $WORKFLOW_STATUS"
|
||||
@@ -130,13 +133,13 @@ jobs:
|
||||
|
||||
if [ -n "${BUILD_ID:-}" ]; then
|
||||
echo "Canceling EAS build: $BUILD_ID"
|
||||
BUILD_CANCEL_OUTPUT=$(npx -y eas-cli@latest build:cancel "$BUILD_ID" --non-interactive 2>&1 || true)
|
||||
BUILD_CANCEL_OUTPUT=$(eas build:cancel "$BUILD_ID" --non-interactive 2>&1 || true)
|
||||
if [ -n "$BUILD_CANCEL_OUTPUT" ]; then
|
||||
echo "$BUILD_CANCEL_OUTPUT"
|
||||
else
|
||||
echo "build:cancel produced no output"
|
||||
fi
|
||||
BUILD_STATUS=$(npx -y eas-cli@latest build:view "$BUILD_ID" --json 2>/dev/null | jq -r '.status // empty' || true)
|
||||
BUILD_STATUS=$(eas build:view "$BUILD_ID" --json 2>/dev/null | jq -r '.status // empty' || true)
|
||||
if [ -n "$BUILD_STATUS" ]; then
|
||||
echo "Post-cancel build status: $BUILD_STATUS"
|
||||
else
|
||||
@@ -145,13 +148,13 @@ jobs:
|
||||
fi
|
||||
if [ -n "${WORKFLOW_RUN_ID:-}" ]; then
|
||||
echo "Canceling EAS workflow run: $WORKFLOW_RUN_ID"
|
||||
WORKFLOW_CANCEL_OUTPUT=$(npx -y eas-cli@latest workflow:cancel "$WORKFLOW_RUN_ID" --non-interactive 2>&1 || true)
|
||||
WORKFLOW_CANCEL_OUTPUT=$(eas workflow:cancel "$WORKFLOW_RUN_ID" --non-interactive 2>&1 || true)
|
||||
if [ -n "$WORKFLOW_CANCEL_OUTPUT" ]; then
|
||||
echo "$WORKFLOW_CANCEL_OUTPUT"
|
||||
else
|
||||
echo "workflow:cancel produced no output"
|
||||
fi
|
||||
WORKFLOW_STATUS=$(npx -y eas-cli@latest workflow:view "$WORKFLOW_RUN_ID" --non-interactive --json 2>/dev/null | jq -r '.status // empty' || true)
|
||||
WORKFLOW_STATUS=$(eas workflow:view "$WORKFLOW_RUN_ID" --non-interactive --json 2>/dev/null | jq -r '.status // empty' || true)
|
||||
if [ -n "$WORKFLOW_STATUS" ]; then
|
||||
echo "Post-cancel workflow status: $WORKFLOW_STATUS"
|
||||
else
|
||||
|
||||
+369
@@ -8,15 +8,383 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--network-capture` to `eas simulator` to record HTTP(S) traffic from apps on the device, and `--network-capture-field` to also keep headers, query values, or request and response bodies. ([#4308](https://github.com/expo/eas-cli/pull/4308) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [24.9.0](https://github.com/expo/eas-cli/releases/tag/v24.9.0) - 2026-10-02
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--force-end-active-rollout` to `eas update`, `eas update:republish`, `eas update:roll-back-to-embedded` and `eas update:rollback`. ([#4233](https://github.com/expo/eas-cli/pull/4233) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Allow `eas simulator:start --platform android --egress local`. On Android, `--egress-allow localhost:<port>` also covers `10.0.2.2:<port>`, and refused destinations are shown as warnings. ([#4519](https://github.com/expo/eas-cli/pull/4519) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [build-tools] Support `eas/start_local_egress` on Linux for Android emulators: an always-listening relay for the emulator's proxy, a systemd scope and nftables fence that refuse traffic that cannot use the tunnel, and fence refusals in the session log. ([#4519](https://github.com/expo/eas-cli/pull/4519) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Add `--upload-source-maps` to `eas update`, which uploads the generated source maps to EAS so that stack traces from an update can be symbolicated. Source maps are stripped of `sourcesContent` before upload. ([#4436](https://github.com/expo/eas-cli/pull/4436) by [@douglowder](https://github.com/douglowder))
|
||||
- [build-tools] Upload the embedded bundle after build by default for projects on SDK 58 and later. Set `EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE` to `0` to disable it. On SDK 57 and below the feature remains experimental and opt-in via `EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE`. ([#4433](https://github.com/expo/eas-cli/pull/4433) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [build-tools] Save manual iOS and Android live preview screenshots as session artifacts when supported by the preview server. ([#4481](https://github.com/expo/eas-cli/pull/4481) by [@vonovak](https://github.com/vonovak))
|
||||
- [build-tools] Add the `eas/install_mitmproxy` step. ([#4307](https://github.com/expo/eas-cli/pull/4307) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Start serve-sim with `--network-capture`, and with `--network-capture-field` for each requested field, when a simulator session requests network capture. ([#4307](https://github.com/expo/eas-cli/pull/4307) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Allow workflow submissions to skip loading app config when project and application identifiers are supplied. ([#4471](https://github.com/expo/eas-cli/pull/4471) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Record Android device sessions and upload the screen recording, including the partial recording a killed Device Hub leaves behind. ([#4418](https://github.com/expo/eas-cli/pull/4418) by [@vonovak](https://github.com/vonovak))
|
||||
- [build-tools] Add start and collect steps that attach bounded simulator logs from serve-sim to device run sessions. ([#4463](https://github.com/expo/eas-cli/pull/4463) by [@sjkim-expo](https://github.com/sjkim-expo))
|
||||
- [build-tools] Add `eas/start_agent_device_session`, one step for agent-device sessions that boots the device, downloads, installs and launches the app, and starts the session, with the independent parts in parallel. ([#4491](https://github.com/expo/eas-cli/pull/4491) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-build-job] Add `sentry` workflow interpolation context schema for Sentry-triggered workflow runs. ([#4453](https://github.com/expo/eas-cli/pull/4453) by [@sswrk](https://github.com/sswrk))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Stop `eas update:rollback` from always forcing non-interactive mode. ([#4504](https://github.com/expo/eas-cli/pull/4504) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Pass the step cancellation signal to `eas/download_build`. ([#4511](https://github.com/expo/eas-cli/pull/4511) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Accept `null` for the TestFlight group `hasAccessToAllBuilds` attribute from App Store Connect. ([#4528](https://github.com/expo/eas-cli/pull/4528) by [@sswrk](https://github.com/sswrk))
|
||||
- [eas-cli] Support `queued` and `starting` status filters in `eas simulator:list` and keep simulator commands compatible with the current GraphQL schema. ([#4527](https://github.com/expo/eas-cli/pull/4527) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Explain how to resolve missing or expired Apple agreements when App Store Connect rejects a request. ([#4513](https://github.com/expo/eas-cli/pull/4513) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] Correct the `eas simulator:start --max-duration-minutes` help text: every plan can set a custom duration up to the plan's maximum, which is also the default. ([#4524](https://github.com/expo/eas-cli/pull/4524) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Fix `eas workflow:validate` crashing with certain workflow schemas, and exit with a non-zero code when validation fails. ([#4507](https://github.com/expo/eas-cli/pull/4507) by [@yuvalgnessin-qz](https://github.com/yuvalgnessin-qz))
|
||||
- [build-tools] Reuse App Store Connect tokens and refresh them before expiry so long uploads and processing waits remain authenticated. ([#4497](https://github.com/expo/eas-cli/pull/4497) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Clear old Maestro CLI HTML reports before each test attempt so retries cannot upload an earlier report. ([#4409](https://github.com/expo/eas-cli/pull/4409) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Poll the web preview readiness every 250 ms instead of every second, so a session goes live sooner after its host is ready. ([#4496](https://github.com/expo/eas-cli/pull/4496) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Keep the `eas simulator` session spinner on one line, so resizing the terminal no longer leaves copies of it behind. The `eas simulator:stop` command is now printed once before the spinner starts. ([#4488](https://github.com/expo/eas-cli/pull/4488) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools] Disable agent-device's internal idle timeouts in remote sessions, so only the session's own idle and duration limits end it. ([#4485](https://github.com/expo/eas-cli/pull/4485) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Keep `eas simulator:events --follow` running while a session is queued or starting. ([#4506](https://github.com/expo/eas-cli/pull/4506) by [@sjkim-expo](https://github.com/sjkim-expo))
|
||||
|
||||
## [24.8.0](https://github.com/expo/eas-cli/releases/tag/v24.8.0) - 2026-09-24
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--build-fingerprint` to `eas simulator` to install the newest build with a given fingerprint. ([#4472](https://github.com/expo/eas-cli/pull/4472) by [@tchayen](https://github.com/tchayen))
|
||||
- [eas-cli] Accept build IDs in `workflow:ssh <id>`. ([#4452](https://github.com/expo/eas-cli/pull/4452) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- [eas-cli] Make `eas simulator:*` commands visible in help output. ([#4456](https://github.com/expo/eas-cli/pull/4456) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools] Add `eas/update_testflight_metadata` to set changelogs and TestFlight groups for an uploaded build with an App Store Connect API key. The step fails if a requested group does not exist. ([#4475](https://github.com/expo/eas-cli/pull/4475) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Pass `--share-url` when launching serve-sim so Share copies the expo.dev preview page instead of the tunnel URL. ([#4434](https://github.com/expo/eas-cli/pull/4434) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `eas integrations:supabase:advisors` to list unresolved Supabase Security and Performance Advisor findings for the linked Supabase project. ([#4342](https://github.com/expo/eas-cli/pull/4342) by [@fiberjw](https://github.com/fiberjw))
|
||||
- [eas-cli] Accept individual App Store Connect API keys for submissions, TestFlight setup, and metadata. ([#4249](https://github.com/expo/eas-cli/pull/4249) by [@sswrk](https://github.com/sswrk))
|
||||
- [build-tools] Add `launch_app_identifier`, `launch_args`, and `open_url` inputs to the serve-sim, agent-device, argent, and Appium remote sessions, so serve-sim launches an installed app before the stream starts. The inputs are iOS only, and Android sessions keep using `eas/launch_application`. ([#4324](https://github.com/expo/eas-cli/pull/4324) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Stream iOS Simulator previews at 1600px and 10 Mbps. ([#4439](https://github.com/expo/eas-cli/pull/4439) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Report early Argent server exits during simulator startup instead of waiting for the full readiness timeout. ([#4454](https://github.com/expo/eas-cli/pull/4454) by [@sjkim-expo](https://github.com/sjkim-expo))
|
||||
- [eas-cli] Stop spinners from printing a new line on every frame after the terminal gets narrower. ([#4470](https://github.com/expo/eas-cli/pull/4470) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Exclude time waiting for simulator concurrency from the session startup timeout and show queue progress. ([#4411](https://github.com/expo/eas-cli/pull/4411) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] List `eas simulator` only once in `eas --help`. The `sim` shorthand is now a hidden alias, so it still runs but no longer duplicates the entry. ([#4457](https://github.com/expo/eas-cli/pull/4457) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
- [build-tools] Start the Argent tool-server with `ARGENT_EMULATOR_NO_WINDOW=1`, so Argent can boot the Android emulator again after it stops. Without it, the windowed emulator fails on the Linux image because `libpulse.so.0` is missing. ([#4465](https://github.com/expo/eas-cli/pull/4465) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Fix `eas account:view` printing `Role: undefined` for the new Release Manager role. ([#4300](https://github.com/expo/eas-cli/pull/4300) by [@byronkarlen](https://github.com/byronkarlen))
|
||||
- [eas-cli] Include legacy serve-sim sessions in `eas simulator:list --type web-preview-only`, and list `web-preview-only` only once in the `--type` options. ([#4466](https://github.com/expo/eas-cli/pull/4466) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Stop creating `CLAUDE.md` in new apps; use `AGENTS.md` for agent instructions. ([#4446](https://github.com/expo/eas-cli/pull/4446) by [@davidmokos](https://github.com/davidmokos))
|
||||
- Upgrade `typescript` to 6.0.3, `ts-jest` to 29.4.12, `@types/node` to 20.19.43, `gql.tada` to 1.11.3, and `https-proxy-agent` to 7.0.6. ([#4400](https://github.com/expo/eas-cli/pull/4400) by [@wschurman](https://github.com/wschurman))
|
||||
- [build-tools] Use the website origin for simulator preview CORS and framing, and allow deploy previews and local website dev servers on staging. ([#4437](https://github.com/expo/eas-cli/pull/4437) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [24.7.0](https://github.com/expo/eas-cli/releases/tag/v24.7.0) - 2026-09-16
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Stream iOS Simulator previews with H.264. ([#4416](https://github.com/expo/eas-cli/pull/4416) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Report the expo.dev preview page as a simulator session's preview URL, and the preview server behind it as `previewApiUrl`. ([#4353](https://github.com/expo/eas-cli/pull/4353) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] `eas simulator:start` and `eas simulator:get` now report the expo.dev preview page instead of the raw tunnel URL, in both the printed instructions and `--json`. ([#4352](https://github.com/expo/eas-cli/pull/4352) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Skip the managed-workflow Expo SDK and Metro config checks for builds that use a custom build config, since those builds run their own steps instead of the standard managed pipeline. ([#4424](https://github.com/expo/eas-cli/pull/4424) by [@douglowder](https://github.com/douglowder))
|
||||
- [build-tools] Honor `EAS_OVERRIDE_PACKAGE_MANAGER` and `EAS_FALLBACK_PACKAGE_MANAGER` when installing and launching simulator packages (serve-sim, device-hub, Argent, agent-device, Appium) so they can use bun instead of stalling on npm audit. ([#4343](https://github.com/expo/eas-cli/pull/4343) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [24.6.0](https://github.com/expo/eas-cli/releases/tag/v24.6.0) - 2026-09-15
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Display logs in `eas build --wait`. ([#4303](https://github.com/expo/eas-cli/pull/4303) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Skip project source refresh for jobs with no sources. ([#4389](https://github.com/expo/eas-cli/pull/4389) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] Stop fetching the primary account in the login query. Commands that need it now fetch it on demand, so scoped access tokens without primary account access no longer fail at login. ([#4380](https://github.com/expo/eas-cli/pull/4380) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Regenerate GraphQL types and update Observe tests for the new `firstSeenAt` and `lastSeenAt` event-name fields. ([#4379](https://github.com/expo/eas-cli/pull/4379) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
## [24.5.0](https://github.com/expo/eas-cli/releases/tag/v24.5.0) - 2026-09-15
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Let connected sandboxes run shell commands through the Expo MCP server. ([#4347](https://github.com/expo/eas-cli/pull/4347) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [24.4.2](https://github.com/expo/eas-cli/releases/tag/v24.4.2) - 2026-09-15
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Support Xcode 27, which replaces Simulator.app with Device Hub, in `build:dev`, `build:run`, and `run` for iOS simulator builds. ([#4405](https://github.com/expo/eas-cli/pull/4405) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [24.4.1](https://github.com/expo/eas-cli/releases/tag/v24.4.1) - 2026-09-15
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Bump `@expo/apple-utils` to `2.2.1`. ([#4404](https://github.com/expo/eas-cli/pull/4404) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [24.4.0](https://github.com/expo/eas-cli/releases/tag/v24.4.0) - 2026-09-14
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- [eas-cli] Require Node `^20.18.3 || >=22.0.0`. ([#4279](https://github.com/expo/eas-cli/pull/4279) by [@ramonclaudio](https://github.com/ramonclaudio))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Inject a guard into every iOS Simulator process during `--egress local` sessions that refuses connections which bypass the system proxy and reports them, with the calling frameworks, in the session log. ([#4393](https://github.com/expo/eas-cli/pull/4393) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [build-tools] Set proxy environment variables inside the iOS Simulator for `--egress local` sessions, so clients that read them (gRPC, libcurl) also exit from the egress client. ([#4390](https://github.com/expo/eas-cli/pull/4390) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Skip project source refresh for jobs with no sources. ([#4389](https://github.com/expo/eas-cli/pull/4389) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [24.3.0](https://github.com/expo/eas-cli/releases/tag/v24.3.0) - 2026-09-11
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-build-job] Add a shared protocol for sandbox daemon commands. ([#4346](https://github.com/expo/eas-cli/pull/4346) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [24.2.0](https://github.com/expo/eas-cli/releases/tag/v24.2.0) - 2026-09-11
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-build-job] Remove repository URLs from Git job inputs and fetch them when resolving project sources. ([#4360](https://github.com/expo/eas-cli/pull/4360) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] Add `eas workflow:insights` to display Workflows Insights for a time range: run counts and success rate compared with the previous period, runs over time, and a per-workflow breakdown, with `--json` output for scripts. ([#4367](https://github.com/expo/eas-cli/pull/4367) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [eas-cli] Add `eas workflow:insights:maestro` to display Maestro test insights for a time range: pass and flake rates, a sortable per-flow table, and a single flow's history with error patterns and recent runs, with `--json` output for scripts. ([#4370](https://github.com/expo/eas-cli/pull/4370) by [@hSATAC](https://github.com/hSATAC))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Reject `--end` without `--start` in the `observe:*`, `update:insights`, `channel:insights`, and `update:view --insights` commands instead of silently falling back to the default time range. ([#4367](https://github.com/expo/eas-cli/pull/4367) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [eas-cli] Reject a time range that does not run forwards in the `observe:*`, `update:insights`, `channel:insights`, `update:view --insights`, and `workflow:insights` commands instead of sending it to the server. ([#4367](https://github.com/expo/eas-cli/pull/4367) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [eas-cli] Reject non-integer values for `--limit` and `--offset` instead of sending them to the server. ([#4367](https://github.com/expo/eas-cli/pull/4367) by [@hSATAC](https://github.com/hSATAC))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [build-tools] Require PATH project sources for local builds. ([#4362](https://github.com/expo/eas-cli/pull/4362) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [24.1.2](https://github.com/expo/eas-cli/releases/tag/v24.1.2) - 2026-09-11
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Shorten the local egress banner printed by `eas simulator:start` and stop mentioning `eas simulator:egress` in interactive mode, where the client runs in the same terminal. ([#4383](https://github.com/expo/eas-cli/pull/4383) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [24.1.1](https://github.com/expo/eas-cli/releases/tag/v24.1.1) - 2026-09-11
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Stop telling interactive `eas simulator:start --egress local` users to run `eas simulator:egress` when the egress client already runs in the same terminal. ([#4382](https://github.com/expo/eas-cli/pull/4382) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [24.1.0](https://github.com/expo/eas-cli/releases/tag/v24.1.0) - 2026-09-10
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--egress-allow <host:port>` to `eas simulator:start` so local egress sessions can reach dev servers on this machine or its network. ([#4369](https://github.com/expo/eas-cli/pull/4369) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli][build-tools] Forward `--egress-allow localhost:<port>` and `127.0.0.1:<port>` entries from the simulator host's loopback interface to this machine (like `adb reverse`), so dev server URLs that use `127.0.0.1` work through local egress. ([#4369](https://github.com/expo/eas-cli/pull/4369) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [build-tools] Add the `eas/start_local_egress` step for iOS simulator sessions with local egress: the device host's system proxy points at a reverse tunnel to the EAS CLI machine, so HTTP(S) requests that honor it exit from that machine and third parties see its IP. ([#4364](https://github.com/expo/eas-cli/pull/4364) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Add `--egress local` to `eas simulator:start` and the `eas simulator:egress` command, which run the local egress client for the session. ([#4364](https://github.com/expo/eas-cli/pull/4364) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [build-tools] Require the serve-sim session token for device run session previews, so the tunnel no longer exposes the shell-exec route. ([#4306](https://github.com/expo/eas-cli/pull/4306) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Report a system error when project source refresh fails for a remote build. ([#4359](https://github.com/expo/eas-cli/pull/4359) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [24.0.0](https://github.com/expo/eas-cli/releases/tag/v24.0.0) - 2026-09-09
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- [eas-cli] Align the `eas observe:*` commands with the renamed Observe GraphQL schema. `--json` output now uses `name`/`value` instead of `metricName`/`metricValue` (metrics) and `name` instead of `eventName` (log events and event-name summaries), and `eas observe:events` now lists user-defined events only — exceptions moved to the new `eas observe:errors`. ([#4338](https://github.com/expo/eas-cli/pull/4338) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Add `lcd_width`, `lcd_height`, and `lcd_density` inputs to configure the Android emulator display. ([#4349](https://github.com/expo/eas-cli/pull/4349) by [@krystofwoldrich-agent](https://github.com/krystofwoldrich-agent))
|
||||
- [eas-cli] Add `eas channel:protect` and `eas channel:unprotect` to manage EAS Update channel protection, and show protection in channel list and view output. ([#4319](https://github.com/expo/eas-cli/pull/4319) by [@sjkim-expo](https://github.com/sjkim-expo))
|
||||
- [eas-cli] Add `eas observe:errors` to display error and exception issue groups (grouped by fingerprint), with `--fingerprint <fingerprint>` to drill into a group's individual occurrences and stack traces. ([#4339](https://github.com/expo/eas-cli/pull/4339) by [@douglowder](https://github.com/douglowder))
|
||||
- [eas-cli] Add `eas observe:event` to display a single Observe event (metric, log, or error) by its ID. ([#4252](https://github.com/expo/eas-cli/pull/4252) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Start Argent remote sessions with `bun x` so they work when the `bunx` symlink is missing. ([491b372a](https://github.com/expo/eas-cli/commit/491b372a) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
- [build-tools] Reduce `expo-device-hub` preview resolution from 1280 px to 960 px to match `serve-sim` and lower streaming bandwidth. ([#4326](https://github.com/expo/eas-cli/pull/4326) by [@krystofwoldrich-agent](https://github.com/krystofwoldrich-agent))
|
||||
- [build-tools] Install FFmpeg before launching `expo-device-hub` in Android device sessions. ([#4332](https://github.com/expo/eas-cli/pull/4332) by [@krystofwoldrich-agent](https://github.com/krystofwoldrich-agent))
|
||||
- [eas-cli] Print the session token in simulator preview links, so they still open once the preview is gated. ([#4312](https://github.com/expo/eas-cli/pull/4312) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Make ASC API key issuer identifier optional in GraphQL and credentials types. ([#4248](https://github.com/expo/eas-cli/pull/4248) by [@sswrk](https://github.com/sswrk))
|
||||
|
||||
## [23.2.0](https://github.com/expo/eas-cli/releases/tag/v23.2.0) - 2026-08-31
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--tag` to `eas simulator` and `eas simulator:list` to group and filter simulator sessions by app variant. ([#4318](https://github.com/expo/eas-cli/pull/4318) by [@tchayen](https://github.com/tchayen))
|
||||
- [eas-cli] Update workflow run logs in real time, instead of every 10 seconds. ([#4228](https://github.com/expo/eas-cli/pull/4228) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- [build-tools] Add `expo-device-hub` web previews to Android web-preview-only, Agent Device, Argent, and Appium remote sessions. ([#4304](https://github.com/expo/eas-cli/pull/4304) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
- [eas-cli] Add `eas workflow:ssh <workflow-job-id> [command...]` to open an ssh session on the worker running a workflow job. ([#4032](https://github.com/expo/eas-cli/pull/4032) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `--ssh` (and `--ssh-idle-timeout`) to `eas workflow:run` to enable ssh on the run's VM jobs. ([#4033](https://github.com/expo/eas-cli/pull/4033) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Warn when starting Android EAS Simulator sessions that support is still in development and iOS parity is coming soon. ([#4320](https://github.com/expo/eas-cli/pull/4320) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [23.1.0](https://github.com/expo/eas-cli/releases/tag/v23.1.0) - 2026-08-31
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Prompt for the Expo SDK version in `eas new` and add a `--sdk-version` flag. The template now downloads from npm instead of GitHub to support SDK-specific versions. ([#4301](https://github.com/expo/eas-cli/pull/4301) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Clean up template metadata in projects created with `eas new`: remove the template LICENSE file and reset the package.json name, version, and license fields. ([#4301](https://github.com/expo/eas-cli/pull/4301) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [build-tools] Support `EAS_PNPM_FILTER_WORKSPACE` to install only the selected workspace's dependencies with `pnpm install --filter`. ([#4302](https://github.com/expo/eas-cli/pull/4302) by [@robingullo](https://github.com/robingullo))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Normalize extracted project permissions so source archives cannot leave files unreadable by EAS workers. ([#4287](https://github.com/expo/eas-cli/pull/4287) by [@AbbanMustafa](https://github.com/AbbanMustafa))
|
||||
- [eas-cli] Add dynamic retries to deploy command that scale up more generously, depending on deployment size ([#4299](https://github.com/expo/eas-cli/pull/4299) by [@kitten](https://github.com/kitten))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Add regression coverage for the bundled iOS entitlement introspection fallback. ([#4285](https://github.com/expo/eas-cli/pull/4285) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [local-build-plugin] Replace `bunyan` with `@expo/bunyan` while preserving the Steps formatter binary. ([#4284](https://github.com/expo/eas-cli/pull/4284) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [steps] Replace deprecated `lodash.get` with a local helper. ([#4283](https://github.com/expo/eas-cli/pull/4283) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli][steps] Bump `uuid` to v11 and remove the redundant `@types/uuid` dependency. ([#4282](https://github.com/expo/eas-cli/pull/4282) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Bump `minizlib` to 3.1.0 to drop its deprecated `rimraf@5` transitive dependency. ([#4281](https://github.com/expo/eas-cli/pull/4281) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [23.0.0](https://github.com/expo/eas-cli/releases/tag/v23.0.0) - 2026-08-28
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- [eas-cli] Rename the EAS Simulator preview-only session type from `--type serve-sim` to `--type web-preview-only`. ([#4298](https://github.com/expo/eas-cli/pull/4298) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Cache CocoaPods dependencies between iOS builds. ([#4266](https://github.com/expo/eas-cli/pull/4266) by [@AbbanMustafa](https://github.com/AbbanMustafa))
|
||||
- [build-tools] Support `EAS_BUN_FILTER_WORKSPACE` to install only the selected workspace's dependencies with `bun install --filter`. ([#4292](https://github.com/expo/eas-cli/pull/4292) by [@konrad-armatys](https://github.com/konrad-armatys))
|
||||
- [eas-cli] Add `eas project:status` (alias `eas status`) command that prints a snapshot of the project's recent builds, development builds, workflow runs, submissions, and updates, with agent-friendly JSON metadata, activity links, structured errors, submission-to-build correlation, and `--non-interactive` support. ([#4006](https://github.com/expo/eas-cli/pull/4006) by [@jonsamp](https://github.com/jonsamp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Prevent user npm minimum release age settings from applying to internal EAS CLI commands. ([#4296](https://github.com/expo/eas-cli/pull/4296) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Reduce serve-sim preview resolution from 1280 px to 960 px to lower streaming bandwidth. ([#4297](https://github.com/expo/eas-cli/pull/4297) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [worker] Use turtle cache upload and download URLs for eas.json build cache restore and save. ([#3860](https://github.com/expo/eas-cli/pull/3860) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [ci] Bump both STAGING and PRODUCTION cli-versions in release workflow. ([#4290](https://github.com/expo/eas-cli/pull/4290) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
## [22.6.0](https://github.com/expo/eas-cli/releases/tag/v22.6.0) - 2026-08-26
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add an opt-in `--max-idle-time-minutes` flag to `eas simulator`; the session stops automatically after that many minutes without activity. ([#4156](https://github.com/expo/eas-cli/pull/4156) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Add experimental `--resource-class` flag to `eas simulator`. ([#4268](https://github.com/expo/eas-cli/pull/4268) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Add worker-side SSH session helpers (upterm relay + session create/report/close). ([#4030](https://github.com/expo/eas-cli/pull/4030) by [@gwdp](https://github.com/gwdp))
|
||||
- [worker] Wire an SSH session into the build lifecycle behind the workflow ssh flag. ([#4031](https://github.com/expo/eas-cli/pull/4031) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Increase the serve-sim video bitrate to 6 Mbps for higher-quality simulator previews. ([#4291](https://github.com/expo/eas-cli/pull/4291) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools] Preapprove custom URL schemes before opening them in iOS Simulator sessions to avoid the first-use confirmation prompt. ([#4274](https://github.com/expo/eas-cli/pull/4274) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [22.4.0](https://github.com/expo/eas-cli/releases/tag/v22.4.0) - 2026-08-24
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--launch-arg`, `--open-url`, and Expo Go `--sdk-version` options to `eas simulator`. ([#4264](https://github.com/expo/eas-cli/pull/4264) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools] Pass launch arguments and a URL to open when launching applications in simulator sessions. ([#4263](https://github.com/expo/eas-cli/pull/4263) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Add `eas billing:subscribe <plan>` to start a Stripe checkout for an EAS plan, and `eas billing:manage` to open the Stripe customer portal. ([#4007](https://github.com/expo/eas-cli/pull/4007) by [@jonsamp](https://github.com/jonsamp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Support downloading iOS simulator archives whose app bundle contents are stored at the archive root. ([#4262](https://github.com/expo/eas-cli/pull/4262) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [22.3.0](https://github.com/expo/eas-cli/releases/tag/v22.3.0) - 2026-08-24
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Add `ios_signing_backend` option to the repack step. ([#4239](https://github.com/expo/eas-cli/pull/4239) by [@gabrieldonadel](https://github.com/gabrieldonadel))
|
||||
- [build-tools] Support an optional `package_version` input on `eas/start_serve_sim_remote_session`, so a simulator session can pin the `@expo/serve-sim` version instead of always running `latest`. ([#4253](https://github.com/expo/eas-cli/pull/4253) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Add composable custom build functions for downloading, installing, and launching application archives in simulator sessions. ([#4222](https://github.com/expo/eas-cli/pull/4222) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Add `--build-id`, `--application-archive-url`, and `--expo-go` to `eas simulator` to install and launch an application before the session is ready. ([#4223](https://github.com/expo/eas-cli/pull/4223) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Add `--environment` flag to the `eas observe:*` commands. ([#4275](https://github.com/expo/eas-cli/pull/4275) by [@kadikraman](https://github.com/kadikraman))
|
||||
- [eas-cli] Add `apple` value to the `--platform` flag of the `eas observe:*` commands. ([#4276](https://github.com/expo/eas-cli/pull/4276) by [@kadikraman](https://github.com/kadikraman))
|
||||
- [eas-cli] Add `--build-number` flag to `eas observe:metrics` and `eas observe:events`. ([#4278](https://github.com/expo/eas-cli/pull/4278) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Upload tvOS (and other non-iOS) IPAs to the App Store Connect platform declared in the bundle's `DTPlatformName`, instead of always uploading to the iOS train. ([#4234](https://github.com/expo/eas-cli/pull/4234) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
## [22.2.0](https://github.com/expo/eas-cli/releases/tag/v22.2.0) - 2026-08-20
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--device` flag to `eas simulator` for selecting the virtual device to start. ([#4172](https://github.com/expo/eas-cli/pull/4172) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Add support for Appium EAS Simulator sessions. ([#4190](https://github.com/expo/eas-cli/pull/4190) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Disallow 0-length uploads. ([#4192](https://github.com/expo/eas-cli/pull/4192) by [@wschurman](https://github.com/wschurman))
|
||||
- [eas-cli] Send the expected `Content-MD5` checksum with GCS signed URL uploads so GCS rejects corrupted uploads. ([#4208](https://github.com/expo/eas-cli/pull/4208) by [@wschurman](https://github.com/wschurman))
|
||||
- [eas-cli] Resolve the `eas go` SDK version to its repack target, so `--sdk-version 57` no longer fails with "No Expo Go repack target is published for SDK 57". ([#4215](https://github.com/expo/eas-cli/pull/4215) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Handle missing fingerprint source in fingerprint:compare command. ([#4210](https://github.com/expo/eas-cli/pull/4210) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Fix lint and enforce max-warnings=0. ([#4219](https://github.com/expo/eas-cli/pull/4219) by [@wschurman](https://github.com/wschurman))
|
||||
- [build-tools] Rename serve-sim ngrok preview URLs to use the `web-preview` prefix. ([#4231](https://github.com/expo/eas-cli/pull/4231) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [22.0.0](https://github.com/expo/eas-cli/releases/tag/v22.0.0) - 2026-08-14
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- [eas-cli] Build objects in `--json` output (`eas build`, `eas build:list`, `eas build:view`, `eas build:resign`) now have `app`, `updateChannel` (object with `id` and `name`), and `runtime` (object with `id` and `version`) instead of `project`, `channel`, and `runtimeVersion`. `eas update:republish --json` output likewise has `runtime` instead of `runtimeVersion`. Other update commands' `--json` output and `eas-update-metadata.json` are unchanged. ([#4021](https://github.com/expo/eas-cli/pull/4021) by [@tchayen](https://github.com/tchayen))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Upload JavaScript source maps generated by builds. ([#4170](https://github.com/expo/eas-cli/pull/4170) by [@AbbanMustafa](https://github.com/AbbanMustafa))
|
||||
- [eas-cli] Set the project icon on the EAS dashboard from the app config when running `eas init`, if the project does not have one yet. ([#4177](https://github.com/expo/eas-cli/pull/4177) by [@tchayen](https://github.com/tchayen))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Report EAS Simulator as available to Expo admins in `eas simulator:availability`, even when the project account's feature gate is disabled. ([#4181](https://github.com/expo/eas-cli/pull/4181) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Make the non-interactive "EAS project not configured" error actionable on every path: list the exact `eas init --id` / `eas init --account` commands and the accounts you can create projects in, instead of suggesting bare `eas init`, which fails the same way. ([#4153](https://github.com/expo/eas-cli/pull/4153) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- [eas-cli] Set up the internal TestFlight group and invite admin testers when submitting interactively with an existing `ascAppId`, with `--no-auto-testflight-setup` to disable it, and fix the TestFlight group URL printed when adding testers partially fails. ([#4136](https://github.com/expo/eas-cli/pull/4136) by [@tchayen](https://github.com/tchayen))
|
||||
|
||||
## [21.8.0](https://github.com/expo/eas-cli/releases/tag/v21.8.0) - 2026-08-11
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add Supabase integration foundation: GraphQL client, shared integration helpers, and provisioning utilities. ([#4130](https://github.com/expo/eas-cli/pull/4130) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `eas integrations:supabase:connect` command. ([#4106](https://github.com/expo/eas-cli/pull/4106) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `eas integrations:supabase:dashboard` command. ([#4107](https://github.com/expo/eas-cli/pull/4107) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `eas integrations:supabase:disconnect` command. ([#4108](https://github.com/expo/eas-cli/pull/4108) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [21.7.1](https://github.com/expo/eas-cli/releases/tag/v21.7.1) - 2026-08-10
|
||||
|
||||
## [21.7.0](https://github.com/expo/eas-cli/releases/tag/v21.7.0) - 2026-08-07
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Label serve-sim metrics artifacts with the sim's device name (from the metrics meta) instead of the raw udid. ([#4127](https://github.com/expo/eas-cli/pull/4127) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Set the artifact kind for Argent session artifacts from the MIME type reported by the tool server, so screenshots and screen recordings are grouped and labelled on the simulator session page instead of appearing in an unclassified file list. ([#4154](https://github.com/expo/eas-cli/pull/4154) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Fail when `--private-key-path` is passed without `updates.codeSigningCertificate` in the resolved app config, instead of ignoring the flag and publishing an unsigned update. ([#4161](https://github.com/expo/eas-cli/pull/4161) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [21.6.0](https://github.com/expo/eas-cli/releases/tag/v21.6.0) - 2026-08-05
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-build-job] Replace eas-cli npm dist-tags with cli-versions.json. ([#4147](https://github.com/expo/eas-cli/pull/4147) by [@douglowder](https://github.com/douglowder))
|
||||
- [eas-cli] Make `eas simulator` the canonical command and keep `eas simulator:start` as an alias. ([#4112](https://github.com/expo/eas-cli/pull/4112) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Validate local composite functions referenced from workflow job hooks during `eas workflow:validate`. ([#4064](https://github.com/expo/eas-cli/pull/4064) by [@sswrk](https://github.com/sswrk))
|
||||
- [eas-cli] Add `eas sim` as a shortcut for `eas simulator` commands, e.g. `eas sim:list` runs `eas simulator:list`. ([#4150](https://github.com/expo/eas-cli/pull/4150) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools] Support an optional `max_idle_time_minutes` input on `eas/start_argent_remote_session` and `eas/start_agent_device_remote_session`; the step stops the device run session after that many minutes without observed session events. ([#4157](https://github.com/expo/eas-cli/pull/4157) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Point accounts without EAS Simulator access at the waitlist: `eas simulator:availability` includes the link (and a `waitlistUrl` field in `--json`), and `eas simulator` now fails early with the same message instead of a generic permission error. ([#4151](https://github.com/expo/eas-cli/pull/4151) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools] Revert "Pin the default `agent-device` version for remote sessions" now that `agent-device` 0.20.5 fixes the broken release. ([#4144](https://github.com/expo/eas-cli/pull/4144) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Make the configure-version gradle script compatible with AGP 9, which removes the `applicationVariants` API; per-output version overrides now fall back to the `androidComponents` variant API. ([#4155](https://github.com/expo/eas-cli/pull/4155) by [@gabrieldonadel](https://github.com/gabrieldonadel))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
@@ -41,6 +409,7 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Soft-stop EAS Simulator sessions before the backing job times out so cleanup can finish. ([#4117](https://github.com/expo/eas-cli/pull/4117) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] clean up error handling in local builds. ([#4105](https://github.com/expo/eas-cli/pull/4105) by [@douglowder](https://github.com/douglowder))
|
||||
- [build-tools] Install ffmpeg when it is missing so Argent screen recording works in EAS Simulator sessions. ([#4110](https://github.com/expo/eas-cli/pull/4110) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Stop simulator job runs when `eas simulator:start` is canceled before the session is ready. ([#4113](https://github.com/expo/eas-cli/pull/4113) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that you d
|
||||
to access the functionality of and directly benefit from the
|
||||
functionality of the Licensed Work.
|
||||
|
||||
Change Date: 2029-07-15
|
||||
Change Date: 2029-10-01
|
||||
|
||||
Change License: MIT
|
||||
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
1. Invoke the GitHub workflow ["Manually trigger a release"](https://github.com/expo/eas-cli/actions/workflows/trigger-release.yml). The next version is chosen automatically based on the changelog entries. If you want to use different version, pass the version string as an input to the workflow, in semver format, e.g. "1.2.3"
|
||||
2. That's it! GitHub Actions is going to take care of the rest. Watch the #eas-cli Slack channel for a successful release notification.
|
||||
|
||||
The release also sets the `STAGING` version in `cli-versions.json`. It does not change the `PRODUCTION` version. See [EAS Build CLI versions](#eas-build-cli-versions).
|
||||
The release also sets the `STAGING` and `PRODUCTION` versions in `cli-versions.json`. See [EAS Build CLI versions](#eas-build-cli-versions).
|
||||
|
||||
## EAS Build CLI versions
|
||||
|
||||
@@ -14,7 +14,7 @@ The release also sets the `STAGING` version in `cli-versions.json`. It does not
|
||||
|
||||
This file replaces the old `latest-eas-build` and `latest-eas-build-staging` npm dist-tags. The build servers read the file from the `main` branch.
|
||||
|
||||
A release updates `STAGING` to the new version automatically. To promote a version to production, invoke the GitHub workflow ["Promote eas-cli to production"](https://github.com/expo/eas-cli/actions/workflows/promote-eas-cli-production.yml). Pass the version to promote as an input. If you leave the input empty, the workflow uses the current `STAGING` version. The workflow checks that the version is published on npm before it updates `PRODUCTION`.
|
||||
A release updates `STAGING` and `PRODUCTION` to the new version automatically. To set a different version to production (to fix a regression, for example), invoke the GitHub workflow ["Promote eas-cli to production"](https://github.com/expo/eas-cli/actions/workflows/promote-eas-cli-production.yml). Pass the version to promote as an input. If you leave the input empty, the workflow uses the current `STAGING` version. The workflow checks that the version is published on npm before it updates `PRODUCTION`.
|
||||
|
||||
## Choosing the next version
|
||||
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "21.5.0",
|
||||
"PRODUCTION": "21.5.0"
|
||||
"STAGING": "24.9.0",
|
||||
"PRODUCTION": "24.9.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "21.6.0",
|
||||
"version": "24.10.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -3,6 +3,7 @@ _.path = "./tools/bin"
|
||||
MISE_NODE_COREPACK = "true"
|
||||
|
||||
[tools]
|
||||
actionlint = "1.7.12"
|
||||
gcloud = "570.0.0"
|
||||
jq = "1.8.1"
|
||||
node = "20.19.4"
|
||||
+5
-4
@@ -11,7 +11,8 @@
|
||||
"start-allow-unused": "lerna run watch-allow-unused --stream --parallel",
|
||||
"watch": "yarn start",
|
||||
"eas": "packages/eas-cli/bin/run",
|
||||
"lint": "oxlint --type-aware --import-plugin --tsconfig ./tsconfig.oxlint.json .",
|
||||
"actionlint": "actionlint",
|
||||
"lint": "oxlint --type-aware --import-plugin --max-warnings=0 --tsconfig ./tsconfig.oxlint.json .",
|
||||
"fmt": "oxfmt .",
|
||||
"fmt:check": "oxfmt --check .",
|
||||
"lint-changelog": "./scripts/bin/run lint-changelog",
|
||||
@@ -21,7 +22,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "29.5.11",
|
||||
"@types/node": "20.14.8",
|
||||
"@types/node": "20.19.43",
|
||||
"eslint-plugin-async-protect": "3.1.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-watch-typeahead": "2.2.2",
|
||||
@@ -31,8 +32,8 @@
|
||||
"oxlint": "1.43.0",
|
||||
"oxlint-tsgolint": "0.11.1",
|
||||
"rimraf": "3.0.2",
|
||||
"ts-jest": "29.1.1",
|
||||
"typescript": "5.5.4"
|
||||
"ts-jest": "29.4.12",
|
||||
"typescript": "6.0.3"
|
||||
},
|
||||
"packageManager": "[email protected]"
|
||||
}
|
||||
@@ -8,3 +8,5 @@ bin/record-sim
|
||||
|
||||
src/graphql-env.d.ts
|
||||
schema.graphql
|
||||
bin/egress-guard.dylib
|
||||
bin/egress-guard-check
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "21.6.0",
|
||||
"version": "24.9.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -24,7 +24,10 @@
|
||||
"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",
|
||||
"build:egress-guard": "resources/egress-guard/build.sh",
|
||||
"test:egress-guard": "resources/egress-guard/tests/run-policy-tests.sh && resources/egress-guard/tests/run-guard-tests.sh",
|
||||
"typecheck": "tsc",
|
||||
"generate-appium-commands": "mise exec [email protected] -- node scripts/generate-appium-commands.js",
|
||||
"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",
|
||||
@@ -38,31 +41,33 @@
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "21.0.0",
|
||||
"@expo/eas-build-job": "21.6.0",
|
||||
"@expo/downloader": "24.8.0",
|
||||
"@expo/eas-build-job": "24.9.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "21.0.0",
|
||||
"@expo/logger": "24.8.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.3.5",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "21.6.0",
|
||||
"@expo/template-file": "21.0.2",
|
||||
"@expo/turtle-spawn": "21.0.0",
|
||||
"@expo/steps": "24.9.0",
|
||||
"@expo/template-file": "24.8.0",
|
||||
"@expo/turtle-spawn": "24.8.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
"@urql/core": "^6.0.1",
|
||||
"bplist-parser": "0.3.2",
|
||||
"content-disposition": "1.0.1",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fast-xml-parser": "^4.4.1",
|
||||
"fs-extra": "^11.2.0",
|
||||
"gql.tada": "^1.8.13",
|
||||
"gql.tada": "^1.11.3",
|
||||
"joi": "^17.13.1",
|
||||
"jose": "5.10.0",
|
||||
"lodash": "^4.18.1",
|
||||
"node-fetch": "^2.7.0",
|
||||
"node-forge": "^1.3.1",
|
||||
"node-pty": "1.2.0-beta.15",
|
||||
"node-stream-zip": "1.15.0",
|
||||
"nullthrows": "^1.1.1",
|
||||
"plist": "^3.1.0",
|
||||
@@ -73,15 +78,17 @@
|
||||
"semver": "^7.6.2",
|
||||
"tar": "7.5.19",
|
||||
"uuid": "9.0.1",
|
||||
"ws": "8.21.1",
|
||||
"yaml": "^2.8.1",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/repack-app": "~0.6.1",
|
||||
"@expo/repack-app": "~0.9.0",
|
||||
"@types/content-disposition": "0.5.9",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/lodash": "^4.17.4",
|
||||
"@types/node": "20.14.2",
|
||||
"@types/node": "20.19.43",
|
||||
"@types/node-fetch": "^2.6.11",
|
||||
"@types/node-forge": "^1.3.11",
|
||||
"@types/plist": "^3.0.5",
|
||||
@@ -89,15 +96,15 @@
|
||||
"@types/retry": "^0.12.5",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"bunyan": "^1.8.15",
|
||||
"@types/ws": "8.5.10",
|
||||
"jest": "^29.7.0",
|
||||
"memfs": "^4.17.1",
|
||||
"nock": "13.4.0",
|
||||
"rimraf": "3.0.2",
|
||||
"ts-jest": "^29.1.4",
|
||||
"ts-jest": "^29.4.12",
|
||||
"ts-mockito": "^2.6.1",
|
||||
"tslib": "^2.6.3",
|
||||
"typescript": "^5.5.4",
|
||||
"typescript": "^6.0.3",
|
||||
"uuid": "^9.0.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
# egress-guard
|
||||
|
||||
A small dylib injected into every process the iOS Simulator launches during a
|
||||
`--egress local` session. It interposes the socket calls that open outbound
|
||||
traffic (`connect`, `connectx`, `sendto`, `sendmsg`, and the `$NOCANCEL`
|
||||
variants of the last three that libsystem exports alongside them) and refuses
|
||||
any destination that is not loopback. A sockaddr whose family was left
|
||||
`AF_UNSPEC` is classified as the family its length implies, which is how the
|
||||
kernel treats it for TCP `connect` and IPv4 sends; on a datagram socket
|
||||
`connect(AF_UNSPEC)` is left to the kernel, which dissolves the association. The system proxy and the `--egress-allow`
|
||||
forwards live on loopback, so everything that honors the proxy keeps working
|
||||
and everything that bypasses it fails with `ECONNREFUSED` in the process that
|
||||
tried, with the calling frameworks recorded.
|
||||
|
||||
Why interposing: simulator processes share the worker's uid, so a packet
|
||||
filter cannot tell them apart from the worker's own traffic. dyld interposing
|
||||
from an inserted library reaches calls made inside Apple's own frameworks
|
||||
(CFNetwork, Network.framework), and `launchctl setenv DYLD_INSERT_LIBRARIES`
|
||||
inside the simulator makes launchd hand the library to every process it
|
||||
spawns. The simulator does not enforce library validation, which is what makes
|
||||
this possible there and nowhere else.
|
||||
|
||||
Configuration comes from the environment the simulator's launchd provides:
|
||||
|
||||
- `EAS_EGRESS_GUARD_LOG`: file the guard appends events to (host path).
|
||||
- `EAS_EGRESS_GUARD_MODE`: `block` (default, and what any unknown value means)
|
||||
or `log` (observe only).
|
||||
|
||||
Each event is one tab-separated line:
|
||||
|
||||
```
|
||||
eas-egress-guard\t<process>\t<pid>\t<function>\t<blocked|logged>\t<peer>\t<caller>,<caller>...
|
||||
```
|
||||
|
||||
A process lists each `(function, peer)` once, up to 128 of them. When that
|
||||
table fills it writes one more line with function `overflow` and the limit as
|
||||
the peer; further distinct destinations are still refused but not listed.
|
||||
|
||||
Layout:
|
||||
|
||||
- `policy.c` / `policy.h`: classification, mode, formatting, per-process
|
||||
dedupe. Pure C, host-testable.
|
||||
- `guard.c`: the interposers and the constructor that reads the environment.
|
||||
- `check.c`: `egress-guard-check`, run inside the simulator right after the
|
||||
guard is installed. Exits non-zero unless the library is loaded in a fresh
|
||||
process and behaves as the mode says on `connect`, `connect$NOCANCEL` and a
|
||||
`connect` with an `AF_UNSPEC` sockaddr; the worker fails the session on that.
|
||||
- `tests/policy_test.c`: host unit tests, `tests/run-policy-tests.sh`.
|
||||
- `tests/guard_test.c`, `tests/guard_insert_test.c`: host tests of the
|
||||
interposers themselves, `tests/run-guard-tests.sh`. The first compiles
|
||||
`guard.c` in and forces the lock schedules (a signal handler making a socket
|
||||
call inside the critical section, a fork while another thread holds the lock,
|
||||
contention); the second inserts the built library like the simulator does and
|
||||
checks descriptor reuse, the `$NOCANCEL` symbols, `AF_UNSPEC` shapes and the
|
||||
UDP disconnect. Nothing in them opens a socket to a non-loopback address.
|
||||
- `tests/nettest.swift`: probe binary the simulator end-to-end test runs inside
|
||||
a device; exercises URLSession, Network.framework, BSD TCP and UDP, DNS.
|
||||
- `build.sh`: universal simulator dylib into `packages/build-tools/bin/`.
|
||||
|
||||
Installation order matters. `simctl` forwards every `SIMCTL_CHILD_`-prefixed
|
||||
variable of its own environment to the process it starts, and for `simctl
|
||||
boot` that process is the simulator's launchd, so the worker boots with the
|
||||
guard and proxy variables in that form and every process of the boot inherits
|
||||
them (measured: 176 of 176). `launchctl setenv` after boot is kept for a
|
||||
device that was already booted, but by then the boot's own processes have
|
||||
started without it. It also cannot change a variable the boot already carried:
|
||||
a device booted with one guard configuration keeps it until it is shut down,
|
||||
which is why the worker boots devices itself and every configuration in the
|
||||
end-to-end test gets its own boot. The self-check runs once boot completes and is followed
|
||||
by a coverage report from `lsof`, listing any process without the library. Both the dylib and the check binary are built by
|
||||
`packages/worker/package.sh` for the iOS worker tarball and by the
|
||||
`test-egress-guard` EAS workflow, not committed.
|
||||
|
||||
Failure semantics: a missing library, a failed `launchctl setenv`, or a failed
|
||||
self-check fails the session, since a `--egress local` session without the
|
||||
guard would silently leak. Only an unwritable event log is a warning, because
|
||||
refusals still happen and only the reporting is lost. Recording is best-effort
|
||||
in the process too: the event log is opened per event rather than held open
|
||||
(a process that closes descriptors it does not own, as `launchd_sim` does,
|
||||
would otherwise make the guard write into whatever reused the number), and a
|
||||
dedupe slot that cannot be taken within a bounded number of attempts (a signal
|
||||
handler re-entering the guard, a fork child inheriting a held lock) skips the
|
||||
event rather than blocking or aborting the process.
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds the guard as a universal (arm64 + x86_64) iOS Simulator dylib into
|
||||
# packages/build-tools/bin/egress-guard.dylib. Requires Xcode.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
# Output directory; defaults to packages/build-tools/bin.
|
||||
bin_dir="${1:-../../bin}"
|
||||
mkdir -p "$bin_dir"
|
||||
out=$(mktemp -d)
|
||||
sdk=$(xcrun --sdk iphonesimulator --show-sdk-path)
|
||||
for arch in arm64 x86_64; do
|
||||
xcrun --sdk iphonesimulator clang \
|
||||
-std=c11 -Wall -Wextra -Werror -O2 \
|
||||
-target "$arch-apple-ios15.0-simulator" -isysroot "$sdk" \
|
||||
-dynamiclib -install_name @rpath/egress-guard.dylib \
|
||||
-o "$out/egress-guard-$arch.dylib" policy.c guard.c
|
||||
done
|
||||
lipo -create -output "$bin_dir/egress-guard.dylib" "$out/egress-guard-arm64.dylib" "$out/egress-guard-x86_64.dylib"
|
||||
lipo -info "$bin_dir/egress-guard.dylib"
|
||||
|
||||
# The self-check the worker runs inside the simulator after installing the guard.
|
||||
for arch in arm64 x86_64; do
|
||||
xcrun --sdk iphonesimulator clang \
|
||||
-std=c11 -Wall -Wextra -Werror -O2 \
|
||||
-target "$arch-apple-ios15.0-simulator" -isysroot "$sdk" \
|
||||
-o "$out/egress-guard-check-$arch" check.c
|
||||
done
|
||||
lipo -create -output "$bin_dir/egress-guard-check" "$out/egress-guard-check-arm64" "$out/egress-guard-check-x86_64"
|
||||
chmod +x "$bin_dir/egress-guard-check"
|
||||
lipo -info "$bin_dir/egress-guard-check"
|
||||
@@ -0,0 +1,112 @@
|
||||
// Self-check run inside a simulator right after the guard is installed:
|
||||
// proves the guard library is loaded in a freshly spawned process and that it
|
||||
// behaves as the requested mode says, on every connect entry point the guard
|
||||
// covers. Exit 0 on success; 2 when the library is not loaded; 3 when it is
|
||||
// loaded but did not behave; 1 on usage errors.
|
||||
//
|
||||
// egress-guard-check --mode block|log
|
||||
#include <arpa/inet.h>
|
||||
#include <dlfcn.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <mach-o/dyld.h>
|
||||
#include <netinet/in.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
// TEST-NET-1 (RFC 5737): never routed, so without the guard a non-blocking
|
||||
// connect reports EINPROGRESS and nothing ever answers.
|
||||
#define PROBE_ADDRESS "192.0.2.1"
|
||||
#define PROBE_PORT 9
|
||||
|
||||
typedef int (*connect_fn)(int, const struct sockaddr *, socklen_t);
|
||||
|
||||
static int guard_loaded(void) {
|
||||
for (uint32_t i = 0; i < _dyld_image_count(); i++) {
|
||||
const char *name = _dyld_get_image_name(i);
|
||||
if (name != NULL && strstr(name, "egress-guard.dylib") != NULL) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// One non-blocking connect to the probe address through `entry`, with the
|
||||
// sockaddr family the caller wants. Returns the connect result; errno is
|
||||
// stored in *err.
|
||||
static int probe(connect_fn entry, sa_family_t family, int *err) {
|
||||
int fd = socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (fd < 0) {
|
||||
*err = errno;
|
||||
return -2;
|
||||
}
|
||||
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) | O_NONBLOCK);
|
||||
struct sockaddr_in to;
|
||||
memset(&to, 0, sizeof to);
|
||||
to.sin_family = family;
|
||||
to.sin_len = sizeof to;
|
||||
to.sin_port = htons(PROBE_PORT);
|
||||
inet_pton(AF_INET, PROBE_ADDRESS, &to.sin_addr);
|
||||
int rc = entry(fd, (struct sockaddr *)&to, sizeof to);
|
||||
*err = errno;
|
||||
close(fd);
|
||||
return rc;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
const char *mode = "block";
|
||||
if (argc == 3 && strcmp(argv[1], "--mode") == 0) {
|
||||
mode = argv[2];
|
||||
} else if (argc != 1) {
|
||||
fprintf(stderr, "usage: egress-guard-check [--mode block|log]\n");
|
||||
return 1;
|
||||
}
|
||||
if (!guard_loaded()) {
|
||||
printf("egress-guard-check: the guard library is not loaded in this process\n");
|
||||
return 2;
|
||||
}
|
||||
connect_fn connect_nocancel = (connect_fn)dlsym(RTLD_DEFAULT, "connect$NOCANCEL");
|
||||
if (connect_nocancel == NULL) {
|
||||
printf("egress-guard-check: connect$NOCANCEL is not exported in this process\n");
|
||||
return 3;
|
||||
}
|
||||
|
||||
// The plain call, the non-cancelable variant, and a sockaddr whose family
|
||||
// was left AF_UNSPEC, which the kernel connects as IPv4.
|
||||
const struct {
|
||||
const char *name;
|
||||
connect_fn entry;
|
||||
sa_family_t family;
|
||||
} probes[] = {
|
||||
{"connect", connect, AF_INET},
|
||||
{"connect$NOCANCEL", connect_nocancel, AF_INET},
|
||||
{"connect with AF_UNSPEC", connect, AF_UNSPEC},
|
||||
};
|
||||
int block = strcmp(mode, "block") == 0;
|
||||
for (size_t i = 0; i < sizeof probes / sizeof probes[0]; i++) {
|
||||
int err = 0;
|
||||
int rc = probe(probes[i].entry, probes[i].family, &err);
|
||||
if (rc == -2) {
|
||||
printf("egress-guard-check: socket() failed: %s\n", strerror(err));
|
||||
return 3;
|
||||
}
|
||||
if (block && !(rc == -1 && err == ECONNREFUSED)) {
|
||||
printf("egress-guard-check: guard loaded but a non-loopback %s was not refused (rc=%d, errno=%d %s)\n",
|
||||
probes[i].name, rc, err, strerror(err));
|
||||
return 3;
|
||||
}
|
||||
if (!block && !(rc == 0 || (rc == -1 && err == EINPROGRESS))) {
|
||||
printf("egress-guard-check: guard loaded in log mode but %s failed (errno=%d %s)\n",
|
||||
probes[i].name, err, strerror(err));
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
if (block) {
|
||||
printf("egress-guard-check: guard loaded; non-loopback connections are refused\n");
|
||||
} else {
|
||||
printf("egress-guard-check: guard loaded in log mode; connections pass through\n");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
// The local egress guard: interposes the calls that open outbound traffic in
|
||||
// every simulator process it is inserted into, refuses non-loopback
|
||||
// destinations in block mode, and records one event per destination per
|
||||
// process. See README.md and policy.h.
|
||||
#include "policy.h"
|
||||
|
||||
#include <dlfcn.h>
|
||||
#include <errno.h>
|
||||
#include <execinfo.h>
|
||||
#include <fcntl.h>
|
||||
#include <os/lock.h>
|
||||
#include <sched.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/types.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define EG_LOG_ENV "EAS_EGRESS_GUARD_LOG"
|
||||
#define EG_MODE_ENV "EAS_EGRESS_GUARD_MODE"
|
||||
#define EG_MAX_CALLERS 6
|
||||
#define EG_CALLER_LENGTH 64
|
||||
#define EG_LOCK_ATTEMPTS 64
|
||||
|
||||
static int eg_initialized = 0;
|
||||
static void *eg_own_image = NULL;
|
||||
static char eg_log_path[1024];
|
||||
static eg_mode_t eg_mode = EG_MODE_BLOCK;
|
||||
static eg_seen_t eg_seen;
|
||||
static int eg_overflow_reported = 0;
|
||||
static os_unfair_lock eg_lock = OS_UNFAIR_LOCK_INIT;
|
||||
|
||||
static void eg_init(void) {
|
||||
if (eg_initialized) {
|
||||
return;
|
||||
}
|
||||
eg_mode = eg_parse_mode(getenv(EG_MODE_ENV));
|
||||
Dl_info self;
|
||||
if (dladdr((const void *)eg_init, &self)) {
|
||||
eg_own_image = self.dli_fbase;
|
||||
}
|
||||
const char *log_path = getenv(EG_LOG_ENV);
|
||||
// The path is kept rather than an open descriptor: a process that closes
|
||||
// descriptors it does not know about (launchd_sim does) would otherwise
|
||||
// leave the guard writing into whatever file reused the number.
|
||||
if (log_path != NULL && log_path[0] != '\0' && strlen(log_path) < sizeof eg_log_path) {
|
||||
strcpy(eg_log_path, log_path);
|
||||
}
|
||||
eg_initialized = 1;
|
||||
}
|
||||
|
||||
__attribute__((constructor)) static void eg_constructor(void) { eg_init(); }
|
||||
|
||||
// Image names of the frames above the guard's own, innermost first, with
|
||||
// consecutive repeats collapsed: "Network,CFNetwork,MyApp".
|
||||
__attribute__((noinline)) static int eg_collect_callers(char names[EG_MAX_CALLERS][EG_CALLER_LENGTH]) {
|
||||
void *frames[EG_MAX_CALLERS + 6];
|
||||
int frame_count = backtrace(frames, EG_MAX_CALLERS + 6);
|
||||
int count = 0;
|
||||
for (int i = 1; i < frame_count && count < EG_MAX_CALLERS; i++) {
|
||||
Dl_info info;
|
||||
const char *image = "?";
|
||||
if (dladdr(frames[i], &info)) {
|
||||
if (info.dli_fbase == eg_own_image) {
|
||||
continue;
|
||||
}
|
||||
if (info.dli_fname != NULL) {
|
||||
const char *slash = strrchr(info.dli_fname, '/');
|
||||
image = slash ? slash + 1 : info.dli_fname;
|
||||
}
|
||||
}
|
||||
if (count > 0 && strcmp(names[count - 1], image) == 0) {
|
||||
continue;
|
||||
}
|
||||
strncpy(names[count], image, EG_CALLER_LENGTH - 1);
|
||||
names[count][EG_CALLER_LENGTH - 1] = '\0';
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// A lock held by this same thread (a signal handler making a socket call
|
||||
// while the interrupted call held it) or by a thread that did not survive
|
||||
// fork() never becomes available and os_unfair_lock_lock aborts the process
|
||||
// in both cases. Give up on recording the event instead; refusal does not
|
||||
// depend on it. Ordinary contention over the microsecond-long critical
|
||||
// section resolves within the first attempts.
|
||||
static int eg_lock_bounded(void) {
|
||||
for (int i = 0; i < EG_LOCK_ATTEMPTS; i++) {
|
||||
if (os_unfair_lock_trylock(&eg_lock)) {
|
||||
return 1;
|
||||
}
|
||||
sched_yield();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// One line in the event log. O_APPEND keeps whole lines intact across
|
||||
// processes writing concurrently; the log is opened per event, at most
|
||||
// EG_SEEN_CAPACITY + 1 times per process.
|
||||
__attribute__((noinline)) static void eg_write_event(const char *function, const char *action,
|
||||
const char *peer, int with_callers) {
|
||||
if (eg_log_path[0] == '\0') {
|
||||
return;
|
||||
}
|
||||
char names[EG_MAX_CALLERS][EG_CALLER_LENGTH];
|
||||
const char *callers[EG_MAX_CALLERS];
|
||||
int caller_count = with_callers ? eg_collect_callers(names) : 0;
|
||||
for (int i = 0; i < caller_count; i++) {
|
||||
callers[i] = names[i];
|
||||
}
|
||||
char line[1024];
|
||||
int n = eg_format_event(line, sizeof line, getprogname(), getpid(), function, action, peer,
|
||||
callers, caller_count);
|
||||
if (n <= 0) {
|
||||
return;
|
||||
}
|
||||
int fd = open(eg_log_path, O_WRONLY | O_APPEND | O_CREAT | O_CLOEXEC, 0644);
|
||||
if (fd >= 0) {
|
||||
(void)write(fd, line, (size_t)n);
|
||||
close(fd);
|
||||
}
|
||||
}
|
||||
|
||||
// Returns 1 when the call must be refused.
|
||||
__attribute__((noinline)) static int eg_handle(const char *function, const struct sockaddr *sa,
|
||||
socklen_t len) {
|
||||
eg_class_t cls = eg_classify(sa, len);
|
||||
if (cls != EG_REMOTE) {
|
||||
return 0;
|
||||
}
|
||||
eg_init();
|
||||
int deny = eg_should_deny(eg_mode, cls);
|
||||
|
||||
char peer[96];
|
||||
if (eg_format_peer(sa, len, peer, sizeof peer) != 0) {
|
||||
strncpy(peer, "?", sizeof peer);
|
||||
}
|
||||
char key[EG_SEEN_KEY_LENGTH];
|
||||
snprintf(key, sizeof key, "%s %s", function, peer);
|
||||
int fresh = 0;
|
||||
int overflowed = 0;
|
||||
if (eg_lock_bounded()) {
|
||||
fresh = eg_seen_insert(&eg_seen, key);
|
||||
if (!fresh && eg_seen.overflow > 0 && !eg_overflow_reported) {
|
||||
eg_overflow_reported = 1;
|
||||
overflowed = 1;
|
||||
}
|
||||
os_unfair_lock_unlock(&eg_lock);
|
||||
}
|
||||
|
||||
const char *action = deny ? "blocked" : "logged";
|
||||
if (fresh) {
|
||||
eg_write_event(function, action, peer, 1);
|
||||
} else if (overflowed) {
|
||||
// Once per process: the table is full, so further distinct destinations
|
||||
// are still refused but no longer listed.
|
||||
char limit[64];
|
||||
snprintf(limit, sizeof limit, "%d distinct destinations", EG_SEEN_CAPACITY);
|
||||
eg_write_event(EG_OVERFLOW_FUNCTION, action, limit, 0);
|
||||
}
|
||||
return deny;
|
||||
}
|
||||
|
||||
// connect() with an AF_UNSPEC address on a datagram socket dissolves the
|
||||
// association and sends nothing; the kernel answers EAFNOSUPPORT. Leave that
|
||||
// to the kernel. On a stream socket the kernel connects to the address the
|
||||
// length implies, which eg_handle classifies.
|
||||
static int eg_dissolves_association(int fd, const struct sockaddr *sa, socklen_t len) {
|
||||
if (sa == NULL || len < 2 || sa->sa_family != AF_UNSPEC) {
|
||||
return 0;
|
||||
}
|
||||
int type = 0;
|
||||
socklen_t type_len = sizeof type;
|
||||
return getsockopt(fd, SOL_SOCKET, SO_TYPE, &type, &type_len) == 0 && type == SOCK_DGRAM;
|
||||
}
|
||||
|
||||
typedef int (*eg_connect_fn)(int, const struct sockaddr *, socklen_t);
|
||||
typedef ssize_t (*eg_sendto_fn)(int, const void *, size_t, int, const struct sockaddr *,
|
||||
socklen_t);
|
||||
typedef ssize_t (*eg_sendmsg_fn)(int, const struct msghdr *, int);
|
||||
|
||||
// The non-cancelable variants libsystem_kernel exports next to the cancelable
|
||||
// ones; dyld interposing is per symbol, so each needs its own entry.
|
||||
extern int eg_connect_nocancel_original(int, const struct sockaddr *, socklen_t) __asm__(
|
||||
"_connect$NOCANCEL");
|
||||
extern ssize_t eg_sendto_nocancel_original(int, const void *, size_t, int,
|
||||
const struct sockaddr *, socklen_t) __asm__(
|
||||
"_sendto$NOCANCEL");
|
||||
extern ssize_t eg_sendmsg_nocancel_original(int, const struct msghdr *, int) __asm__(
|
||||
"_sendmsg$NOCANCEL");
|
||||
|
||||
static int eg_connect_through(eg_connect_fn original, int fd, const struct sockaddr *sa,
|
||||
socklen_t len) {
|
||||
if (!eg_dissolves_association(fd, sa, len) && eg_handle("connect", sa, len)) {
|
||||
errno = ECONNREFUSED;
|
||||
return -1;
|
||||
}
|
||||
return original(fd, sa, len);
|
||||
}
|
||||
|
||||
static int eg_connect(int fd, const struct sockaddr *sa, socklen_t len) {
|
||||
return eg_connect_through(connect, fd, sa, len);
|
||||
}
|
||||
|
||||
static int eg_connect_nocancel(int fd, const struct sockaddr *sa, socklen_t len) {
|
||||
return eg_connect_through(eg_connect_nocancel_original, fd, sa, len);
|
||||
}
|
||||
|
||||
static int eg_connectx(int s, const sa_endpoints_t *endpoints, sae_associd_t associd,
|
||||
unsigned int flags, const struct iovec *iov, unsigned int iovcnt,
|
||||
size_t *len, sae_connid_t *connid) {
|
||||
if (endpoints != NULL && endpoints->sae_dstaddr != NULL &&
|
||||
!eg_dissolves_association(s, endpoints->sae_dstaddr, endpoints->sae_dstaddrlen) &&
|
||||
eg_handle("connectx", endpoints->sae_dstaddr, endpoints->sae_dstaddrlen)) {
|
||||
errno = ECONNREFUSED;
|
||||
return -1;
|
||||
}
|
||||
return connectx(s, endpoints, associd, flags, iov, iovcnt, len, connid);
|
||||
}
|
||||
|
||||
static ssize_t eg_sendto_through(eg_sendto_fn original, int fd, const void *buf, size_t n,
|
||||
int flags, const struct sockaddr *sa, socklen_t len) {
|
||||
if (sa != NULL && eg_handle("sendto", sa, len)) {
|
||||
errno = ECONNREFUSED;
|
||||
return -1;
|
||||
}
|
||||
return original(fd, buf, n, flags, sa, len);
|
||||
}
|
||||
|
||||
static ssize_t eg_sendto(int fd, const void *buf, size_t n, int flags, const struct sockaddr *sa,
|
||||
socklen_t len) {
|
||||
return eg_sendto_through(sendto, fd, buf, n, flags, sa, len);
|
||||
}
|
||||
|
||||
static ssize_t eg_sendto_nocancel(int fd, const void *buf, size_t n, int flags,
|
||||
const struct sockaddr *sa, socklen_t len) {
|
||||
return eg_sendto_through(eg_sendto_nocancel_original, fd, buf, n, flags, sa, len);
|
||||
}
|
||||
|
||||
static ssize_t eg_sendmsg_through(eg_sendmsg_fn original, int fd, const struct msghdr *msg,
|
||||
int flags) {
|
||||
if (msg != NULL && msg->msg_name != NULL && msg->msg_namelen > 0 &&
|
||||
eg_handle("sendmsg", (const struct sockaddr *)msg->msg_name, msg->msg_namelen)) {
|
||||
errno = ECONNREFUSED;
|
||||
return -1;
|
||||
}
|
||||
return original(fd, msg, flags);
|
||||
}
|
||||
|
||||
static ssize_t eg_sendmsg(int fd, const struct msghdr *msg, int flags) {
|
||||
return eg_sendmsg_through(sendmsg, fd, msg, flags);
|
||||
}
|
||||
|
||||
static ssize_t eg_sendmsg_nocancel(int fd, const struct msghdr *msg, int flags) {
|
||||
return eg_sendmsg_through(eg_sendmsg_nocancel_original, fd, msg, flags);
|
||||
}
|
||||
|
||||
// dyld applies these to every image in the process, including the shared
|
||||
// cache, which is how calls made inside CFNetwork and Network.framework are
|
||||
// caught.
|
||||
__attribute__((used)) static const struct {
|
||||
const void *replacement;
|
||||
const void *original;
|
||||
} eg_interposers[] __attribute__((section("__DATA,__interpose"))) = {
|
||||
{(const void *)eg_connect, (const void *)connect},
|
||||
{(const void *)eg_connect_nocancel, (const void *)eg_connect_nocancel_original},
|
||||
{(const void *)eg_connectx, (const void *)connectx},
|
||||
{(const void *)eg_sendto, (const void *)sendto},
|
||||
{(const void *)eg_sendto_nocancel, (const void *)eg_sendto_nocancel_original},
|
||||
{(const void *)eg_sendmsg, (const void *)sendmsg},
|
||||
{(const void *)eg_sendmsg_nocancel, (const void *)eg_sendmsg_nocancel_original},
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
#include "policy.h"
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
static int is_v4_local(uint32_t host_order) {
|
||||
return (host_order >> 24) == 127 || host_order == 0;
|
||||
}
|
||||
|
||||
// XNU accepts a sockaddr whose family is AF_UNSPEC as the family its length
|
||||
// implies: TCP connect() translates it on both socket families and IPv4
|
||||
// sendto()/sendmsg() deliver with it. Classify it the way the kernel uses it.
|
||||
static sa_family_t eg_family(const struct sockaddr *sa, socklen_t len) {
|
||||
if (sa->sa_family == AF_UNSPEC) {
|
||||
if (len == sizeof(struct sockaddr_in)) {
|
||||
return AF_INET;
|
||||
}
|
||||
if (len == sizeof(struct sockaddr_in6)) {
|
||||
return AF_INET6;
|
||||
}
|
||||
}
|
||||
return sa->sa_family;
|
||||
}
|
||||
|
||||
eg_class_t eg_classify(const struct sockaddr *sa, socklen_t len) {
|
||||
if (sa == NULL || len < 2) {
|
||||
return EG_PASSTHROUGH;
|
||||
}
|
||||
sa_family_t family = eg_family(sa, len);
|
||||
if (family == AF_INET) {
|
||||
if (len < sizeof(struct sockaddr_in)) {
|
||||
return EG_PASSTHROUGH;
|
||||
}
|
||||
const struct sockaddr_in *in = (const struct sockaddr_in *)sa;
|
||||
return is_v4_local(ntohl(in->sin_addr.s_addr)) ? EG_LOOPBACK : EG_REMOTE;
|
||||
}
|
||||
if (family == AF_INET6) {
|
||||
if (len < sizeof(struct sockaddr_in6)) {
|
||||
return EG_PASSTHROUGH;
|
||||
}
|
||||
const struct in6_addr *a6 = &((const struct sockaddr_in6 *)sa)->sin6_addr;
|
||||
if (IN6_IS_ADDR_LOOPBACK(a6) || IN6_IS_ADDR_UNSPECIFIED(a6)) {
|
||||
return EG_LOOPBACK;
|
||||
}
|
||||
if (IN6_IS_ADDR_V4MAPPED(a6)) {
|
||||
uint32_t v4;
|
||||
memcpy(&v4, &a6->s6_addr[12], sizeof v4);
|
||||
return is_v4_local(ntohl(v4)) ? EG_LOOPBACK : EG_REMOTE;
|
||||
}
|
||||
return EG_REMOTE;
|
||||
}
|
||||
return EG_PASSTHROUGH;
|
||||
}
|
||||
|
||||
eg_mode_t eg_parse_mode(const char *value) {
|
||||
if (value != NULL && strcmp(value, "log") == 0) {
|
||||
return EG_MODE_LOG;
|
||||
}
|
||||
return EG_MODE_BLOCK;
|
||||
}
|
||||
|
||||
int eg_should_deny(eg_mode_t mode, eg_class_t cls) {
|
||||
return mode == EG_MODE_BLOCK && cls == EG_REMOTE;
|
||||
}
|
||||
|
||||
int eg_format_peer(const struct sockaddr *sa, socklen_t len, char *out, size_t n) {
|
||||
char ip[INET6_ADDRSTRLEN];
|
||||
int written;
|
||||
if (sa == NULL || len < 2) {
|
||||
return -1;
|
||||
}
|
||||
sa_family_t family = eg_family(sa, len);
|
||||
if (family == AF_INET && len >= sizeof(struct sockaddr_in)) {
|
||||
const struct sockaddr_in *in = (const struct sockaddr_in *)sa;
|
||||
if (inet_ntop(AF_INET, &in->sin_addr, ip, sizeof ip) == NULL) {
|
||||
return -1;
|
||||
}
|
||||
written = snprintf(out, n, "%s:%d", ip, ntohs(in->sin_port));
|
||||
} else if (family == AF_INET6 && len >= sizeof(struct sockaddr_in6)) {
|
||||
const struct sockaddr_in6 *in6 = (const struct sockaddr_in6 *)sa;
|
||||
if (inet_ntop(AF_INET6, &in6->sin6_addr, ip, sizeof ip) == NULL) {
|
||||
return -1;
|
||||
}
|
||||
written = snprintf(out, n, "[%s]:%d", ip, ntohs(in6->sin6_port));
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
return (written < 0 || (size_t)written >= n) ? -1 : 0;
|
||||
}
|
||||
|
||||
int eg_seen_insert(eg_seen_t *seen, const char *key) {
|
||||
for (int i = 0; i < seen->count; i++) {
|
||||
if (strncmp(seen->keys[i], key, EG_SEEN_KEY_LENGTH - 1) == 0) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
if (seen->count >= EG_SEEN_CAPACITY) {
|
||||
seen->overflow++;
|
||||
return 0;
|
||||
}
|
||||
strncpy(seen->keys[seen->count], key, EG_SEEN_KEY_LENGTH - 1);
|
||||
seen->keys[seen->count][EG_SEEN_KEY_LENGTH - 1] = '\0';
|
||||
seen->count++;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Append `text` to out[*pos], replacing field separators; returns 0 when it fit.
|
||||
static int append_field(char *out, size_t n, size_t *pos, const char *text) {
|
||||
for (const char *c = text; *c; c++) {
|
||||
if (*pos + 1 >= n) {
|
||||
return -1;
|
||||
}
|
||||
out[(*pos)++] = (*c == '\t' || *c == '\n' || *c == '\r') ? ' ' : *c;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int append_raw(char *out, size_t n, size_t *pos, const char *text) {
|
||||
size_t len = strlen(text);
|
||||
if (*pos + len + 1 > n) {
|
||||
return -1;
|
||||
}
|
||||
memcpy(out + *pos, text, len);
|
||||
*pos += len;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int eg_format_event(char *out, size_t n, const char *progname, int pid, const char *function,
|
||||
const char *action, const char *peer, const char *const *callers,
|
||||
int caller_count) {
|
||||
size_t pos = 0;
|
||||
char pid_text[16];
|
||||
snprintf(pid_text, sizeof pid_text, "%d", pid);
|
||||
if (append_raw(out, n, &pos, EG_EVENT_PREFIX "\t") != 0 ||
|
||||
append_field(out, n, &pos, progname ? progname : "?") != 0 ||
|
||||
append_raw(out, n, &pos, "\t") != 0 || append_raw(out, n, &pos, pid_text) != 0 ||
|
||||
append_raw(out, n, &pos, "\t") != 0 || append_field(out, n, &pos, function) != 0 ||
|
||||
append_raw(out, n, &pos, "\t") != 0 || append_field(out, n, &pos, action) != 0 ||
|
||||
append_raw(out, n, &pos, "\t") != 0 || append_field(out, n, &pos, peer) != 0 ||
|
||||
append_raw(out, n, &pos, "\t") != 0) {
|
||||
return -1;
|
||||
}
|
||||
for (int i = 0; i < caller_count && callers != NULL; i++) {
|
||||
if (i > 0 && append_raw(out, n, &pos, ",") != 0) {
|
||||
return -1;
|
||||
}
|
||||
// Commas separate callers, so they cannot appear inside one.
|
||||
for (const char *c = callers[i]; *c; c++) {
|
||||
char ch = (*c == ',' || *c == '\t' || *c == '\n' || *c == '\r') ? ' ' : *c;
|
||||
if (pos + 1 >= n) {
|
||||
return -1;
|
||||
}
|
||||
out[pos++] = ch;
|
||||
}
|
||||
}
|
||||
if (append_raw(out, n, &pos, "\n") != 0) {
|
||||
return -1;
|
||||
}
|
||||
out[pos] = '\0';
|
||||
return (int)pos;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Policy and formatting for the local egress guard. Pure functions with no
|
||||
// interposing, so they compile and test on macOS as well as in the simulator.
|
||||
#ifndef EAS_EGRESS_GUARD_POLICY_H
|
||||
#define EAS_EGRESS_GUARD_POLICY_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <sys/socket.h>
|
||||
|
||||
// What a destination address is, as far as the guard cares.
|
||||
typedef enum {
|
||||
EG_PASSTHROUGH = 0, // not an internet address (unix sockets, NULL, short); never touched
|
||||
EG_LOOPBACK = 1, // 127.0.0.0/8, ::1, ::ffff:127.x, unspecified; the proxy and forwards live here
|
||||
EG_REMOTE = 2, // anything else, including link-local and multicast
|
||||
} eg_class_t;
|
||||
|
||||
typedef enum {
|
||||
EG_MODE_BLOCK = 0, // refuse EG_REMOTE with ECONNREFUSED
|
||||
EG_MODE_LOG = 1, // observe only
|
||||
} eg_mode_t;
|
||||
|
||||
eg_class_t eg_classify(const struct sockaddr *sa, socklen_t len);
|
||||
|
||||
// "block" or unset selects block; "log" selects log. Anything else is block:
|
||||
// an unknown mode must fail closed.
|
||||
eg_mode_t eg_parse_mode(const char *value);
|
||||
|
||||
int eg_should_deny(eg_mode_t mode, eg_class_t cls);
|
||||
|
||||
// Writes "1.2.3.4:443" or "[2001:db8::1]:443". Returns 0 on success.
|
||||
int eg_format_peer(const struct sockaddr *sa, socklen_t len, char *out, size_t n);
|
||||
|
||||
// Fixed-capacity set of strings, so a retrying client logs a destination once
|
||||
// per process instead of once per attempt.
|
||||
#define EG_SEEN_CAPACITY 128
|
||||
#define EG_SEEN_KEY_LENGTH 192
|
||||
typedef struct {
|
||||
char keys[EG_SEEN_CAPACITY][EG_SEEN_KEY_LENGTH];
|
||||
int count;
|
||||
int overflow; // insertions refused because the table was full
|
||||
} eg_seen_t;
|
||||
|
||||
// 1 when the key was not present and was inserted; 0 when already present or
|
||||
// when the table is full (counted in overflow).
|
||||
int eg_seen_insert(eg_seen_t *seen, const char *key);
|
||||
|
||||
#define EG_EVENT_PREFIX "eas-egress-guard"
|
||||
|
||||
// Function name of the one event a process writes when its seen table fills:
|
||||
// from then on distinct destinations are still refused but no longer listed.
|
||||
#define EG_OVERFLOW_FUNCTION "overflow"
|
||||
|
||||
// One tab-separated line, newline terminated:
|
||||
// eas-egress-guard\t<progname>\t<pid>\t<function>\t<action>\t<peer>\t<caller>,<caller>...\n
|
||||
// Tabs and newlines inside fields are replaced with spaces. Returns the length
|
||||
// written, or -1 when it does not fit.
|
||||
int eg_format_event(char *out, size_t n, const char *progname, int pid, const char *function,
|
||||
const char *action, const char *peer, const char *const *callers,
|
||||
int caller_count);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,291 @@
|
||||
// Black-box host tests of the built guard, loaded through
|
||||
// DYLD_INSERT_LIBRARIES like the simulator does. Remote calls use fd -1 or are
|
||||
// refused before the kernel; the only real sockets are on loopback. Build and
|
||||
// run with tests/run-guard-tests.sh.
|
||||
//
|
||||
// guard-insert-test <case> <log-path> [<extra-path>]
|
||||
#include <arpa/inet.h>
|
||||
#include <dlfcn.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <netinet/in.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/uio.h>
|
||||
#include <unistd.h>
|
||||
|
||||
typedef int (*connect_fn)(int, const struct sockaddr *, socklen_t);
|
||||
typedef ssize_t (*sendto_fn)(int, const void *, size_t, int, const struct sockaddr *, socklen_t);
|
||||
typedef ssize_t (*sendmsg_fn)(int, const struct msghdr *, int);
|
||||
|
||||
static struct sockaddr_in remote(int port) {
|
||||
struct sockaddr_in a;
|
||||
memset(&a, 0, sizeof a);
|
||||
a.sin_family = AF_INET;
|
||||
a.sin_len = sizeof a;
|
||||
a.sin_port = htons(port);
|
||||
inet_pton(AF_INET, "192.0.2.1", &a.sin_addr);
|
||||
return a;
|
||||
}
|
||||
|
||||
static struct sockaddr_in6 remote6(int port) {
|
||||
struct sockaddr_in6 a;
|
||||
memset(&a, 0, sizeof a);
|
||||
a.sin6_family = AF_INET6;
|
||||
a.sin6_len = sizeof a;
|
||||
a.sin6_port = htons(port);
|
||||
inet_pton(AF_INET6, "2001:db8::1", &a.sin6_addr);
|
||||
return a;
|
||||
}
|
||||
|
||||
static int refused_sendto(int port) {
|
||||
struct sockaddr_in to = remote(port);
|
||||
errno = 0;
|
||||
return sendto(-1, "x", 1, 0, (struct sockaddr *)&to, sizeof to) == -1 && errno == ECONNREFUSED;
|
||||
}
|
||||
|
||||
static off_t file_size(const char *path) {
|
||||
struct stat st;
|
||||
return stat(path, &st) == 0 ? st.st_size : -1;
|
||||
}
|
||||
|
||||
static int count_lines(const char *path, const char *needle) {
|
||||
FILE *f = fopen(path, "r");
|
||||
if (f == NULL) {
|
||||
return -1;
|
||||
}
|
||||
int count = 0;
|
||||
char line[1024];
|
||||
while (fgets(line, sizeof line, f)) {
|
||||
if (strstr(line, needle)) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
return count;
|
||||
}
|
||||
|
||||
// Daemon-style cleanup: close every descriptor the process does not know
|
||||
// about, then open an application file. The guard's event must land in the
|
||||
// event log, not in that file.
|
||||
static int fd_reuse(const char *log_path, const char *app_path) {
|
||||
for (int fd = 3; fd < getdtablesize(); fd++) {
|
||||
close(fd);
|
||||
}
|
||||
int app_fd = open(app_path, O_CREAT | O_TRUNC | O_WRONLY, 0600);
|
||||
if (app_fd < 0 || !refused_sendto(9)) {
|
||||
return 2;
|
||||
}
|
||||
close(app_fd);
|
||||
if (file_size(app_path) != 0) {
|
||||
printf("FAIL fd-reuse: %lld bytes written into an unrelated file\n",
|
||||
(long long)file_size(app_path));
|
||||
return 1;
|
||||
}
|
||||
if (count_lines(log_path, "\tsendto\tblocked\t192.0.2.1:9\t") != 1) {
|
||||
printf("FAIL fd-reuse: event missing from the event log\n");
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Started with stdout closed, as the runner arranges: the process's own stdout
|
||||
// output must not end up in the event log, and the event must.
|
||||
static int low_fd(const char *log_path) {
|
||||
printf("STDOUT-LINE\n");
|
||||
fflush(stdout);
|
||||
if (!refused_sendto(9)) {
|
||||
return 2;
|
||||
}
|
||||
if (count_lines(log_path, "STDOUT-LINE") != 0) {
|
||||
return 1;
|
||||
}
|
||||
return count_lines(log_path, "\tsendto\tblocked\t192.0.2.1:9\t") == 1 ? 0 : 1;
|
||||
}
|
||||
|
||||
static int nocancel(void) {
|
||||
connect_fn nc_connect = (connect_fn)dlsym(RTLD_DEFAULT, "connect$NOCANCEL");
|
||||
sendto_fn nc_sendto = (sendto_fn)dlsym(RTLD_DEFAULT, "sendto$NOCANCEL");
|
||||
sendmsg_fn nc_sendmsg = (sendmsg_fn)dlsym(RTLD_DEFAULT, "sendmsg$NOCANCEL");
|
||||
if (!nc_connect || !nc_sendto || !nc_sendmsg) {
|
||||
return 2;
|
||||
}
|
||||
struct sockaddr_in to = remote(443);
|
||||
int failures = 0;
|
||||
errno = 0;
|
||||
if (nc_connect(-1, (struct sockaddr *)&to, sizeof to) != -1 || errno != ECONNREFUSED) {
|
||||
printf("FAIL nocancel: connect$NOCANCEL not refused (errno %d)\n", errno);
|
||||
failures++;
|
||||
}
|
||||
errno = 0;
|
||||
if (nc_sendto(-1, "x", 1, 0, (struct sockaddr *)&to, sizeof to) != -1 || errno != ECONNREFUSED) {
|
||||
printf("FAIL nocancel: sendto$NOCANCEL not refused (errno %d)\n", errno);
|
||||
failures++;
|
||||
}
|
||||
char payload[] = "x";
|
||||
struct iovec iov = {.iov_base = payload, .iov_len = 1};
|
||||
struct msghdr msg = {.msg_name = &to, .msg_namelen = sizeof to, .msg_iov = &iov, .msg_iovlen = 1};
|
||||
errno = 0;
|
||||
if (nc_sendmsg(-1, &msg, 0) != -1 || errno != ECONNREFUSED) {
|
||||
printf("FAIL nocancel: sendmsg$NOCANCEL not refused (errno %d)\n", errno);
|
||||
failures++;
|
||||
}
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
|
||||
// The shapes the kernel accepts as IPv4/IPv6 despite sa_family == AF_UNSPEC.
|
||||
static int unspec(void) {
|
||||
int failures = 0;
|
||||
struct sockaddr_in to = remote(443);
|
||||
to.sin_family = AF_UNSPEC;
|
||||
int tcp = socket(AF_INET, SOCK_STREAM, 0);
|
||||
errno = 0;
|
||||
if (connect(tcp, (struct sockaddr *)&to, sizeof to) != -1 || errno != ECONNREFUSED) {
|
||||
printf("FAIL unspec: TCP connect with AF_UNSPEC not refused (errno %d)\n", errno);
|
||||
failures++;
|
||||
}
|
||||
close(tcp);
|
||||
struct sockaddr_in6 to6 = remote6(443);
|
||||
to6.sin6_family = AF_UNSPEC;
|
||||
int tcp6 = socket(AF_INET6, SOCK_STREAM, 0);
|
||||
errno = 0;
|
||||
if (connect(tcp6, (struct sockaddr *)&to6, sizeof to6) != -1 || errno != ECONNREFUSED) {
|
||||
printf("FAIL unspec: TCP6 connect with AF_UNSPEC not refused (errno %d)\n", errno);
|
||||
failures++;
|
||||
}
|
||||
close(tcp6);
|
||||
errno = 0;
|
||||
if (sendto(-1, "x", 1, 0, (struct sockaddr *)&to, sizeof to) != -1 || errno != ECONNREFUSED) {
|
||||
printf("FAIL unspec: sendto with AF_UNSPEC not refused (errno %d)\n", errno);
|
||||
failures++;
|
||||
}
|
||||
char payload[] = "x";
|
||||
struct iovec iov = {.iov_base = payload, .iov_len = 1};
|
||||
struct msghdr msg = {.msg_name = &to, .msg_namelen = sizeof to, .msg_iov = &iov, .msg_iovlen = 1};
|
||||
errno = 0;
|
||||
if (sendmsg(-1, &msg, 0) != -1 || errno != ECONNREFUSED) {
|
||||
printf("FAIL unspec: sendmsg with AF_UNSPEC not refused (errno %d)\n", errno);
|
||||
failures++;
|
||||
}
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
|
||||
// A UDP socket associated with loopback dissolves the association through
|
||||
// connect(AF_UNSPEC), whatever address bytes follow the family. The guard
|
||||
// must let the kernel do that.
|
||||
static int udp_disconnect(void) {
|
||||
int fd = socket(AF_INET, SOCK_DGRAM, 0);
|
||||
struct sockaddr_in loopback;
|
||||
memset(&loopback, 0, sizeof loopback);
|
||||
loopback.sin_family = AF_INET;
|
||||
loopback.sin_len = sizeof loopback;
|
||||
loopback.sin_port = htons(9);
|
||||
loopback.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
if (connect(fd, (struct sockaddr *)&loopback, sizeof loopback) != 0) {
|
||||
return 2;
|
||||
}
|
||||
struct sockaddr_in peer;
|
||||
socklen_t peer_len = sizeof peer;
|
||||
if (getpeername(fd, (struct sockaddr *)&peer, &peer_len) != 0) {
|
||||
return 2;
|
||||
}
|
||||
int failures = 0;
|
||||
for (int stale = 0; stale < 2; stale++) {
|
||||
struct sockaddr_in dissolve = stale ? remote(443) : loopback;
|
||||
if (!stale) {
|
||||
memset(&dissolve, 0, sizeof dissolve);
|
||||
dissolve.sin_len = sizeof dissolve;
|
||||
}
|
||||
dissolve.sin_family = AF_UNSPEC;
|
||||
if (connect(fd, (struct sockaddr *)&loopback, sizeof loopback) != 0) {
|
||||
return 2;
|
||||
}
|
||||
errno = 0;
|
||||
int rc = connect(fd, (struct sockaddr *)&dissolve, sizeof dissolve);
|
||||
int err = errno;
|
||||
peer_len = sizeof peer;
|
||||
int still_connected = getpeername(fd, (struct sockaddr *)&peer, &peer_len) == 0;
|
||||
if (rc != -1 || err != EAFNOSUPPORT || still_connected) {
|
||||
printf("FAIL udp-disconnect (%s address): rc=%d errno=%d still_connected=%d\n",
|
||||
stale ? "stale remote" : "zeroed", rc, err, still_connected);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
close(fd);
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
|
||||
// Past 128 distinct destinations a process writes one overflow line and
|
||||
// nothing more; refusal continues.
|
||||
static int overflow(const char *log_path) {
|
||||
for (int port = 1; port <= 140; port++) {
|
||||
if (!refused_sendto(port)) {
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
int listed = count_lines(log_path, "\tsendto\tblocked\t192.0.2.1:");
|
||||
int overflow_lines = count_lines(log_path, "\toverflow\tblocked\t128 distinct destinations\t");
|
||||
if (listed != 128 || overflow_lines != 1) {
|
||||
printf("FAIL overflow: %d destinations listed, %d overflow line(s)\n", listed, overflow_lines);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// The event names the images above the guard, not the guard itself.
|
||||
static int callers(const char *log_path) {
|
||||
if (!refused_sendto(9)) {
|
||||
return 2;
|
||||
}
|
||||
FILE *f = fopen(log_path, "r");
|
||||
if (f == NULL) {
|
||||
return 2;
|
||||
}
|
||||
char line[1024];
|
||||
int ok = 0;
|
||||
while (fgets(line, sizeof line, f)) {
|
||||
if (strstr(line, "\tsendto\tblocked\t192.0.2.1:9\t")) {
|
||||
const char *callers_field = strrchr(line, '\t') + 1;
|
||||
ok = strstr(callers_field, "guard-insert-test") != NULL &&
|
||||
strstr(callers_field, "egress-guard") == NULL;
|
||||
if (!ok) {
|
||||
printf("FAIL callers: %s", line);
|
||||
}
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc < 3) {
|
||||
return 2;
|
||||
}
|
||||
const char *name = argv[1];
|
||||
const char *log_path = argv[2];
|
||||
if (strcmp(name, "fd-reuse") == 0) {
|
||||
return argc == 4 ? fd_reuse(log_path, argv[3]) : 2;
|
||||
}
|
||||
if (strcmp(name, "low-fd") == 0) {
|
||||
return low_fd(log_path);
|
||||
}
|
||||
if (strcmp(name, "nocancel") == 0) {
|
||||
return nocancel();
|
||||
}
|
||||
if (strcmp(name, "unspec") == 0) {
|
||||
return unspec();
|
||||
}
|
||||
if (strcmp(name, "udp-disconnect") == 0) {
|
||||
return udp_disconnect();
|
||||
}
|
||||
if (strcmp(name, "callers") == 0) {
|
||||
return callers(log_path);
|
||||
}
|
||||
if (strcmp(name, "overflow") == 0) {
|
||||
return overflow(log_path);
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// White-box host tests of guard.c's dedupe lock: the schedules that would
|
||||
// abort the process are forced deterministically. Every case runs in its own
|
||||
// process; sockets are never opened (fd -1), so nothing can leave the host.
|
||||
// Build and run with tests/run-guard-tests.sh.
|
||||
#include "../policy.h"
|
||||
|
||||
#include <pthread.h>
|
||||
#include <signal.h>
|
||||
#include <sys/wait.h>
|
||||
|
||||
static int scheduled_seen_insert(eg_seen_t *seen, const char *key);
|
||||
#define eg_seen_insert scheduled_seen_insert
|
||||
#include "../guard.c"
|
||||
#undef eg_seen_insert
|
||||
|
||||
#include <arpa/inet.h>
|
||||
|
||||
#define CONTENTION_THREADS 8
|
||||
#define CONTENTION_KEYS 12
|
||||
#define CONTENTION_ROUNDS 25
|
||||
|
||||
static int interrupt_insert;
|
||||
static int ready_pipe[2];
|
||||
static int release_pipe[2];
|
||||
|
||||
static struct sockaddr_in remote(int port) {
|
||||
struct sockaddr_in a;
|
||||
memset(&a, 0, sizeof a);
|
||||
a.sin_family = AF_INET;
|
||||
a.sin_len = sizeof a;
|
||||
a.sin_port = htons(port);
|
||||
inet_pton(AF_INET, "192.0.2.1", &a.sin_addr);
|
||||
return a;
|
||||
}
|
||||
|
||||
// Block mode must refuse before touching the kernel, even on an invalid fd.
|
||||
static void send_remote(int port) {
|
||||
struct sockaddr_in to = remote(port);
|
||||
if (eg_sendto(-1, "x", 1, 0, (struct sockaddr *)&to, sizeof to) != -1 || errno != ECONNREFUSED) {
|
||||
_exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
static void on_signal(int sig) {
|
||||
(void)sig;
|
||||
send_remote(9);
|
||||
}
|
||||
|
||||
static int scheduled_seen_insert(eg_seen_t *seen, const char *key) {
|
||||
if (interrupt_insert) {
|
||||
interrupt_insert = 0;
|
||||
raise(SIGUSR1);
|
||||
}
|
||||
return eg_seen_insert(seen, key);
|
||||
}
|
||||
|
||||
static int signal_reentry(void) {
|
||||
signal(SIGUSR1, on_signal);
|
||||
interrupt_insert = 1;
|
||||
send_remote(9);
|
||||
send_remote(10);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void *hold_lock(void *unused) {
|
||||
(void)unused;
|
||||
char byte = 'x';
|
||||
os_unfair_lock_lock(&eg_lock);
|
||||
(void)write(ready_pipe[1], &byte, 1);
|
||||
(void)read(release_pipe[0], &byte, 1);
|
||||
os_unfair_lock_unlock(&eg_lock);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static int fork_while_locked(void) {
|
||||
if (pipe(ready_pipe) || pipe(release_pipe)) {
|
||||
return 2;
|
||||
}
|
||||
pthread_t thread;
|
||||
if (pthread_create(&thread, NULL, hold_lock, NULL)) {
|
||||
return 2;
|
||||
}
|
||||
char byte;
|
||||
if (read(ready_pipe[0], &byte, 1) != 1) {
|
||||
return 2;
|
||||
}
|
||||
pid_t child = fork();
|
||||
if (child < 0) {
|
||||
return 2;
|
||||
}
|
||||
if (child == 0) {
|
||||
alarm(5);
|
||||
send_remote(9);
|
||||
send_remote(10);
|
||||
_exit(0);
|
||||
}
|
||||
(void)write(release_pipe[1], "x", 1);
|
||||
pthread_join(thread, NULL);
|
||||
int status;
|
||||
waitpid(child, &status, 0);
|
||||
if (WIFSIGNALED(status)) {
|
||||
printf("FAIL fork: child terminated by signal %d\n", WTERMSIG(status));
|
||||
return 1;
|
||||
}
|
||||
return WIFEXITED(status) ? WEXITSTATUS(status) : 2;
|
||||
}
|
||||
|
||||
static void *hammer(void *unused) {
|
||||
(void)unused;
|
||||
for (int round = 0; round < CONTENTION_ROUNDS; round++) {
|
||||
for (int key = 0; key < CONTENTION_KEYS; key++) {
|
||||
send_remote(1000 + key);
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Under contention a thread may give up its insert; the next attempt on the
|
||||
// same key must then record it, so every key ends up logged exactly once.
|
||||
static int contention(const char *log_path) {
|
||||
pthread_t threads[CONTENTION_THREADS];
|
||||
for (int i = 0; i < CONTENTION_THREADS; i++) {
|
||||
if (pthread_create(&threads[i], NULL, hammer, NULL)) {
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < CONTENTION_THREADS; i++) {
|
||||
pthread_join(threads[i], NULL);
|
||||
}
|
||||
FILE *log = fopen(log_path, "r");
|
||||
if (log == NULL) {
|
||||
return 2;
|
||||
}
|
||||
int counts[CONTENTION_KEYS] = {0};
|
||||
int total = 0;
|
||||
char line[1024];
|
||||
while (fgets(line, sizeof line, log)) {
|
||||
total++;
|
||||
for (int key = 0; key < CONTENTION_KEYS; key++) {
|
||||
char needle[64];
|
||||
snprintf(needle, sizeof needle, "\tsendto\tblocked\t192.0.2.1:%d\t", 1000 + key);
|
||||
if (strstr(line, needle)) {
|
||||
counts[key]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
fclose(log);
|
||||
int failures = 0;
|
||||
for (int key = 0; key < CONTENTION_KEYS; key++) {
|
||||
if (counts[key] != 1) {
|
||||
printf("FAIL contention: 192.0.2.1:%d logged %d times\n", 1000 + key, counts[key]);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
if (total != CONTENTION_KEYS) {
|
||||
printf("FAIL contention: %d lines for %d keys\n", total, CONTENTION_KEYS);
|
||||
failures++;
|
||||
}
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc != 2) {
|
||||
return 2;
|
||||
}
|
||||
eg_mode = EG_MODE_BLOCK;
|
||||
if (strcmp(argv[1], "signal") == 0) {
|
||||
return signal_reentry();
|
||||
}
|
||||
if (strcmp(argv[1], "fork") == 0) {
|
||||
return fork_while_locked();
|
||||
}
|
||||
if (strcmp(argv[1], "contention") == 0) {
|
||||
return contention(eg_log_path);
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// Probe run inside a simulator by the egress guard end-to-end test. Exercises
|
||||
// every outbound path the guard must cover and prints one JSON object.
|
||||
//
|
||||
// nettest --proxy-port <port> --udp-port <port>
|
||||
//
|
||||
// proxy-port: a CONNECT proxy on the host's loopback, standing in for the
|
||||
// egress proxy. udp-port: a UDP echo on loopback.
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
var results: [String: String] = [:]
|
||||
let args = CommandLine.arguments
|
||||
func arg(_ name: String) -> Int {
|
||||
guard let i = args.firstIndex(of: name), i + 1 < args.count else { return 0 }
|
||||
return Int(args[i + 1]) ?? 0
|
||||
}
|
||||
let proxyPort = arg("--proxy-port")
|
||||
let udpPort = arg("--udp-port")
|
||||
let remoteHost = "example.com"
|
||||
let remotePort = 443
|
||||
|
||||
func classify(_ error: Error?) -> String {
|
||||
guard let e = error as NSError? else { return "ok" }
|
||||
if e.domain == NSURLErrorDomain && e.code == NSURLErrorCannotConnectToHost { return "refused" }
|
||||
if e.domain == NSPOSIXErrorDomain && e.code == Int(ECONNREFUSED) { return "refused" }
|
||||
return "error(\(e.domain):\(e.code))"
|
||||
}
|
||||
|
||||
// 1. URLSession straight to the internet. Blocked by the guard.
|
||||
func urlSessionDirect() {
|
||||
let g = DispatchGroup(); g.enter()
|
||||
let c = URLSessionConfiguration.ephemeral; c.timeoutIntervalForRequest = 10
|
||||
c.connectionProxyDictionary = [:] // explicitly no proxy, even if the host has one
|
||||
URLSession(configuration: c).dataTask(with: URL(string: "https://\(remoteHost)/")!) { _, r, e in
|
||||
results["urlsessionDirect"] = e == nil ? "ok(\((r as? HTTPURLResponse)?.statusCode ?? 0))" : classify(e); g.leave()
|
||||
}.resume(); g.wait()
|
||||
}
|
||||
|
||||
// 2. URLSession through a CONNECT proxy on loopback. Allowed: the guard only
|
||||
// sees a loopback connect.
|
||||
func urlSessionProxied() {
|
||||
let g = DispatchGroup(); g.enter()
|
||||
let c = URLSessionConfiguration.ephemeral; c.timeoutIntervalForRequest = 15
|
||||
// The kCFNetworkProxies* constants are not exposed to Swift on iOS; these are their values.
|
||||
c.connectionProxyDictionary = ["HTTPSEnable": 1, "HTTPSProxy": "127.0.0.1", "HTTPSPort": proxyPort]
|
||||
URLSession(configuration: c).dataTask(with: URL(string: "https://\(remoteHost)/")!) { _, r, e in
|
||||
results["urlsessionProxied"] = e == nil ? "ok(\((r as? HTTPURLResponse)?.statusCode ?? 0))" : classify(e); g.leave()
|
||||
}.resume(); g.wait()
|
||||
}
|
||||
|
||||
// 3. Network.framework connection straight out. Blocked.
|
||||
func nwConnectionDirect() {
|
||||
let g = DispatchGroup(); g.enter()
|
||||
let p = NWParameters.tls; p.preferNoProxies = true
|
||||
let c = NWConnection(host: NWEndpoint.Host(remoteHost), port: NWEndpoint.Port(integerLiteral: UInt16(remotePort)), using: p)
|
||||
var done = false
|
||||
c.stateUpdateHandler = { s in
|
||||
guard !done else { return }
|
||||
switch s {
|
||||
case .ready: done = true; results["nwconnectionDirect"] = "ok"; c.cancel(); g.leave()
|
||||
case .failed(let e): done = true; results["nwconnectionDirect"] = classify(e); g.leave()
|
||||
case .waiting(let e): done = true; results["nwconnectionDirect"] = "waiting(" + classify(e) + ")"; c.cancel(); g.leave()
|
||||
default: break
|
||||
}
|
||||
}
|
||||
c.start(queue: .global())
|
||||
_ = g.wait(timeout: .now() + 15)
|
||||
if results["nwconnectionDirect"] == nil { results["nwconnectionDirect"] = "timeout"; c.cancel() }
|
||||
}
|
||||
|
||||
// 4. Plain BSD TCP connect to a resolved address. Blocked.
|
||||
func bsdConnectDirect() {
|
||||
var hints = addrinfo(); hints.ai_socktype = SOCK_STREAM; hints.ai_family = AF_INET
|
||||
var info: UnsafeMutablePointer<addrinfo>? = nil
|
||||
guard getaddrinfo(remoteHost, "443", &hints, &info) == 0, let ai = info else { results["bsdConnectDirect"] = "dns-failed"; return }
|
||||
results["dns"] = "ok"
|
||||
let fd = socket(ai.pointee.ai_family, ai.pointee.ai_socktype, ai.pointee.ai_protocol)
|
||||
let rc = connect(fd, ai.pointee.ai_addr, ai.pointee.ai_addrlen)
|
||||
results["bsdConnectDirect"] = rc == 0 ? "ok" : (errno == ECONNREFUSED ? "refused" : "errno(\(errno))")
|
||||
close(fd); freeaddrinfo(info)
|
||||
}
|
||||
|
||||
// 5. UDP datagram straight out. Blocked. 6. UDP datagram to loopback echo. Allowed.
|
||||
func udp(_ key: String, _ host: String, _ port: Int, expectEcho: Bool) {
|
||||
let fd = socket(AF_INET, SOCK_DGRAM, 0)
|
||||
var to = sockaddr_in(); to.sin_family = sa_family_t(AF_INET); to.sin_port = in_port_t(UInt16(port)).bigEndian
|
||||
inet_pton(AF_INET, host, &to.sin_addr)
|
||||
var payload: [UInt8] = Array("ping".utf8)
|
||||
let sent = withUnsafePointer(to: &to) { p in p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
sendto(fd, &payload, payload.count, 0, sa, socklen_t(MemoryLayout<sockaddr_in>.size)) } }
|
||||
if sent < 0 { results[key] = errno == ECONNREFUSED ? "refused" : "errno(\(errno))"; close(fd); return }
|
||||
if expectEcho {
|
||||
var tv = timeval(tv_sec: 3, tv_usec: 0); setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
|
||||
var buf = [UInt8](repeating: 0, count: 16)
|
||||
let n = recv(fd, &buf, buf.count, 0)
|
||||
results[key] = n > 0 ? "ok" : "no-echo"
|
||||
} else { results[key] = "sent" }
|
||||
close(fd)
|
||||
}
|
||||
|
||||
urlSessionDirect()
|
||||
urlSessionProxied()
|
||||
nwConnectionDirect()
|
||||
bsdConnectDirect()
|
||||
udp("udpDirect", "8.8.8.8", 53, expectEcho: false)
|
||||
udp("udpLoopback", "127.0.0.1", udpPort, expectEcho: true)
|
||||
|
||||
// 7. The same literal destination twice: the guard must log it once per process.
|
||||
func bsdConnectLiteral(_ key: String) {
|
||||
var to = sockaddr_in(); to.sin_family = sa_family_t(AF_INET); to.sin_port = in_port_t(UInt16(443)).bigEndian
|
||||
inet_pton(AF_INET, "1.1.1.1", &to.sin_addr)
|
||||
let fd = socket(AF_INET, SOCK_STREAM, 0)
|
||||
let rc = withUnsafePointer(to: &to) { p in p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
connect(fd, sa, socklen_t(MemoryLayout<sockaddr_in>.size)) } }
|
||||
results[key] = rc == 0 ? "ok" : (errno == ECONNREFUSED ? "refused" : "errno(\(errno))")
|
||||
close(fd)
|
||||
}
|
||||
bsdConnectLiteral("literalFirst")
|
||||
bsdConnectLiteral("literalSecond")
|
||||
|
||||
// 8. A sockaddr_in whose family was left AF_UNSPEC: the kernel connects it as
|
||||
// IPv4, so the guard must refuse it. 9. connect(AF_UNSPEC) on a UDP socket
|
||||
// dissolves the association; the guard must leave that to the kernel.
|
||||
func unspecConnect() {
|
||||
var to = sockaddr_in(); to.sin_family = sa_family_t(AF_UNSPEC); to.sin_port = in_port_t(UInt16(443)).bigEndian
|
||||
inet_pton(AF_INET, "1.0.0.1", &to.sin_addr)
|
||||
let fd = socket(AF_INET, SOCK_STREAM, 0)
|
||||
let rc = withUnsafePointer(to: &to) { p in p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
connect(fd, sa, socklen_t(MemoryLayout<sockaddr_in>.size)) } }
|
||||
results["unspecConnect"] = rc == 0 ? "ok" : (errno == ECONNREFUSED ? "refused" : "errno(\(errno))")
|
||||
close(fd)
|
||||
}
|
||||
func udpDisconnect() {
|
||||
let fd = socket(AF_INET, SOCK_DGRAM, 0)
|
||||
var to = sockaddr_in(); to.sin_family = sa_family_t(AF_INET); to.sin_port = in_port_t(UInt16(udpPort)).bigEndian
|
||||
inet_pton(AF_INET, "127.0.0.1", &to.sin_addr)
|
||||
let associated = withUnsafePointer(to: &to) { p in p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
connect(fd, sa, socklen_t(MemoryLayout<sockaddr_in>.size)) } }
|
||||
var dissolve = sockaddr_in(); dissolve.sin_family = sa_family_t(AF_UNSPEC); dissolve.sin_port = in_port_t(UInt16(443)).bigEndian
|
||||
inet_pton(AF_INET, "1.1.1.1", &dissolve.sin_addr)
|
||||
let rc = withUnsafePointer(to: &dissolve) { p in p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
connect(fd, sa, socklen_t(MemoryLayout<sockaddr_in>.size)) } }
|
||||
let err = errno
|
||||
var peer = sockaddr_in(); var peerLength = socklen_t(MemoryLayout<sockaddr_in>.size)
|
||||
let stillConnected = withUnsafeMutablePointer(to: &peer) { p in p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
getpeername(fd, sa, &peerLength) } } == 0
|
||||
results["udpDisconnect"] = associated != 0 ? "associate-failed"
|
||||
: (rc == -1 && err == EAFNOSUPPORT && !stillConnected) ? "ok" : "errno(\(err)),connected=\(stillConnected)"
|
||||
close(fd)
|
||||
}
|
||||
// 10. The non-cancelable variant of sendto, as libsystem exports it. Blocked.
|
||||
func nocancelSendto() {
|
||||
typealias SendtoFn = @convention(c) (Int32, UnsafeRawPointer?, Int, Int32, UnsafePointer<sockaddr>?, socklen_t) -> Int
|
||||
guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "sendto$NOCANCEL") else {
|
||||
results["nocancelSendto"] = "no-symbol"; return
|
||||
}
|
||||
let nocancelSendto = unsafeBitCast(symbol, to: SendtoFn.self)
|
||||
let fd = socket(AF_INET, SOCK_DGRAM, 0)
|
||||
var to = sockaddr_in(); to.sin_family = sa_family_t(AF_INET); to.sin_port = in_port_t(UInt16(53)).bigEndian
|
||||
inet_pton(AF_INET, "8.8.8.8", &to.sin_addr)
|
||||
var payload: [UInt8] = Array("ping".utf8)
|
||||
let sent = withUnsafePointer(to: &to) { p in p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
nocancelSendto(fd, &payload, payload.count, 0, sa, socklen_t(MemoryLayout<sockaddr_in>.size)) } }
|
||||
results["nocancelSendto"] = sent < 0 ? (errno == ECONNREFUSED ? "refused" : "errno(\(errno))") : "sent"
|
||||
close(fd)
|
||||
}
|
||||
unspecConnect()
|
||||
udpDisconnect()
|
||||
nocancelSendto()
|
||||
|
||||
let json = try! JSONSerialization.data(withJSONObject: results, options: [.sortedKeys])
|
||||
print(String(data: json, encoding: .utf8)!)
|
||||
@@ -0,0 +1,143 @@
|
||||
// Host-runnable tests for the guard's policy and formatting. Build and run
|
||||
// with tests/run-policy-tests.sh; every failure prints the failing line.
|
||||
#include "../policy.h"
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <sys/un.h>
|
||||
|
||||
static int failures = 0;
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
failures++; \
|
||||
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static struct sockaddr_in v4(const char *ip, int port) {
|
||||
struct sockaddr_in a; memset(&a, 0, sizeof a);
|
||||
a.sin_family = AF_INET; a.sin_len = sizeof a; a.sin_port = htons(port);
|
||||
inet_pton(AF_INET, ip, &a.sin_addr);
|
||||
return a;
|
||||
}
|
||||
static struct sockaddr_in6 v6(const char *ip, int port) {
|
||||
struct sockaddr_in6 a; memset(&a, 0, sizeof a);
|
||||
a.sin6_family = AF_INET6; a.sin6_len = sizeof a; a.sin6_port = htons(port);
|
||||
inet_pton(AF_INET6, ip, &a.sin6_addr);
|
||||
return a;
|
||||
}
|
||||
|
||||
static void test_classify(void) {
|
||||
struct sockaddr_in a4; struct sockaddr_in6 a6; struct sockaddr_un un;
|
||||
|
||||
a4 = v4("127.0.0.1", 8899); CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_LOOPBACK);
|
||||
a4 = v4("127.42.0.9", 80); CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_LOOPBACK);
|
||||
a4 = v4("0.0.0.0", 80); CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_LOOPBACK);
|
||||
a4 = v4("93.184.216.34", 443); CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_REMOTE);
|
||||
a4 = v4("192.168.1.10", 8081); CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_REMOTE);
|
||||
a4 = v4("169.254.1.1", 80); CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_REMOTE);
|
||||
a4 = v4("224.0.0.251", 5353); CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_REMOTE);
|
||||
|
||||
a6 = v6("::1", 8899); CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_LOOPBACK);
|
||||
a6 = v6("::", 80); CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_LOOPBACK);
|
||||
a6 = v6("::ffff:127.0.0.1", 8899); CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_LOOPBACK);
|
||||
a6 = v6("::ffff:93.184.216.34", 443); CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_REMOTE);
|
||||
a6 = v6("2606:4700::1", 443); CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_REMOTE);
|
||||
a6 = v6("fe80::1", 443); CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_REMOTE);
|
||||
|
||||
memset(&un, 0, sizeof un); un.sun_family = AF_UNIX; strcpy(un.sun_path, "/var/run/mDNSResponder");
|
||||
CHECK(eg_classify((struct sockaddr *)&un, sizeof un) == EG_PASSTHROUGH);
|
||||
CHECK(eg_classify(NULL, 0) == EG_PASSTHROUGH);
|
||||
a4 = v4("93.184.216.34", 443); CHECK(eg_classify((struct sockaddr *)&a4, 4) == EG_PASSTHROUGH); // too short to read
|
||||
|
||||
// AF_UNSPEC is what the kernel makes of it: the family the exact length implies.
|
||||
a4 = v4("93.184.216.34", 443); a4.sin_family = AF_UNSPEC;
|
||||
CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_REMOTE);
|
||||
CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4 + 8) == EG_PASSTHROUGH); // kernel: EINVAL
|
||||
a4 = v4("127.0.0.1", 8899); a4.sin_family = AF_UNSPEC;
|
||||
CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_LOOPBACK);
|
||||
a4 = v4("0.0.0.0", 0); a4.sin_family = AF_UNSPEC;
|
||||
CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_LOOPBACK); // UDP disconnect shape
|
||||
a6 = v6("2606:4700::1", 443); a6.sin6_family = AF_UNSPEC;
|
||||
CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_REMOTE);
|
||||
a6 = v6("::ffff:93.184.216.34", 443); a6.sin6_family = AF_UNSPEC;
|
||||
CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_REMOTE);
|
||||
a6 = v6("::1", 443); a6.sin6_family = AF_UNSPEC;
|
||||
CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_LOOPBACK);
|
||||
memset(&un, 0, sizeof un); un.sun_family = AF_UNSPEC;
|
||||
CHECK(eg_classify((struct sockaddr *)&un, sizeof un) == EG_PASSTHROUGH);
|
||||
}
|
||||
|
||||
static void test_mode(void) {
|
||||
CHECK(eg_parse_mode(NULL) == EG_MODE_BLOCK);
|
||||
CHECK(eg_parse_mode("") == EG_MODE_BLOCK);
|
||||
CHECK(eg_parse_mode("block") == EG_MODE_BLOCK);
|
||||
CHECK(eg_parse_mode("log") == EG_MODE_LOG);
|
||||
CHECK(eg_parse_mode("LOG") == EG_MODE_BLOCK);
|
||||
CHECK(eg_parse_mode("permissive") == EG_MODE_BLOCK);
|
||||
|
||||
CHECK(eg_should_deny(EG_MODE_BLOCK, EG_REMOTE) == 1);
|
||||
CHECK(eg_should_deny(EG_MODE_BLOCK, EG_LOOPBACK) == 0);
|
||||
CHECK(eg_should_deny(EG_MODE_BLOCK, EG_PASSTHROUGH) == 0);
|
||||
CHECK(eg_should_deny(EG_MODE_LOG, EG_REMOTE) == 0);
|
||||
}
|
||||
|
||||
static void test_format_peer(void) {
|
||||
char out[64];
|
||||
struct sockaddr_in a4 = v4("93.184.216.34", 443);
|
||||
CHECK(eg_format_peer((struct sockaddr *)&a4, sizeof a4, out, sizeof out) == 0);
|
||||
CHECK(strcmp(out, "93.184.216.34:443") == 0);
|
||||
struct sockaddr_in6 a6 = v6("2606:4700::1", 443);
|
||||
CHECK(eg_format_peer((struct sockaddr *)&a6, sizeof a6, out, sizeof out) == 0);
|
||||
CHECK(strcmp(out, "[2606:4700::1]:443") == 0);
|
||||
CHECK(eg_format_peer((struct sockaddr *)&a4, sizeof a4, out, 8) != 0);
|
||||
struct sockaddr_un un; memset(&un, 0, sizeof un); un.sun_family = AF_UNIX;
|
||||
CHECK(eg_format_peer((struct sockaddr *)&un, sizeof un, out, sizeof out) != 0);
|
||||
a4.sin_family = AF_UNSPEC;
|
||||
CHECK(eg_format_peer((struct sockaddr *)&a4, sizeof a4, out, sizeof out) == 0);
|
||||
CHECK(strcmp(out, "93.184.216.34:443") == 0);
|
||||
a6.sin6_family = AF_UNSPEC;
|
||||
CHECK(eg_format_peer((struct sockaddr *)&a6, sizeof a6, out, sizeof out) == 0);
|
||||
CHECK(strcmp(out, "[2606:4700::1]:443") == 0);
|
||||
}
|
||||
|
||||
static void test_seen(void) {
|
||||
static eg_seen_t seen; memset(&seen, 0, sizeof seen);
|
||||
CHECK(eg_seen_insert(&seen, "connect 1.1.1.1:443") == 1);
|
||||
CHECK(eg_seen_insert(&seen, "connect 1.1.1.1:443") == 0);
|
||||
CHECK(eg_seen_insert(&seen, "sendto 1.1.1.1:443") == 1);
|
||||
CHECK(seen.count == 2);
|
||||
char key[64];
|
||||
for (int i = 0; i < EG_SEEN_CAPACITY + 5; i++) { snprintf(key, sizeof key, "k%d", i); eg_seen_insert(&seen, key); }
|
||||
CHECK(seen.count == EG_SEEN_CAPACITY);
|
||||
CHECK(seen.overflow == 7); // 2 + 128 + 5 attempts, 128 fit
|
||||
CHECK(eg_seen_insert(&seen, "k0") == 0); // still remembered
|
||||
}
|
||||
|
||||
static void test_format_event(void) {
|
||||
char out[512];
|
||||
const char *callers[] = {"Network", "CFNetwork", "MyApp"};
|
||||
int n = eg_format_event(out, sizeof out, "MyApp", 4242, "connect", "blocked", "93.184.216.34:443", callers, 3);
|
||||
CHECK(n > 0);
|
||||
CHECK(strcmp(out, "eas-egress-guard\tMyApp\t4242\tconnect\tblocked\t93.184.216.34:443\tNetwork,CFNetwork,MyApp\n") == 0);
|
||||
|
||||
const char *tabbed[] = {"a\tb"};
|
||||
n = eg_format_event(out, sizeof out, "we\nird\tname", 1, "sendto", "logged", "1.2.3.4:53", tabbed, 1);
|
||||
CHECK(n > 0);
|
||||
CHECK(strcmp(out, "eas-egress-guard\twe ird name\t1\tsendto\tlogged\t1.2.3.4:53\ta b\n") == 0);
|
||||
|
||||
n = eg_format_event(out, sizeof out, "NoCallers", 7, "connectx", "blocked", "[::2]:80", NULL, 0);
|
||||
CHECK(n > 0 && strcmp(out, "eas-egress-guard\tNoCallers\t7\tconnectx\tblocked\t[::2]:80\t\n") == 0);
|
||||
|
||||
CHECK(eg_format_event(out, 16, "MyApp", 4242, "connect", "blocked", "93.184.216.34:443", callers, 3) == -1);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
test_classify(); test_mode(); test_format_peer(); test_seen(); test_format_event();
|
||||
if (failures) { fprintf(stderr, "%d failure(s)\n", failures); return 1; }
|
||||
printf("egress-guard policy tests: all passed\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds the guard for the host (macOS) and runs the guard tests against it:
|
||||
# white-box lock schedules with guard.c compiled in, and black-box cases with
|
||||
# the built library inserted the way the simulator inserts it. No socket is
|
||||
# ever opened to a non-loopback address.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
out=$(mktemp -d)
|
||||
trap 'rm -rf "$out"' EXIT
|
||||
|
||||
clang -std=c11 -Wall -Wextra -Werror -O2 -dynamiclib policy.c guard.c -o "$out/egress-guard.dylib"
|
||||
clang -std=c11 -Wall -Wextra -Werror -O2 -pthread policy.c tests/guard_test.c -o "$out/guard-test"
|
||||
clang -std=c11 -Wall -Wextra -Werror -O2 tests/guard_insert_test.c -o "$out/guard-insert-test"
|
||||
|
||||
failures=0
|
||||
report() {
|
||||
if [[ $2 -eq 0 ]]; then
|
||||
echo "ok $1"
|
||||
else
|
||||
echo "FAIL $1 (exit $2)"
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# White-box: guard.c compiled into the test, lock schedules forced.
|
||||
for name in signal fork contention; do
|
||||
status=0
|
||||
: > "$out/$name.log"
|
||||
EAS_EGRESS_GUARD_MODE=block EAS_EGRESS_GUARD_LOG="$out/$name.log" \
|
||||
"$out/guard-test" "$name" || status=$?
|
||||
report "$name" "$status"
|
||||
done
|
||||
|
||||
# Black-box: the built library inserted into a plain process.
|
||||
export DYLD_INSERT_LIBRARIES="$out/egress-guard.dylib"
|
||||
export EAS_EGRESS_GUARD_MODE=block
|
||||
|
||||
status=0
|
||||
: > "$out/fd-reuse.log"
|
||||
EAS_EGRESS_GUARD_LOG="$out/fd-reuse.log" \
|
||||
"$out/guard-insert-test" fd-reuse "$out/fd-reuse.log" "$out/application-file" || status=$?
|
||||
report fd-reuse "$status"
|
||||
|
||||
status=0
|
||||
: > "$out/low-fd.log"
|
||||
EAS_EGRESS_GUARD_LOG="$out/low-fd.log" \
|
||||
"$out/guard-insert-test" low-fd "$out/low-fd.log" 1>&- || status=$?
|
||||
report low-fd "$status"
|
||||
|
||||
for name in nocancel unspec udp-disconnect callers overflow; do
|
||||
status=0
|
||||
: > "$out/$name.log"
|
||||
EAS_EGRESS_GUARD_LOG="$out/$name.log" \
|
||||
"$out/guard-insert-test" "$name" "$out/$name.log" || status=$?
|
||||
report "$name" "$status"
|
||||
done
|
||||
|
||||
if [[ $failures -ne 0 ]]; then
|
||||
echo "egress-guard guard tests: $failures failure(s)"
|
||||
exit 1
|
||||
fi
|
||||
echo "egress-guard guard tests: all passed"
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds the policy unit tests for the host (macOS) and runs them.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
out=$(mktemp -d)
|
||||
clang -std=c11 -Wall -Wextra -Werror -o "$out/policy_test" policy.c tests/policy_test.c
|
||||
"$out/policy_test"
|
||||
@@ -0,0 +1,130 @@
|
||||
// Regenerates src/steps/utils/appiumCommands.generated.ts from Appium and the
|
||||
// XCUITest and UiAutomator2 drivers, so TypeScript can verify that we handle
|
||||
// every command that can appear in Appium event timings.
|
||||
//
|
||||
// Appium and its drivers are NOT dependencies of this package. Like repack, we
|
||||
// install them on demand into a temp directory just for this script, so nothing
|
||||
// heavy ships with @expo/build-tools.
|
||||
//
|
||||
// Run with: yarn workspace @expo/build-tools generate-appium-commands
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
|
||||
// Track the Appium 3 line, matching the appium@^3 the worker installs for
|
||||
// simulator sessions (see startAppiumRemoteSession.ts).
|
||||
const APPIUM_VERSION = '^3';
|
||||
const DRIVERS = [
|
||||
{
|
||||
packageName: 'appium-xcuitest-driver',
|
||||
exportName: 'XCUITestDriver',
|
||||
typeName: 'XCUITestAppiumCommand',
|
||||
},
|
||||
{
|
||||
packageName: 'appium-uiautomator2-driver',
|
||||
exportName: 'AndroidUiautomator2Driver',
|
||||
typeName: 'UiAutomator2AppiumCommand',
|
||||
},
|
||||
];
|
||||
|
||||
function renderCommandType(typeName, commands) {
|
||||
return `export type ${typeName} =\n${commands.map(command => ` | ${serializeStringLiteral(command)}`).join('\n')};`;
|
||||
}
|
||||
|
||||
function serializeStringLiteral(value) {
|
||||
const jsonContents = JSON.stringify(value).slice(1, -1).replaceAll('\\"', '"');
|
||||
return `'${jsonContents.replaceAll("'", "\\'")}'`;
|
||||
}
|
||||
|
||||
function readRouteCommands(methodMap) {
|
||||
return Object.values(methodMap).flatMap(route =>
|
||||
Object.values(route).map(method => method.command)
|
||||
);
|
||||
}
|
||||
|
||||
async function readDriverCommands({ packageName, exportName }, sandbox) {
|
||||
const packageJsonPath = require.resolve(`${packageName}/package.json`, { paths: [sandbox] });
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
const modulePath = path.join(path.dirname(packageJsonPath), packageJson.exports['.'].import);
|
||||
const driverModule = await import(pathToFileURL(modulePath).href);
|
||||
const Driver = driverModule[exportName];
|
||||
return [
|
||||
...readRouteCommands(Driver.newMethodMap),
|
||||
...Object.values(Driver.executeMethodMap).map(method => method.command),
|
||||
];
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'appium-command-generator-'));
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
path.join(sandbox, 'package.json'),
|
||||
`${JSON.stringify({ name: 'appium-command-generator-sandbox', private: true })}\n`
|
||||
);
|
||||
execFileSync(
|
||||
'npm',
|
||||
[
|
||||
'install',
|
||||
'--prefix',
|
||||
sandbox,
|
||||
`appium@${APPIUM_VERSION}`,
|
||||
...DRIVERS.map(({ packageName }) => packageName),
|
||||
],
|
||||
{ stdio: 'inherit' }
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { ALL_COMMANDS } = require(require.resolve('@appium/base-driver', { paths: [sandbox] }));
|
||||
const baseDriverCommands = [...new Set(ALL_COMMANDS)].sort();
|
||||
const baseDriverCommandSet = new Set(baseDriverCommands);
|
||||
const driverCommands = await Promise.all(
|
||||
DRIVERS.map(async driver => ({
|
||||
typeName: driver.typeName,
|
||||
commands: [...new Set(await readDriverCommands(driver, sandbox))]
|
||||
.filter(command => !baseDriverCommandSet.has(command))
|
||||
.sort(),
|
||||
}))
|
||||
);
|
||||
const commands = [
|
||||
...new Set([...baseDriverCommands, ...driverCommands.flatMap(({ commands }) => commands)]),
|
||||
];
|
||||
|
||||
const outputPath = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'src',
|
||||
'steps',
|
||||
'utils',
|
||||
'appiumCommands.generated.ts'
|
||||
);
|
||||
const contents = `// AUTO-GENERATED. Do not edit by hand.
|
||||
// Run \`yarn workspace @expo/build-tools generate-appium-commands\` to refresh this
|
||||
// list from Appium, XCUITest, and UiAutomator2 (installed on demand by the generator).
|
||||
${renderCommandType('BaseDriverAppiumCommand', baseDriverCommands)}
|
||||
|
||||
${driverCommands
|
||||
.map(({ typeName, commands: commandsForDriver }) =>
|
||||
renderCommandType(typeName, commandsForDriver)
|
||||
)
|
||||
.join('\n\n')}
|
||||
|
||||
export type AppiumCommand =
|
||||
| BaseDriverAppiumCommand
|
||||
| XCUITestAppiumCommand
|
||||
| UiAutomator2AppiumCommand;
|
||||
`;
|
||||
fs.writeFileSync(outputPath, contents);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Wrote ${commands.length} Appium commands to ${outputPath}`);
|
||||
} finally {
|
||||
fs.rmSync(sandbox, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import bunyan from 'bunyan';
|
||||
import type { bunyan } from '@expo/logger';
|
||||
|
||||
export function createLogger(): bunyan {
|
||||
const logger = {
|
||||
|
||||
@@ -11,6 +11,19 @@ jest.mock('../utils/stepMetrics');
|
||||
const mockUploadStepMetricsToWwwAsync = jest.mocked(uploadStepMetricsToWwwAsync);
|
||||
|
||||
describe(CustomBuildContext, () => {
|
||||
it('copies the MCP server URL from the build context', () => {
|
||||
const ctx = new BuildContext(createTestIosJob(), {
|
||||
env: { __API_SERVER_URL: 'http://api.expo.test' },
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
uploadArtifact: jest.fn(),
|
||||
workingdir: '',
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
});
|
||||
|
||||
expect(new CustomBuildContext(ctx).mcpServerUrl).toBe('ws://localhost:8787');
|
||||
});
|
||||
|
||||
it('should not lose workflowInterpolationContext', () => {
|
||||
const contextUploadArtifact = jest.fn();
|
||||
const ctx = new BuildContext(
|
||||
|
||||
@@ -4,9 +4,11 @@ import { BuildStepGlobalContext, StepsConfigParser } from '@expo/steps';
|
||||
import { runGenericJobAsync } from '../generic';
|
||||
import { CustomBuildContext } from '../customBuildContext';
|
||||
import { uploadJobOutputsToWwwAsync } from '../utils/outputs';
|
||||
import { stopLocalEgressResourcesAsync } from '../steps/utils/localEgress';
|
||||
|
||||
jest.mock('../customBuildContext');
|
||||
jest.mock('../utils/outputs');
|
||||
jest.mock('../steps/utils/localEgress', () => ({ stopLocalEgressResourcesAsync: jest.fn() }));
|
||||
jest.mock('../common/projectSources');
|
||||
jest.mock('../steps/easFunctions', () => ({ getEasFunctions: jest.fn().mockReturnValue([]) }));
|
||||
jest.mock('../steps/easFunctionGroups', () => ({
|
||||
@@ -58,6 +60,25 @@ describe(runGenericJobAsync, () => {
|
||||
mockUploadJobOutputsToWwwAsync.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it.each(['success', 'failure'])(
|
||||
'releases local egress after workflow %s, before uploading outputs',
|
||||
async outcome => {
|
||||
const executeAsync =
|
||||
outcome === 'success'
|
||||
? jest.fn().mockResolvedValue(undefined)
|
||||
: jest.fn().mockRejectedValue(new Error(outcome));
|
||||
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
|
||||
parseAsync: jest.fn().mockResolvedValue({ executeAsync }),
|
||||
}));
|
||||
const { runResult } = await runGenericJobAsync(mockCtx);
|
||||
expect(runResult.ok).toBe(outcome === 'success');
|
||||
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledWith(mockCtx.logger);
|
||||
expect(jest.mocked(stopLocalEgressResourcesAsync).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockUploadJobOutputsToWwwAsync.mock.invocationCallOrder[0]
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('awaits drainPendingMetricUploads in COMPLETE_JOB phase', async () => {
|
||||
const mockWorkflow = { executeAsync: jest.fn().mockResolvedValue(undefined) };
|
||||
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
|
||||
|
||||
@@ -60,6 +60,7 @@ jest.mock('../../utils/expoUpdates', () => ({
|
||||
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync: jest.fn(async () => null),
|
||||
}));
|
||||
jest.mock('../../utils/expoUpdatesEmbedded', () => ({
|
||||
...jest.requireActual('../../utils/expoUpdatesEmbedded'),
|
||||
uploadEmbeddedBundleAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/hooks', () => ({
|
||||
@@ -317,36 +318,82 @@ describe(androidBuilder, () => {
|
||||
expect(runBuilderWithHooksAsync).toHaveBeenCalledWith(ctx, expect.any(Function));
|
||||
});
|
||||
|
||||
it('runs the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is set', async () => {
|
||||
const ctx = new BuildContext(createTestAndroidJob(), {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE: '1',
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
describe('embedded bundle upload phase', () => {
|
||||
function createContext({
|
||||
env = {},
|
||||
sdkVersion,
|
||||
}: {
|
||||
env?: Record<string, string>;
|
||||
sdkVersion?: string;
|
||||
}): BuildContext<Android.Job> {
|
||||
return new BuildContext(createTestAndroidJob(), {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
...env,
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
metadata: sdkVersion ? { sdkVersion } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
it('runs by default on SDK 58 and later', async () => {
|
||||
const ctx = createContext({ sdkVersion: '58.0.0' });
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
|
||||
});
|
||||
|
||||
await androidBuilder(ctx);
|
||||
it('skips by default on SDK 57 and earlier', async () => {
|
||||
const ctx = createContext({ sdkVersion: '57.0.0' });
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
|
||||
});
|
||||
await androidBuilder(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(),
|
||||
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await androidBuilder(ctx);
|
||||
it('skips by default when the SDK version is unknown', async () => {
|
||||
const ctx = createContext({});
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs on SDK 57 and earlier when EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE is 1', async () => {
|
||||
const ctx = createContext({
|
||||
sdkVersion: '57.0.0',
|
||||
env: { EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE: '1' },
|
||||
});
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
|
||||
});
|
||||
|
||||
it('skips on SDK 58 and later when EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE is 0', async () => {
|
||||
const ctx = createContext({
|
||||
sdkVersion: '58.0.0',
|
||||
env: { EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE: '0' },
|
||||
});
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs on SDK 57 and earlier when the experimental EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is 1', async () => {
|
||||
const ctx = createContext({
|
||||
sdkVersion: '57.0.0',
|
||||
env: { EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE: '1' },
|
||||
});
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -32,7 +32,10 @@ import {
|
||||
configureExpoUpdatesIfInstalledAsync,
|
||||
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
|
||||
} from '../utils/expoUpdates';
|
||||
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
|
||||
import {
|
||||
shouldUploadEmbeddedBundle,
|
||||
uploadEmbeddedBundleAsync,
|
||||
} from '../utils/expoUpdatesEmbedded';
|
||||
import { Hook, runHookIfPresent } from '../utils/hooks';
|
||||
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
|
||||
|
||||
@@ -98,7 +101,7 @@ async function buildInnerAsync(
|
||||
await restoreCcacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
platform: ctx.job.platform,
|
||||
target: { platform: ctx.job.platform },
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
@@ -223,7 +226,7 @@ async function buildInnerAsync(
|
||||
});
|
||||
});
|
||||
|
||||
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
|
||||
if (shouldUploadEmbeddedBundle(ctx)) {
|
||||
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
|
||||
await uploadEmbeddedBundleAsync(ctx);
|
||||
});
|
||||
@@ -238,7 +241,7 @@ async function buildInnerAsync(
|
||||
await saveCcacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
platform: ctx.job.platform,
|
||||
target: { platform: ctx.job.platform },
|
||||
evictUsedBefore,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Artifacts, BuildContext } from '../context';
|
||||
import { findAndUploadXcodeBuildLogsAsync } from '../ios/xcodeBuildLogs';
|
||||
import { maybeFindAndUploadBuildArtifacts } from '../utils/artifacts';
|
||||
import { Hook, runHookIfPresent } from '../utils/hooks';
|
||||
import { maybeUploadSourceMapAsync } from '../utils/sourceMaps';
|
||||
|
||||
export async function runBuilderWithHooksAsync<T extends BuildJob>(
|
||||
ctx: BuildContext<T>,
|
||||
@@ -38,6 +39,9 @@ export async function runBuilderWithHooksAsync<T extends BuildJob>(
|
||||
}
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.UPLOAD_BUILD_ARTIFACTS, async () => {
|
||||
if (buildSuccess) {
|
||||
await maybeUploadSourceMapAsync(ctx);
|
||||
}
|
||||
await maybeFindAndUploadBuildArtifacts(ctx, {
|
||||
logger: ctx.logger,
|
||||
});
|
||||
|
||||
@@ -18,17 +18,25 @@ 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 {
|
||||
cacheStatsAsync,
|
||||
restoreCcacheAsync,
|
||||
restoreCocoapodsCacheAsync,
|
||||
} from '../steps/functions/restoreBuildCache';
|
||||
import { saveCcacheAsync, saveCocoapodsCacheAsync } from '../steps/functions/saveBuildCache';
|
||||
import { uploadApplicationArchive } from '../utils/artifacts';
|
||||
import {
|
||||
configureExpoUpdatesIfInstalledAsync,
|
||||
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
|
||||
} from '../utils/expoUpdates';
|
||||
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
|
||||
import {
|
||||
shouldUploadEmbeddedBundle,
|
||||
uploadEmbeddedBundleAsync,
|
||||
} from '../utils/expoUpdatesEmbedded';
|
||||
import { Hook, runHookIfPresent } from '../utils/hooks';
|
||||
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
|
||||
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
|
||||
import { isSourceMapUploadEnabled, resolveIosSourceMapPathAsync } from '../utils/sourceMaps';
|
||||
|
||||
const INSTALL_PODS_WARN_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes
|
||||
const INSTALL_PODS_KILL_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
||||
@@ -102,7 +110,13 @@ async function buildInnerAsync(
|
||||
await restoreCcacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
platform: ctx.job.platform,
|
||||
target: { platform: ctx.job.platform, simulator: ctx.job.simulator === true },
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
await restoreCocoapodsCacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
@@ -168,20 +182,24 @@ async function buildInnerAsync(
|
||||
fastlaneResult = await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {
|
||||
const scheme = resolveScheme(ctx);
|
||||
const entitlements = await readEntitlementsAsync(ctx, { scheme, buildConfiguration });
|
||||
const sourceMapPath = isSourceMapUploadEnabled(ctx)
|
||||
? await resolveIosSourceMapPathAsync(ctx)
|
||||
: null;
|
||||
return await runFastlaneGym(ctx, {
|
||||
credentials,
|
||||
scheme,
|
||||
buildConfiguration,
|
||||
entitlements,
|
||||
...(resolvedExpoUpdatesRuntimeVersion?.runtimeVersion
|
||||
? {
|
||||
extraEnv: {
|
||||
extraEnv: {
|
||||
...(resolvedExpoUpdatesRuntimeVersion?.runtimeVersion
|
||||
? {
|
||||
EXPO_UPDATES_FINGERPRINT_OVERRIDE:
|
||||
resolvedExpoUpdatesRuntimeVersion?.runtimeVersion,
|
||||
EXPO_UPDATES_WORKFLOW_OVERRIDE: ctx.job.type,
|
||||
},
|
||||
}
|
||||
: null),
|
||||
}
|
||||
: null),
|
||||
...(sourceMapPath ? { SOURCEMAP_FILE: sourceMapPath } : null),
|
||||
},
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
@@ -225,7 +243,7 @@ async function buildInnerAsync(
|
||||
});
|
||||
});
|
||||
|
||||
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
|
||||
if (shouldUploadEmbeddedBundle(ctx)) {
|
||||
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
|
||||
await uploadEmbeddedBundleAsync(ctx);
|
||||
});
|
||||
@@ -240,11 +258,17 @@ async function buildInnerAsync(
|
||||
await saveCcacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
platform: ctx.job.platform,
|
||||
target: { platform: ctx.job.platform, simulator: ctx.job.simulator === true },
|
||||
evictUsedBefore,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
await saveCocoapodsCacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.CACHE_STATS, async () => {
|
||||
|
||||
@@ -70,6 +70,48 @@ describe('easBuildInternal', () => {
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('overrides the minimum release age only for the internal config command', async () => {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
child: jest.fn(),
|
||||
} as any;
|
||||
const ctx = {
|
||||
logger,
|
||||
env: {
|
||||
FOO: 'bar',
|
||||
NPM_CONFIG_MIN_RELEASE_AGE: '365',
|
||||
},
|
||||
job: { platform: 'ios', buildProfile: 'production' },
|
||||
} as any;
|
||||
jest.mocked(resolveEasCommandPrefixAndEnvAsync).mockResolvedValueOnce({
|
||||
cmd: 'npx',
|
||||
args: ['-y', 'eas-cli@latest'],
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
|
||||
});
|
||||
jest.mocked(spawn).mockResolvedValueOnce({
|
||||
stdout: Buffer.from(JSON.stringify({ buildProfile: { env: {} } })),
|
||||
} as any);
|
||||
|
||||
await resolveEnvFromBuildProfileAsync(ctx, { cwd: '/tmp/project' });
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'npx',
|
||||
expect.any(Array),
|
||||
expect.objectContaining({
|
||||
env: {
|
||||
FOO: 'bar',
|
||||
NPM_CONFIG_MIN_RELEASE_AGE: '0',
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(ctx.env).toEqual({
|
||||
FOO: 'bar',
|
||||
NPM_CONFIG_MIN_RELEASE_AGE: '365',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes --refresh-ad-hoc-provisioning-profile to build:internal for iOS jobs with refreshAdHocProvisioningProfile', async () => {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
|
||||
@@ -3,7 +3,10 @@ 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';
|
||||
import {
|
||||
installDependenciesAsync,
|
||||
installDependenciesWithNpmCacheFallbackAsync,
|
||||
} from '../installDependencies';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => jest.fn());
|
||||
jest.mock('../../sentry', () => ({
|
||||
@@ -236,6 +239,85 @@ describe(installDependenciesWithNpmCacheFallbackAsync, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(installDependenciesAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(spawn).mockReturnValue(createSpawnPromise(Promise.resolve(createSpawnResult())));
|
||||
});
|
||||
|
||||
it('installs only the selected workspace when EAS_PNPM_FILTER_WORKSPACE is set', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await installDependenciesAsync({
|
||||
packageManager: PackageManager.PNPM,
|
||||
env: { EAS_PNPM_FILTER_WORKSPACE: 'my-app' },
|
||||
logger,
|
||||
cwd: '/tmp/build',
|
||||
useFrozenLockfile: true,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'pnpm',
|
||||
['install', '--filter', 'my-app', '--frozen-lockfile'],
|
||||
expect.objectContaining({ cwd: '/tmp/build' })
|
||||
);
|
||||
});
|
||||
|
||||
it('installs all workspaces when EAS_PNPM_FILTER_WORKSPACE is not set', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await installDependenciesAsync({
|
||||
packageManager: PackageManager.PNPM,
|
||||
env: {},
|
||||
logger,
|
||||
cwd: '/tmp/build',
|
||||
useFrozenLockfile: true,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'pnpm',
|
||||
['install', '--frozen-lockfile'],
|
||||
expect.objectContaining({ cwd: '/tmp/build' })
|
||||
);
|
||||
});
|
||||
|
||||
it('installs only the selected workspace when EAS_BUN_FILTER_WORKSPACE is set', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await installDependenciesAsync({
|
||||
packageManager: PackageManager.BUN,
|
||||
env: { EAS_BUN_FILTER_WORKSPACE: 'my-app' },
|
||||
logger,
|
||||
cwd: '/tmp/build',
|
||||
useFrozenLockfile: true,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'bun',
|
||||
['install', '--filter', 'my-app', '--frozen-lockfile'],
|
||||
expect.objectContaining({ cwd: '/tmp/build' })
|
||||
);
|
||||
});
|
||||
|
||||
it('installs all workspaces when EAS_BUN_FILTER_WORKSPACE is not set', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await installDependenciesAsync({
|
||||
packageManager: PackageManager.BUN,
|
||||
env: {},
|
||||
logger,
|
||||
cwd: '/tmp/build',
|
||||
useFrozenLockfile: true,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'bun',
|
||||
['install', '--frozen-lockfile'],
|
||||
expect.objectContaining({ cwd: '/tmp/build' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function createSpawnPromise(result: Promise<SpawnResult>): SpawnPromise<SpawnResult> {
|
||||
return Object.assign(result, {
|
||||
child: {
|
||||
|
||||
@@ -2,10 +2,13 @@ import {
|
||||
ArchiveSourceType,
|
||||
BuildMode,
|
||||
BuildTrigger,
|
||||
ErrorCode,
|
||||
Job,
|
||||
Platform,
|
||||
SystemError,
|
||||
Workflow,
|
||||
} from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { randomBytes, randomUUID } from 'crypto';
|
||||
import { vol } from 'memfs';
|
||||
import fetch, { Response } from 'node-fetch';
|
||||
@@ -14,7 +17,7 @@ import { setTimeout } from 'timers/promises';
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import { shallowCloneRepositoryAsync } from '../git';
|
||||
import { prepareProjectSourcesAsync } from '../projectSources';
|
||||
import { prepareProjectSourcesAsync, unpackTarGzAsync } from '../projectSources';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('node-fetch');
|
||||
@@ -23,7 +26,28 @@ jest.mock('@expo/downloader');
|
||||
jest.mock('@urql/core');
|
||||
|
||||
describe('projectSources', () => {
|
||||
it('should use the refreshed repository URL', async () => {
|
||||
it('makes extracted project sources readable and writable by the worker', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await unpackTarGzAsync({
|
||||
source: '/workingdir/project.tar.gz',
|
||||
destination: '/workingdir/build',
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'tar',
|
||||
['-C', '/workingdir/build', '--strip-components', '1', '-zxf', '/workingdir/project.tar.gz'],
|
||||
{ logger }
|
||||
);
|
||||
expect(spawn).toHaveBeenNthCalledWith(2, 'chmod', ['-R', 'u+rwX', '/workingdir/build'], {
|
||||
logger,
|
||||
});
|
||||
expect(logger.info).toHaveBeenCalledWith('Normalizing project source permissions');
|
||||
});
|
||||
|
||||
it.each([true, false])('uses the refreshed URL (legacy URL: %s)', async includeRepositoryUrl => {
|
||||
const robotAccessToken = randomUUID();
|
||||
const buildId = randomUUID();
|
||||
await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true });
|
||||
@@ -39,7 +63,9 @@ describe('projectSources', () => {
|
||||
appId: randomUUID(),
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
...(includeRepositoryUrl
|
||||
? { repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git' }
|
||||
: {}),
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash,
|
||||
},
|
||||
@@ -89,102 +115,88 @@ describe('projectSources', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should fallback to the original repository URL if the refresh fails', async () => {
|
||||
const robotAccessToken = randomUUID();
|
||||
const buildId = randomUUID();
|
||||
await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true });
|
||||
it.each(['http', 'network', 'json', 'schema', 'missing-url', 'PATH', 'GCS', 'R2'])(
|
||||
'throws a system error if refresh fails (%s)',
|
||||
async failure => {
|
||||
const robotAccessToken = randomUUID();
|
||||
const buildId = randomUUID();
|
||||
await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true });
|
||||
|
||||
const ctx = new BuildContext(
|
||||
{
|
||||
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
|
||||
type: Workflow.MANAGED,
|
||||
mode: BuildMode.BUILD,
|
||||
initiatingUserId: randomUUID(),
|
||||
appId: randomUUID(),
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash: randomBytes(20).toString('hex'),
|
||||
},
|
||||
platform: Platform.IOS,
|
||||
secrets: {
|
||||
robotAccessToken,
|
||||
environmentSecrets: [],
|
||||
},
|
||||
} as Job,
|
||||
{
|
||||
env: {
|
||||
__API_SERVER_URL: 'https://api.expo.dev',
|
||||
EXPO_TOKEN: robotAccessToken,
|
||||
EAS_BUILD_ID: buildId,
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
},
|
||||
workingdir: '/workingdir',
|
||||
logger: createMockLogger(),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
uploadArtifact: jest.fn(),
|
||||
const ctx = new BuildContext(
|
||||
{
|
||||
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
|
||||
type: Workflow.MANAGED,
|
||||
mode: BuildMode.BUILD,
|
||||
initiatingUserId: randomUUID(),
|
||||
appId: randomUUID(),
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash: randomBytes(20).toString('hex'),
|
||||
},
|
||||
platform: Platform.IOS,
|
||||
secrets: {
|
||||
robotAccessToken,
|
||||
environmentSecrets: [],
|
||||
},
|
||||
} as Job,
|
||||
{
|
||||
env: {
|
||||
__API_SERVER_URL: 'https://api.expo.dev',
|
||||
EXPO_TOKEN: robotAccessToken,
|
||||
EAS_BUILD_ID: buildId,
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
},
|
||||
workingdir: '/workingdir',
|
||||
logger: createMockLogger(),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
uploadArtifact: jest.fn(),
|
||||
}
|
||||
);
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
const cause = new Error('Source request failed');
|
||||
fetchMock.mockImplementation(async () => {
|
||||
if (failure === 'network') {
|
||||
throw cause;
|
||||
}
|
||||
return {
|
||||
ok: failure !== 'http',
|
||||
status: failure === 'http' ? 500 : 200,
|
||||
json: async () => {
|
||||
if (failure === 'json') {
|
||||
throw cause;
|
||||
}
|
||||
if (['PATH', 'GCS', 'R2'].includes(failure)) {
|
||||
return { data: { type: failure, path: '/project.tar.gz', bucketKey: 'project' } };
|
||||
}
|
||||
if (failure === 'missing-url') {
|
||||
return {
|
||||
data: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash: randomBytes(20).toString('hex'),
|
||||
},
|
||||
};
|
||||
}
|
||||
return { data: { repository_url: 'https://github.com/expo/eas-cli.git' } };
|
||||
},
|
||||
} as Response;
|
||||
});
|
||||
|
||||
const result = prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
await expect(result).rejects.toBeInstanceOf(SystemError);
|
||||
await expect(result).rejects.toMatchObject({
|
||||
errorCode: ErrorCode.SERVER_ERROR,
|
||||
cause: expect.any(Error),
|
||||
});
|
||||
if (failure === 'network') {
|
||||
await expect(result).rejects.toMatchObject({ cause });
|
||||
}
|
||||
);
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
fetchMock.mockImplementation(
|
||||
async () =>
|
||||
({
|
||||
ok: false,
|
||||
text: async () => 'Failed to generate repository URL',
|
||||
}) as Response
|
||||
);
|
||||
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
expect(shallowCloneRepositoryAsync).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
archiveSource: {
|
||||
...ctx.job.projectArchive,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
},
|
||||
})
|
||||
);
|
||||
fetchMock.mockImplementation(
|
||||
async () =>
|
||||
({
|
||||
ok: false,
|
||||
json: async () => ({
|
||||
// repositoryUrl is the right key
|
||||
repository_url: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
}),
|
||||
}) as Response
|
||||
);
|
||||
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
expect(shallowCloneRepositoryAsync).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
archiveSource: {
|
||||
...ctx.job.projectArchive,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
fetchMock.mockImplementation(
|
||||
async () =>
|
||||
({
|
||||
ok: false,
|
||||
json: () => Promise.reject(new Error('Failed to generate repository URL')),
|
||||
}) as Response
|
||||
);
|
||||
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
expect(shallowCloneRepositoryAsync).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
archiveSource: {
|
||||
...ctx.job.projectArchive,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(shallowCloneRepositoryAsync).toHaveBeenCalledTimes(3);
|
||||
}, 15_000);
|
||||
expect(shallowCloneRepositoryAsync).not.toHaveBeenCalled();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
},
|
||||
15_000
|
||||
);
|
||||
|
||||
it('should retry fetching the repository URL', async () => {
|
||||
const robotAccessToken = randomUUID();
|
||||
@@ -202,7 +214,6 @@ describe('projectSources', () => {
|
||||
appId: randomUUID(),
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash,
|
||||
},
|
||||
@@ -259,7 +270,7 @@ describe('projectSources', () => {
|
||||
);
|
||||
}, 15_000);
|
||||
|
||||
it(`should fallback to the original repository URL if we're missing some config`, async () => {
|
||||
it(`throws a system error if refresh configuration is missing`, async () => {
|
||||
const robotAccessToken = randomUUID();
|
||||
await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true });
|
||||
const logger = createMockLogger();
|
||||
@@ -273,7 +284,6 @@ describe('projectSources', () => {
|
||||
appId: randomUUID(),
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash: randomBytes(20).toString('hex'),
|
||||
},
|
||||
@@ -297,12 +307,11 @@ describe('projectSources', () => {
|
||||
}
|
||||
);
|
||||
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
'Failed to refresh project archive, falling back to the original one'
|
||||
);
|
||||
await expect(prepareProjectSourcesAsync(ctx, ctx.buildDirectory)).rejects.toMatchObject({
|
||||
errorCode: ErrorCode.SERVER_ERROR,
|
||||
cause: new Error('EAS_BUILD_ID is not set'),
|
||||
});
|
||||
expect(shallowCloneRepositoryAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('uploadProjectMetadataAsFireAndForget', () => {
|
||||
@@ -382,6 +391,10 @@ describe('projectSources', () => {
|
||||
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
fetchMock.mockImplementation(async () => ({ ok: true }) as Response);
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: ctx.job.projectArchive }),
|
||||
} as Response);
|
||||
|
||||
// Call prepareProjectSourcesAsync and don't await metadata upload
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
@@ -495,6 +508,10 @@ describe('projectSources', () => {
|
||||
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
fetchMock.mockResolvedValue({ ok: true } as Response);
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: ctx.job.projectArchive }),
|
||||
} as Response);
|
||||
|
||||
const startTime = Date.now();
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
@@ -559,6 +576,10 @@ describe('projectSources', () => {
|
||||
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
fetchMock.mockResolvedValue({ ok: true } as Response);
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: ctx.job.projectArchive }),
|
||||
} as Response);
|
||||
|
||||
// Should not throw even though upload will fail
|
||||
await expect(prepareProjectSourcesAsync(ctx, ctx.buildDirectory)).resolves.not.toThrow();
|
||||
@@ -573,3 +594,56 @@ describe('projectSources', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('local project sources', () => {
|
||||
function createContext(type: ArchiveSourceType): BuildContext<Job> {
|
||||
return new BuildContext(
|
||||
{
|
||||
platform: Platform.IOS,
|
||||
projectArchive: { type, path: '/source.tar.gz' },
|
||||
} as Job,
|
||||
{
|
||||
env: { EAS_BUILD_RUNNER: 'local-build-plugin', __API_SERVER_URL: 'https://api.expo.dev' },
|
||||
workingdir: '/workingdir',
|
||||
logger: createMockLogger(),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
uploadArtifact: jest.fn(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
it('copies and unpacks a PATH source without fetching from www', async () => {
|
||||
await vol.promises.mkdir('/workingdir', { recursive: true });
|
||||
await vol.promises.writeFile('/source.tar.gz', 'local archive');
|
||||
const ctx = createContext(ArchiveSourceType.PATH);
|
||||
await expect(prepareProjectSourcesAsync(ctx, ctx.buildDirectory)).resolves.toEqual({
|
||||
handled: true,
|
||||
});
|
||||
expect(await vol.promises.readFile('/workingdir/project.tar.gz', 'utf8')).toBe('local archive');
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'tar',
|
||||
['-C', ctx.buildDirectory, '--strip-components', '1', '-zxf', '/workingdir/project.tar.gz'],
|
||||
{ logger: ctx.logger }
|
||||
);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(shallowCloneRepositoryAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(
|
||||
Object.values(ArchiveSourceType).filter(
|
||||
type => type !== ArchiveSourceType.PATH && type !== ArchiveSourceType.NONE
|
||||
)
|
||||
)('rejects local %s sources before fetching or unpacking', async type => {
|
||||
const ctx = createContext(type);
|
||||
const result = prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
await expect(result).rejects.toBeInstanceOf(SystemError);
|
||||
await expect(result).rejects.toMatchObject({
|
||||
errorCode: 'SERVER_ERROR',
|
||||
trackingCode: 'INVALID_LOCAL_PROJECT_SOURCE',
|
||||
message: `Expected a PATH project source for a local build, received ${type}.`,
|
||||
});
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(shallowCloneRepositoryAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ export async function shallowCloneRepositoryAsync({
|
||||
destinationDirectory,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
archiveSource: ArchiveSource & { type: ArchiveSourceType.GIT };
|
||||
archiveSource: ArchiveSource & { type: ArchiveSourceType.GIT; repositoryUrl: string };
|
||||
destinationDirectory: string;
|
||||
}): Promise<void> {
|
||||
const { repositoryUrl } = archiveSource;
|
||||
|
||||
@@ -32,7 +32,11 @@ export async function installDependenciesAsync({
|
||||
break;
|
||||
}
|
||||
case PackageManager.PNPM: {
|
||||
args = ['install', useFrozenLockfile ? '--frozen-lockfile' : '--no-frozen-lockfile'];
|
||||
args = [
|
||||
'install',
|
||||
...(env['EAS_PNPM_FILTER_WORKSPACE'] ? ['--filter', env['EAS_PNPM_FILTER_WORKSPACE']] : []),
|
||||
useFrozenLockfile ? '--frozen-lockfile' : '--no-frozen-lockfile',
|
||||
];
|
||||
break;
|
||||
}
|
||||
case PackageManager.YARN: {
|
||||
@@ -58,7 +62,11 @@ export async function installDependenciesAsync({
|
||||
break;
|
||||
}
|
||||
case PackageManager.BUN:
|
||||
args = ['install', ...(useFrozenLockfile ? ['--frozen-lockfile'] : [])];
|
||||
args = [
|
||||
'install',
|
||||
...(env['EAS_BUN_FILTER_WORKSPACE'] ? ['--filter', env['EAS_BUN_FILTER_WORKSPACE']] : []),
|
||||
...(useFrozenLockfile ? ['--frozen-lockfile'] : []),
|
||||
];
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported package manager: ${packageManager}`);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import downloadFile from '@expo/downloader';
|
||||
import { ArchiveSource, ArchiveSourceSchemaZ, ArchiveSourceType, Job } from '@expo/eas-build-job';
|
||||
import { ArchiveSourceType, Job, SystemError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
@@ -19,33 +19,30 @@ export async function prepareProjectSourcesAsync<TJob extends Job>(
|
||||
destinationDirectory: string
|
||||
): // Return type required to make switch exhaustive.
|
||||
Promise<{ handled: boolean }> {
|
||||
let projectArchive: ArchiveSource = ctx.job.projectArchive;
|
||||
if (ctx.isLocal) {
|
||||
console.warn('Local build, skipping project archive refresh');
|
||||
} else {
|
||||
const projectArchiveResult = await asyncResult(fetchProjectArchiveSourceAsync(ctx));
|
||||
|
||||
if (!projectArchiveResult.ok) {
|
||||
ctx.logger.error(
|
||||
{ err: projectArchiveResult.reason },
|
||||
'Failed to refresh project archive, falling back to the original one'
|
||||
);
|
||||
}
|
||||
|
||||
projectArchive = projectArchiveResult.value ?? ctx.job.projectArchive;
|
||||
if (ctx.job.projectArchive.type === ArchiveSourceType.NONE) {
|
||||
return { handled: true };
|
||||
}
|
||||
|
||||
if (ctx.isLocal) {
|
||||
if (ctx.job.projectArchive.type !== ArchiveSourceType.PATH) {
|
||||
throw new SystemError(
|
||||
`Expected a PATH project source for a local build, received ${ctx.job.projectArchive.type}.`,
|
||||
{ trackingCode: 'INVALID_LOCAL_PROJECT_SOURCE' }
|
||||
);
|
||||
}
|
||||
await prepareProjectSourcesLocallyAsync(ctx, ctx.job.projectArchive.path, destinationDirectory);
|
||||
return { handled: true };
|
||||
}
|
||||
|
||||
const projectArchiveResult = await asyncResult(fetchProjectArchiveSourceAsync(ctx));
|
||||
if (!projectArchiveResult.ok) {
|
||||
throw new SystemError('Failed to fetch project sources. Re-run the job.', {
|
||||
cause: projectArchiveResult.reason,
|
||||
});
|
||||
}
|
||||
const projectArchive = projectArchiveResult.value;
|
||||
|
||||
switch (projectArchive.type) {
|
||||
case ArchiveSourceType.R2:
|
||||
case ArchiveSourceType.GCS: {
|
||||
throw new Error('Remote project sources should be resolved earlier to URL');
|
||||
}
|
||||
|
||||
case ArchiveSourceType.PATH: {
|
||||
await prepareProjectSourcesLocallyAsync(ctx, projectArchive.path, destinationDirectory); // used in eas build --local
|
||||
return { handled: true };
|
||||
}
|
||||
|
||||
case ArchiveSourceType.NONE: {
|
||||
// May be used in no-sources jobs like submission jobs.
|
||||
return { handled: true };
|
||||
@@ -110,7 +107,7 @@ async function prepareProjectSourcesLocallyAsync<TJob extends Job>(
|
||||
});
|
||||
}
|
||||
|
||||
async function unpackTarGzAsync({
|
||||
export async function unpackTarGzAsync({
|
||||
logger,
|
||||
source,
|
||||
destination,
|
||||
@@ -122,6 +119,8 @@ async function unpackTarGzAsync({
|
||||
await spawn('tar', ['-C', destination, '--strip-components', '1', '-zxf', source], {
|
||||
logger,
|
||||
});
|
||||
logger.info('Normalizing project source permissions');
|
||||
await spawn('chmod', ['-R', 'u+rwX', destination], { logger });
|
||||
}
|
||||
|
||||
function uploadProjectMetadataAsFireAndForget(
|
||||
@@ -247,7 +246,7 @@ async function uploadProjectMetadataAsync(
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProjectArchiveSourceAsync(ctx: BuildContext<Job>): Promise<ArchiveSource> {
|
||||
async function fetchProjectArchiveSourceAsync(ctx: BuildContext<Job>) {
|
||||
const taskId = nullthrows(ctx.env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
const expoApiServerURL = nullthrows(ctx.env.__API_SERVER_URL, '__API_SERVER_URL is not set');
|
||||
const robotAccessToken = nullthrows(
|
||||
@@ -285,7 +284,20 @@ async function fetchProjectArchiveSourceAsync(ctx: BuildContext<Job>): Promise<A
|
||||
);
|
||||
}
|
||||
|
||||
const dataResult = z.object({ data: ArchiveSourceSchemaZ }).safeParse(jsonResult.value);
|
||||
const dataResult = z
|
||||
.object({
|
||||
data: z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal(ArchiveSourceType.GIT),
|
||||
repositoryUrl: z.string().url(),
|
||||
gitRef: z.string().nullable(),
|
||||
gitCommitHash: z.string(),
|
||||
}),
|
||||
z.object({ type: z.literal(ArchiveSourceType.URL), url: z.string().url() }),
|
||||
z.object({ type: z.literal(ArchiveSourceType.NONE) }),
|
||||
]),
|
||||
})
|
||||
.safeParse(jsonResult.value);
|
||||
if (!dataResult.success) {
|
||||
throw new Error(
|
||||
`Unexpected data from server (${response.status}): ${z.prettifyError(dataResult.error)}`
|
||||
|
||||
@@ -68,6 +68,7 @@ export interface BuildContextOptions {
|
||||
skipNativeBuild?: boolean;
|
||||
metadata?: Metadata;
|
||||
expoApiV2BaseUrl?: string;
|
||||
mcpServerUrl?: string;
|
||||
}
|
||||
|
||||
export class SkipNativeBuildError extends Error {}
|
||||
@@ -84,6 +85,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
) => void;
|
||||
public readonly skipNativeBuild?: boolean;
|
||||
public readonly expoApiV2BaseUrl?: string;
|
||||
public readonly mcpServerUrl?: string;
|
||||
public artifacts: Artifacts = {};
|
||||
|
||||
private readonly _isLocal: boolean;
|
||||
@@ -112,6 +114,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
this._metadata = options.metadata;
|
||||
this.skipNativeBuild = options.skipNativeBuild;
|
||||
this.expoApiV2BaseUrl = options.expoApiV2BaseUrl;
|
||||
this.mcpServerUrl = options.mcpServerUrl;
|
||||
|
||||
const environmentSecrets = this.getEnvironmentSecrets(job);
|
||||
this._env = {
|
||||
|
||||
@@ -63,6 +63,7 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
|
||||
public readonly logger: bunyan;
|
||||
public readonly graphqlClient: Client;
|
||||
public readonly runtimeApi: BuilderRuntimeApi;
|
||||
public readonly mcpServerUrl?: string;
|
||||
public job: TJob;
|
||||
public metadata?: Metadata;
|
||||
|
||||
@@ -84,6 +85,7 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
|
||||
this.runtimeApi = {
|
||||
uploadArtifact: (...args) => buildCtx['uploadArtifact'](...args),
|
||||
};
|
||||
this.mcpServerUrl = buildCtx.mcpServerUrl;
|
||||
this.expoApiV2BaseUrl = buildCtx.expoApiV2BaseUrl;
|
||||
this.startTime = new Date();
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { BuildContext } from './context';
|
||||
import { CustomBuildContext } from './customBuildContext';
|
||||
import { getEasFunctionGroups } from './steps/easFunctionGroups';
|
||||
import { getEasFunctions } from './steps/easFunctions';
|
||||
import { stopLocalEgressResourcesAsync } from './steps/utils/localEgress';
|
||||
import { uploadJobOutputsToWwwAsync } from './utils/outputs';
|
||||
import { retryAsync } from './utils/retry';
|
||||
|
||||
@@ -82,7 +83,17 @@ export async function runGenericJobAsync(
|
||||
}
|
||||
});
|
||||
|
||||
const runResult = await asyncResult(workflow.executeAsync());
|
||||
const runResult = await asyncResult(
|
||||
(async () => {
|
||||
try {
|
||||
await workflow.executeAsync();
|
||||
} finally {
|
||||
// The session-owning step may never run if simulator boot/setup fails,
|
||||
// or may time out while resource acquisition is still in flight.
|
||||
await stopLocalEgressResourcesAsync(ctx.logger);
|
||||
}
|
||||
})()
|
||||
);
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.COMPLETE_JOB, async () => {
|
||||
const results = await Promise.allSettled([
|
||||
|
||||
@@ -5,6 +5,8 @@ import RemoteLoggerStream from './logging/RemoteLoggerStream';
|
||||
export { Builders, HttpLogStream, RemoteLoggerStream };
|
||||
export { uploadWithSignedUrl } from './storage/uploadWithSignedUrl';
|
||||
export type { SignedUrl, UploadWithSignedUrlParams } from './storage/uploadWithSignedUrl';
|
||||
export { downloadCacheAsync } from './steps/functions/restoreCache';
|
||||
export { uploadCacheAsync } from './steps/functions/saveCache';
|
||||
|
||||
export {
|
||||
ArtifactToUpload,
|
||||
@@ -18,6 +20,9 @@ export {
|
||||
|
||||
export { PackageManager } from './utils/packageManager';
|
||||
|
||||
export * as TurtleSshSession from './utils/turtleSshSession';
|
||||
export { formatSecondsForLog } from './utils/formatDuration';
|
||||
|
||||
export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs';
|
||||
|
||||
export { Hook, runHookIfPresent } from './utils/hooks';
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { getXcodeVersionAsync } from '../xcode';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
|
||||
describe(getXcodeVersionAsync, () => {
|
||||
it('returns the Xcode version', async () => {
|
||||
mockedSpawn.mockResolvedValue({
|
||||
stdout: 'Xcode 26.0\nBuild version 17A324\n',
|
||||
} as any);
|
||||
|
||||
await expect(getXcodeVersionAsync({ env: process.env })).resolves.toBe('26.0');
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('xcodebuild', ['-version'], {
|
||||
stdio: 'pipe',
|
||||
env: process.env,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when the Xcode version cannot be parsed', async () => {
|
||||
mockedSpawn.mockResolvedValue({ stdout: 'unexpected output' } as any);
|
||||
|
||||
await expect(getXcodeVersionAsync({ env: process.env })).rejects.toThrow(
|
||||
'Failed to determine Xcode version'
|
||||
);
|
||||
});
|
||||
|
||||
it('wraps errors from xcodebuild in a SystemError', async () => {
|
||||
const cause = new Error('xcode-select: error: invalid developer directory');
|
||||
mockedSpawn.mockRejectedValue(cause);
|
||||
|
||||
const promise = getXcodeVersionAsync({ env: process.env });
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
message: 'Failed to get Xcode version',
|
||||
cause,
|
||||
});
|
||||
});
|
||||
});
|
||||
+17
-24
@@ -1,11 +1,9 @@
|
||||
import { Ios } from '@expo/eas-build-job';
|
||||
import { createLogger } from '@expo/logger';
|
||||
import fs from 'fs-extra';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { BuildContext } from '../../../context';
|
||||
import { distributionCertificate } from '../__tests__/fixtures';
|
||||
import Keychain from '../keychain';
|
||||
|
||||
@@ -17,11 +15,9 @@ jest.setTimeout(60 * 1000);
|
||||
// We need the JS code to modify the file system, so we need to mock it.
|
||||
jest.unmock('fs');
|
||||
|
||||
let ctx: BuildContext<Ios.Job>;
|
||||
|
||||
describe('Keychain class', () => {
|
||||
describe('ensureCertificateImported method', () => {
|
||||
let keychain: Keychain<Ios.Job>;
|
||||
let keychain: Keychain;
|
||||
const certificatePath = path.join(os.tmpdir(), `cert-${uuid()}.p12`);
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -36,37 +32,34 @@ describe('Keychain class', () => {
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = new BuildContext({ projectRootDirectory: '.' } as Ios.Job, {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: mockLogger,
|
||||
env: {},
|
||||
uploadArtifact: jest.fn(),
|
||||
});
|
||||
keychain = new Keychain(ctx);
|
||||
await keychain.create();
|
||||
keychain = new Keychain();
|
||||
await keychain.create({ logger: mockLogger });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await keychain.destroy();
|
||||
await keychain.destroy({ logger: mockLogger });
|
||||
});
|
||||
|
||||
it("should throw an error if the certificate hasn't been imported", async () => {
|
||||
await expect(
|
||||
keychain.ensureCertificateImported(
|
||||
distributionCertificate.teamId,
|
||||
distributionCertificate.fingerprint
|
||||
)
|
||||
keychain.ensureCertificateImported({
|
||||
teamId: distributionCertificate.teamId,
|
||||
fingerprint: distributionCertificate.fingerprint,
|
||||
})
|
||||
).rejects.toThrowError(/hasn't been imported successfully/);
|
||||
});
|
||||
|
||||
it("shouldn't throw any error if the certificate has been imported successfully", async () => {
|
||||
await keychain.importCertificate(certificatePath, distributionCertificate.password);
|
||||
await keychain.importCertificate({
|
||||
logger: mockLogger,
|
||||
certPath: certificatePath,
|
||||
certPassword: distributionCertificate.password,
|
||||
});
|
||||
await expect(
|
||||
keychain.ensureCertificateImported(
|
||||
distributionCertificate.teamId,
|
||||
distributionCertificate.fingerprint
|
||||
)
|
||||
keychain.ensureCertificateImported({
|
||||
teamId: distributionCertificate.teamId,
|
||||
fingerprint: distributionCertificate.fingerprint,
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { errors } from '@expo/eas-build-job';
|
||||
import { createLogger } from '@expo/logger';
|
||||
import { SpawnResult } from '@expo/turtle-spawn';
|
||||
|
||||
import { runFastlane } from '../../fastlane';
|
||||
import Keychain from '../keychain';
|
||||
|
||||
jest.mock('../../fastlane');
|
||||
const runFastlaneMock = jest.mocked(runFastlane);
|
||||
const logger = createLogger({ name: 'test' });
|
||||
const logError = jest.spyOn(logger, 'error').mockImplementation();
|
||||
let keychain: Keychain;
|
||||
const options = {
|
||||
logger,
|
||||
certPath: '/test.p12',
|
||||
certPassword: 'certificate-password',
|
||||
};
|
||||
const result = (stdout: string, stderr = ''): SpawnResult => ({
|
||||
stdout,
|
||||
stderr,
|
||||
output: [stdout, stderr],
|
||||
pid: 1,
|
||||
status: 0,
|
||||
signal: null,
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
keychain = new Keychain();
|
||||
runFastlaneMock.mockResolvedValueOnce(result(''));
|
||||
await keychain.create({ logger });
|
||||
runFastlaneMock.mockClear();
|
||||
});
|
||||
|
||||
it('runs the import with the certificate and keychain credentials, without streaming output', async () => {
|
||||
runFastlaneMock.mockResolvedValueOnce(result('Result: true'));
|
||||
await keychain.importCertificate(options);
|
||||
expect(runFastlaneMock).toHaveBeenCalledWith([
|
||||
'run',
|
||||
'import_certificate',
|
||||
'certificate_path:/test.p12',
|
||||
'certificate_password:certificate-password',
|
||||
`keychain_path:${keychain.data.path}`,
|
||||
`keychain_password:${keychain.data.password}`,
|
||||
]);
|
||||
expect(logError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe.each(['success', 'failure'])('Fastlane %s', outcome => {
|
||||
it.each([
|
||||
{
|
||||
output: 'SecKeychainItemImport: MAC verification failed during PKCS12 import',
|
||||
codes: ['PKCS12_MAC_VERIFICATION_FAILED'],
|
||||
},
|
||||
{
|
||||
output: 'SecKeychainItemImport: Unknown format in import',
|
||||
codes: ['PKCS12_UNKNOWN_FORMAT'],
|
||||
},
|
||||
{
|
||||
output: 'SecKeychainItemImport: Some other error',
|
||||
codes: ['CERTIFICATE_IMPORT_FAILED'],
|
||||
},
|
||||
{
|
||||
output:
|
||||
'SecKeychainItemImport: Unknown format in import\nSecKeychainItemSetAccessWithPassword: Access denied',
|
||||
codes: ['PKCS12_UNKNOWN_FORMAT', 'PRIVATE_KEY_ACCESS_FAILED'],
|
||||
},
|
||||
{
|
||||
output:
|
||||
'seckeychainitemimport: unknown format in import\nSecKeychainItemImport: Some other error',
|
||||
codes: ['PKCS12_UNKNOWN_FORMAT', 'CERTIFICATE_IMPORT_FAILED'],
|
||||
},
|
||||
])('reports only the applicable codes: $codes', async ({ output, codes }) => {
|
||||
if (outcome === 'success') {
|
||||
runFastlaneMock.mockResolvedValueOnce(result(output));
|
||||
await keychain.importCertificate(options);
|
||||
} else {
|
||||
runFastlaneMock.mockRejectedValueOnce(
|
||||
Object.assign(new Error('import failed'), {
|
||||
stderr: output,
|
||||
})
|
||||
);
|
||||
const failure = await keychain.importCertificate(options).catch(error => error);
|
||||
expect(failure.metadata.diagnosticCodes).toEqual(codes);
|
||||
}
|
||||
expect(logError.mock.calls.map(([fields]) => fields.diagnosticCode)).toEqual(codes);
|
||||
});
|
||||
});
|
||||
|
||||
it('never forwards output, command arguments, private key data, or the original error', async () => {
|
||||
const secret = 'very-secret-password';
|
||||
const error = Object.assign(new Error(`fastlane certificate_password:${secret}`), {
|
||||
stdout: `private key data ${secret}`,
|
||||
stderr: `security: SecKeychainItemSetAccessWithPassword: ${secret}`,
|
||||
});
|
||||
runFastlaneMock.mockRejectedValueOnce(error);
|
||||
const failure = await keychain.importCertificate(options).catch(error => error);
|
||||
expect(failure).toBeInstanceOf(errors.UserError);
|
||||
expect(failure.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
|
||||
expect(failure.metadata).toEqual({ diagnosticCodes: ['PRIVATE_KEY_ACCESS_FAILED'] });
|
||||
expect(failure.toExternalExpoError().message).toContain(
|
||||
'Fastlane could not complete certificate import'
|
||||
);
|
||||
expect(failure.cause).toBeUndefined();
|
||||
expect(JSON.stringify([failure, logError.mock.calls])).not.toContain(secret);
|
||||
expect(logError).toHaveBeenCalledWith(
|
||||
{ diagnosticCode: 'PRIVATE_KEY_ACCESS_FAILED' },
|
||||
expect.stringContaining('private key')
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['ENOENT', 'EACCES'])(
|
||||
'classifies a %s process launch failure as a system error',
|
||||
async code => {
|
||||
runFastlaneMock.mockRejectedValueOnce(Object.assign(new Error('secret command'), { code }));
|
||||
const error = await keychain.importCertificate(options).catch(error => error);
|
||||
expect(error).toBeInstanceOf(errors.SystemError);
|
||||
expect(error.trackingCode).toBe('IOS_CERTIFICATE_IMPORT_PROCESS_START_FAILED');
|
||||
expect(error.cause).toBeUndefined();
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
null,
|
||||
new Error('failure'),
|
||||
{ stderr: 'SecKeychainItemImport: Unknown format in import' },
|
||||
Object.assign(new Error('failure'), { stdout: 123, stderr: {} }),
|
||||
])('handles rejected values without usable process output: %p', async value => {
|
||||
runFastlaneMock.mockRejectedValueOnce(value);
|
||||
await expect(keychain.importCertificate(options)).rejects.toThrow(
|
||||
'Fastlane could not complete certificate import'
|
||||
);
|
||||
expect(logError).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -18,7 +18,7 @@ jest.unmock('fs');
|
||||
describe('ProvisioningProfile class', () => {
|
||||
describe('verifyCertificate method', () => {
|
||||
let ctx: BuildContext<Ios.Job>;
|
||||
let keychain: Keychain<Ios.Job>;
|
||||
let keychain: Keychain;
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = new BuildContext({ projectRootDirectory: '.' } as Ios.Job, {
|
||||
@@ -30,12 +30,12 @@ describe('ProvisioningProfile class', () => {
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
});
|
||||
keychain = new Keychain(ctx);
|
||||
await keychain.create();
|
||||
keychain = new Keychain();
|
||||
await keychain.create({ logger: mockLogger });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await keychain.destroy();
|
||||
await keychain.destroy({ logger: mockLogger });
|
||||
});
|
||||
|
||||
it("shouldn't throw any error if the provisioning profile and distribution certificate match", async () => {
|
||||
|
||||
@@ -1,19 +1,53 @@
|
||||
import { Ios } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { errors } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import spawn, { SpawnResult } from '@expo/turtle-spawn';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { BuildContext } from '../../context';
|
||||
import { runFastlane } from '../fastlane';
|
||||
|
||||
export default class Keychain<TJob extends Ios.Job> {
|
||||
// Do not log raw fastlane output or spawn errors: they can contain passwords,
|
||||
// command arguments, and private key attributes from set-key-partition-list.
|
||||
const IMPORT_CERTIFICATE_DIAGNOSTICS = [
|
||||
{
|
||||
code: 'PKCS12_MAC_VERIFICATION_FAILED',
|
||||
pattern: /SecKeychainItemImport: MAC verification failed during PKCS12 import/i,
|
||||
message:
|
||||
'macOS could not verify the PKCS#12 MAC. Check the certificate password and PKCS#12 export format; this error does not prove that the password is wrong.',
|
||||
},
|
||||
{
|
||||
code: 'PKCS12_UNKNOWN_FORMAT',
|
||||
pattern: /SecKeychainItemImport: Unknown format in import/i,
|
||||
message: 'macOS did not recognize the certificate import format.',
|
||||
},
|
||||
{
|
||||
code: 'CERTIFICATE_IMPORT_FAILED',
|
||||
pattern:
|
||||
/SecKeychainItemImport:(?! MAC verification failed during PKCS12 import| Unknown format in import)/i,
|
||||
message: 'macOS reported a certificate import error (SecKeychainItemImport).',
|
||||
},
|
||||
{
|
||||
code: 'PRIVATE_KEY_ACCESS_FAILED',
|
||||
pattern: /SecKeychainItemSetAccessWithPassword:/,
|
||||
message:
|
||||
'macOS could not set access to the imported private key (SecKeychainItemSetAccessWithPassword).',
|
||||
},
|
||||
{
|
||||
code: 'KEYCHAIN_ITEM_LOOKUP_FAILED',
|
||||
pattern: /SecItemCopyMatching:/,
|
||||
message:
|
||||
'macOS could not find an item while configuring private key access (SecItemCopyMatching).',
|
||||
},
|
||||
];
|
||||
|
||||
export default class Keychain {
|
||||
private readonly keychainPath: string;
|
||||
private readonly keychainPassword: string;
|
||||
private created = false;
|
||||
private destroyed = false;
|
||||
|
||||
constructor(private readonly ctx: BuildContext<TJob>) {
|
||||
constructor() {
|
||||
this.keychainPath = path.join(os.tmpdir(), `eas-build-${uuid()}.keychain`);
|
||||
this.keychainPassword = uuid();
|
||||
}
|
||||
@@ -25,8 +59,8 @@ export default class Keychain<TJob extends Ios.Job> {
|
||||
};
|
||||
}
|
||||
|
||||
public async create(): Promise<void> {
|
||||
this.ctx.logger.debug(`Creating keychain - ${this.keychainPath}`);
|
||||
public async create({ logger }: { logger: bunyan }): Promise<void> {
|
||||
logger.debug(`Creating keychain - ${this.keychainPath}`);
|
||||
await runFastlane([
|
||||
'run',
|
||||
'create_keychain',
|
||||
@@ -38,23 +72,77 @@ export default class Keychain<TJob extends Ios.Job> {
|
||||
this.created = true;
|
||||
}
|
||||
|
||||
public async importCertificate(certPath: string, certPassword: string): Promise<void> {
|
||||
public async importCertificate({
|
||||
logger,
|
||||
certPath,
|
||||
certPassword,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
certPath: string;
|
||||
certPassword: string;
|
||||
}): Promise<void> {
|
||||
if (!this.created) {
|
||||
throw new Error('You must create a keychain first.');
|
||||
}
|
||||
|
||||
this.ctx.logger.debug(`Importing certificate ${certPath} into keychain ${this.keychainPath}`);
|
||||
await runFastlane([
|
||||
'run',
|
||||
'import_certificate',
|
||||
`certificate_path:${certPath}`,
|
||||
`certificate_password:${certPassword}`,
|
||||
`keychain_path:${this.keychainPath}`,
|
||||
`keychain_password:${this.keychainPassword}`,
|
||||
]);
|
||||
logger.debug(`Importing certificate ${certPath} into keychain ${this.keychainPath}`);
|
||||
try {
|
||||
const result = await runFastlane([
|
||||
'run',
|
||||
'import_certificate',
|
||||
`certificate_path:${certPath}`,
|
||||
`certificate_password:${certPassword}`,
|
||||
`keychain_path:${this.keychainPath}`,
|
||||
`keychain_password:${this.keychainPassword}`,
|
||||
]);
|
||||
const output = [result.stdout, result.stderr].join('\n');
|
||||
for (const diagnostic of IMPORT_CERTIFICATE_DIAGNOSTICS) {
|
||||
if (diagnostic.pattern.test(output)) {
|
||||
logger.error({ diagnosticCode: diagnostic.code }, diagnostic.message);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const processError =
|
||||
error instanceof Error
|
||||
? (error as Error &
|
||||
Partial<Pick<SpawnResult, 'stdout' | 'stderr'> & NodeJS.ErrnoException>)
|
||||
: undefined;
|
||||
const output = [processError?.stdout, processError?.stderr]
|
||||
.filter(value => typeof value === 'string')
|
||||
.join('\n');
|
||||
const diagnosticCodes: string[] = [];
|
||||
for (const diagnostic of IMPORT_CERTIFICATE_DIAGNOSTICS) {
|
||||
if (diagnostic.pattern.test(output)) {
|
||||
diagnosticCodes.push(diagnostic.code);
|
||||
logger.error({ diagnosticCode: diagnostic.code }, diagnostic.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Never attach the original error: its message includes passwords.
|
||||
|
||||
if (processError?.code === 'ENOENT' || processError?.code === 'EACCES') {
|
||||
throw new errors.SystemError('Fastlane could not be started to import the certificate.', {
|
||||
trackingCode: 'IOS_CERTIFICATE_IMPORT_PROCESS_START_FAILED',
|
||||
});
|
||||
}
|
||||
throw new errors.UserError(
|
||||
errors.ErrorCode.UNKNOWN_ERROR,
|
||||
'Fastlane could not complete certificate import. Check the certificate import diagnostics in the Prepare credentials logs.',
|
||||
{
|
||||
trackingCode: 'IOS_CERTIFICATE_IMPORT_FAILED',
|
||||
metadata: { diagnosticCodes },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async ensureCertificateImported(teamId: string, fingerprint: string): Promise<void> {
|
||||
public async ensureCertificateImported({
|
||||
teamId,
|
||||
fingerprint,
|
||||
}: {
|
||||
teamId: string;
|
||||
fingerprint: string;
|
||||
}): Promise<void> {
|
||||
const identities = await this.findIdentitiesByTeamId(teamId);
|
||||
if (!identities.includes(fingerprint)) {
|
||||
throw new Error(
|
||||
@@ -63,27 +151,33 @@ export default class Keychain<TJob extends Ios.Job> {
|
||||
}
|
||||
}
|
||||
|
||||
public async destroy(keychainPath?: string): Promise<void> {
|
||||
public async destroy({
|
||||
logger,
|
||||
keychainPath,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
keychainPath?: string;
|
||||
}): Promise<void> {
|
||||
if (!keychainPath && !this.created) {
|
||||
this.ctx.logger.warn("There is nothing to destroy, a keychain hasn't been created yet.");
|
||||
logger.warn("There is nothing to destroy, a keychain hasn't been created yet.");
|
||||
return;
|
||||
}
|
||||
if (this.destroyed) {
|
||||
this.ctx.logger.warn('The keychain has been already destroyed');
|
||||
logger.warn('The keychain has been already destroyed');
|
||||
return;
|
||||
}
|
||||
const keychainToDeletePath = keychainPath ?? this.keychainPath;
|
||||
this.ctx.logger.info(`Destroying keychain - ${keychainToDeletePath}`);
|
||||
logger.info(`Destroying keychain - ${keychainToDeletePath}`);
|
||||
try {
|
||||
await runFastlane(['run', 'delete_keychain', `keychain_path:${keychainToDeletePath}`]);
|
||||
this.destroyed = true;
|
||||
} catch (err) {
|
||||
this.ctx.logger.error({ err }, 'Failed to delete the keychain\n');
|
||||
logger.error({ err }, 'Failed to delete the keychain\n');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
public async cleanUpKeychains(): Promise<void> {
|
||||
public async cleanUpKeychains({ logger }: { logger: bunyan }): Promise<void> {
|
||||
const { stdout } = await spawn('security', ['list-keychains'], { stdio: 'pipe' });
|
||||
const keychainList = (/"(.*)"/g.exec(stdout) ?? ([] as string[])).map(i =>
|
||||
i.slice(1, i.length - 1)
|
||||
@@ -92,7 +186,7 @@ export default class Keychain<TJob extends Ios.Job> {
|
||||
/eas-build-[\w-]+\.keychain$/.exec(keychain)
|
||||
);
|
||||
for (const turtleKeychainPath of turtleKeychainList) {
|
||||
await this.destroy(turtleKeychainPath);
|
||||
await this.destroy({ logger, keychainPath: turtleKeychainPath });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface Credentials {
|
||||
export type TargetProvisioningProfiles = Record<string, ProvisioningProfileData>;
|
||||
|
||||
export default class IosCredentialsManager<TJob extends Ios.Job> {
|
||||
private keychain?: Keychain<TJob>;
|
||||
private keychain?: Keychain;
|
||||
private readonly provisioningProfiles: ProvisioningProfile<TJob>[] = [];
|
||||
private cleanedUp = false;
|
||||
|
||||
@@ -48,8 +48,8 @@ export default class IosCredentialsManager<TJob extends Ios.Job> {
|
||||
this.ctx.logger.info('Preparing credentials');
|
||||
|
||||
this.ctx.logger.info('Creating keychain');
|
||||
this.keychain = new Keychain(this.ctx);
|
||||
await this.keychain.create();
|
||||
this.keychain = new Keychain();
|
||||
await this.keychain.create({ logger: this.ctx.logger });
|
||||
|
||||
const targets = Object.keys(buildCredentials);
|
||||
const targetProvisioningProfiles: TargetProvisioningProfiles = {};
|
||||
@@ -82,7 +82,7 @@ export default class IosCredentialsManager<TJob extends Ios.Job> {
|
||||
}
|
||||
|
||||
if (this.keychain) {
|
||||
await this.keychain.destroy();
|
||||
await this.keychain.destroy({ logger: this.ctx.logger });
|
||||
}
|
||||
if (this.provisioningProfiles) {
|
||||
for (const provisioningProfile of this.provisioningProfiles) {
|
||||
@@ -120,10 +120,11 @@ export default class IosCredentialsManager<TJob extends Ios.Job> {
|
||||
);
|
||||
|
||||
this.ctx.logger.info('Importing distribution certificate into the keychain');
|
||||
await this.keychain.importCertificate(
|
||||
distCertPath,
|
||||
targetCredentials.distributionCertificate.password
|
||||
);
|
||||
await this.keychain.importCertificate({
|
||||
logger: this.ctx.logger,
|
||||
certPath: distCertPath,
|
||||
certPassword: targetCredentials.distributionCertificate.password,
|
||||
});
|
||||
|
||||
this.ctx.logger.info('Initializing provisioning profile');
|
||||
const provisioningProfile = new ProvisioningProfile(
|
||||
@@ -138,10 +139,10 @@ export default class IosCredentialsManager<TJob extends Ios.Job> {
|
||||
this.ctx.logger.info(
|
||||
'Validating whether the distribution certificate has been imported successfully'
|
||||
);
|
||||
await this.keychain.ensureCertificateImported(
|
||||
provisioningProfile.data.teamId,
|
||||
certificateFingerprint
|
||||
);
|
||||
await this.keychain.ensureCertificateImported({
|
||||
teamId: provisioningProfile.data.teamId,
|
||||
fingerprint: certificateFingerprint,
|
||||
});
|
||||
|
||||
this.ctx.logger.info(
|
||||
'Verifying whether the distribution certificate and provisioning profile match'
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
export async function getXcodeVersionAsync({ env }: { env: NodeJS.ProcessEnv }): Promise<string> {
|
||||
let stdout: string;
|
||||
try {
|
||||
({ stdout } = await spawn('xcodebuild', ['-version'], { stdio: 'pipe', env }));
|
||||
} catch (error) {
|
||||
throw new SystemError('Failed to get Xcode version', { cause: error });
|
||||
}
|
||||
const version = /^Xcode\s+(\d+(?:\.\d+)*)$/m.exec(stdout)?.[1];
|
||||
if (!version) {
|
||||
throw new SystemError(`Failed to determine Xcode version from: ${stdout.trim()}`);
|
||||
}
|
||||
return version;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { Writable } from 'stream';
|
||||
import { retryAsync } from '../utils/retry';
|
||||
|
||||
const MAX_BATCH_BYTES = 200_000;
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
interface BufferedLogEntry {
|
||||
enqueuedAt: number;
|
||||
@@ -161,6 +162,7 @@ export default class HttpLogStream extends Writable {
|
||||
'Content-Type': 'application/x-ndjson',
|
||||
},
|
||||
body: logs.map(log => log.serialized).join('\n'),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -186,4 +186,21 @@ describe(HttpLogStream.name, () => {
|
||||
const [firstAttemptLogs, secondAttemptLogs] = parseRequestLogs();
|
||||
expect(secondAttemptLogs).toEqual(firstAttemptLogs);
|
||||
});
|
||||
|
||||
it('sets a ten-second timeout on HTTP uploads', async () => {
|
||||
fetchMock.mockResolvedValue(createResponse());
|
||||
const abortSignalTimeoutSpy = jest.spyOn(AbortSignal, 'timeout');
|
||||
const stream = new HttpLogStream({
|
||||
url: 'https://logs.expo.test/build-id',
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
logger: createLogger({ name: 'test' }),
|
||||
});
|
||||
|
||||
stream.write({ logId: 'stable-id', msg: 'Do not hang' });
|
||||
await stream.cleanUp();
|
||||
|
||||
expect(abortSignalTimeoutSpy).toHaveBeenCalledWith(10_000);
|
||||
expect(fetchMock.mock.calls[0][1]?.signal).toBeInstanceOf(AbortSignal);
|
||||
abortSignalTimeoutSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -19,8 +19,11 @@ import { generateGymfileFromTemplateFunction } from './functions/generateGymfile
|
||||
import { createGetCredentialsForBuildTriggeredByGithubIntegration } from './functions/getCredentialsForBuildTriggeredByGitHubIntegration';
|
||||
import { injectAndroidCredentialsFunction } from './functions/injectAndroidCredentials';
|
||||
import { createInstallMaestroBuildFunction } from './functions/installMaestro';
|
||||
import { createInstallMitmproxyBuildFunction } from './functions/installMitmproxy';
|
||||
import { createInstallBuildFunction } from './functions/installBuild';
|
||||
import { createInstallNodeModulesBuildFunction } from './functions/installNodeModules';
|
||||
import { createInstallPodsBuildFunction } from './functions/installPods';
|
||||
import { createLaunchApplicationFunction } from './functions/launchApplication';
|
||||
import { createPrebuildBuildFunction } from './functions/prebuild';
|
||||
import { createReadAppConfigBuildFunction } from './functions/readAppConfig';
|
||||
import { createReadIpaInfoBuildFunction } from './functions/readIpaInfo';
|
||||
@@ -43,17 +46,24 @@ import { createRolloutPosthogFlagFunction } from './functions/rolloutPosthogFlag
|
||||
import { createSaveCacheFunction } from './functions/saveCache';
|
||||
import { createSendSlackMessageFunction } from './functions/sendSlackMessage';
|
||||
import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/startAgentDeviceRemoteSession';
|
||||
import { createStartAgentDeviceSessionBuildFunction } from './functions/startAgentDeviceSession';
|
||||
import { createStartAndroidEmulatorBuildFunction } from './functions/startAndroidEmulator';
|
||||
import { createStartArgentRemoteSessionBuildFunction } from './functions/startArgentRemoteSession';
|
||||
import { createStartAppiumRemoteSessionBuildFunction } from './functions/startAppiumRemoteSession';
|
||||
import { createStartCuttlefishDeviceBuildFunction } from './functions/startCuttlefishDevice';
|
||||
import { createStartIosSimulatorBuildFunction } from './functions/startIosSimulator';
|
||||
import { createStartIosSimulatorRecordingsBuildFunction } from './functions/startIosSimulatorRecordings';
|
||||
import { createStartServeSimRemoteSessionBuildFunction } from './functions/startServeSimRemoteSession';
|
||||
import { createStartLocalEgressBuildFunction } from './functions/startLocalEgress';
|
||||
import { createStartWebPreviewRemoteSessionBuildFunction } from './functions/startWebPreviewRemoteSession';
|
||||
import { createStartServeSimMetricsBuildFunction } from './functions/startServeSimMetrics';
|
||||
import { createStartServeSimLogsBuildFunction } from './functions/startServeSimLogs';
|
||||
import { createCollectServeSimLogsBuildFunction } from './functions/collectServeSimLogs';
|
||||
import { createStartSandboxBuildFunction } from './functions/startSandbox';
|
||||
import { createCollectServeSimMetricsBuildFunction } from './functions/collectServeSimMetrics';
|
||||
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
|
||||
import { createUploadDeviceRunSessionScreenRecordingsBuildFunction } from './functions/uploadDeviceRunSessionScreenRecordings';
|
||||
import { createUploadToAscBuildFunction } from './functions/uploadToAsc';
|
||||
import { createUpdateTestFlightMetadataBuildFunction } from './functions/updateTestFlightMetadata';
|
||||
import { createSetUpNpmrcBuildFunction } from './functions/useNpmToken';
|
||||
import { createWaitForPosthogMetricFunction } from './functions/waitForPosthogMetric';
|
||||
import { createUploadPosthogSourcemapsFunction } from './functions/uploadPosthogSourcemaps';
|
||||
@@ -73,6 +83,8 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createPrebuildBuildFunction(),
|
||||
createReadIpaInfoBuildFunction(),
|
||||
createDownloadBuildFunction(ctx),
|
||||
createInstallBuildFunction(),
|
||||
createLaunchApplicationFunction(),
|
||||
createEasExportBuildFunction(),
|
||||
createEasDeployBuildFunction(),
|
||||
createRepackBuildFunction(),
|
||||
@@ -93,17 +105,24 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
runFastlaneFunction(),
|
||||
parseXcactivitylogFunction(),
|
||||
createStartAgentDeviceRemoteSessionBuildFunction(ctx),
|
||||
createStartAgentDeviceSessionBuildFunction(ctx),
|
||||
createStartArgentRemoteSessionBuildFunction(ctx),
|
||||
createStartAppiumRemoteSessionBuildFunction(ctx),
|
||||
createStartAndroidEmulatorBuildFunction(),
|
||||
createStartCuttlefishDeviceBuildFunction(),
|
||||
createStartIosSimulatorBuildFunction(),
|
||||
createStartLocalEgressBuildFunction(),
|
||||
createStartIosSimulatorRecordingsBuildFunction(),
|
||||
createFinishIosSimulatorRecordingsBuildFunction(),
|
||||
createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx),
|
||||
createStartServeSimRemoteSessionBuildFunction(ctx),
|
||||
createStartWebPreviewRemoteSessionBuildFunction(ctx),
|
||||
createStartServeSimMetricsBuildFunction(),
|
||||
createStartServeSimLogsBuildFunction(),
|
||||
createCollectServeSimLogsBuildFunction(ctx),
|
||||
createStartSandboxBuildFunction(ctx),
|
||||
createCollectServeSimMetricsBuildFunction(ctx),
|
||||
createInstallMaestroBuildFunction(),
|
||||
createInstallMitmproxyBuildFunction(),
|
||||
|
||||
createInstallPodsBuildFunction(),
|
||||
createSendSlackMessageFunction(),
|
||||
@@ -118,6 +137,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
|
||||
createSubmissionEntityFunction(),
|
||||
createUploadToAscBuildFunction(),
|
||||
createUpdateTestFlightMetadataBuildFunction(),
|
||||
|
||||
createReportMaestroTestResultsFunction(ctx),
|
||||
createMaestroTestsBuildFunction(ctx),
|
||||
|
||||
@@ -162,6 +162,28 @@ describe(createEasBuildBuildFunctionGroup, () => {
|
||||
expect(installPodsStep!.ctx.relativeWorkingDirectory).toBe('./ios');
|
||||
});
|
||||
|
||||
it('restores and saves caches around an iOS simulator build', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.IOS,
|
||||
simulator: true,
|
||||
});
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx);
|
||||
const stepNames = steps.map(step => step.displayName);
|
||||
const restoreCacheIndex = stepNames.indexOf('Restore Cache');
|
||||
const installPodsIndex = stepNames.indexOf('Install Pods');
|
||||
const saveCacheIndex = stepNames.indexOf('Save Cache');
|
||||
|
||||
expect(restoreCacheIndex).toBeGreaterThan(-1);
|
||||
expect(restoreCacheIndex).toBeLessThan(installPodsIndex);
|
||||
expect(saveCacheIndex).toBeGreaterThan(installPodsIndex);
|
||||
|
||||
const restoreCacheStep = steps[restoreCacheIndex];
|
||||
expect(restoreCacheStep.inputs?.find(input => input.id === 'simulator')?.rawValue).toBe(true);
|
||||
});
|
||||
|
||||
it('sets working directory on all steps except checkout (Android with credentials)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.ANDROID,
|
||||
|
||||
@@ -98,6 +98,8 @@ function createStepsForIosSimulatorBuild({
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
}: HelperFunctionsInput): BuildStep[] {
|
||||
const evictUsedBefore = new Date();
|
||||
|
||||
const calculateEASUpdateRuntimeVersion =
|
||||
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'calculate_eas_update_runtime_version',
|
||||
@@ -106,6 +108,16 @@ function createStepsForIosSimulatorBuild({
|
||||
const installPods = createInstallPodsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory: workingDirectory ? path.join(workingDirectory, './ios') : './ios',
|
||||
});
|
||||
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.IOS,
|
||||
simulator: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
const configureEASUpdate =
|
||||
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
@@ -123,6 +135,16 @@ function createStepsForIosSimulatorBuild({
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
},
|
||||
});
|
||||
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.IOS,
|
||||
simulator: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
return [
|
||||
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
@@ -138,6 +160,7 @@ function createStepsForIosSimulatorBuild({
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
restoreCache,
|
||||
calculateEASUpdateRuntimeVersion,
|
||||
installPods,
|
||||
configureEASUpdate,
|
||||
@@ -159,6 +182,10 @@ function createStepsForIosSimulatorBuild({
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+12
-1
@@ -1,6 +1,6 @@
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { createInstallMaestroBuildFunction } from '../installMaestro';
|
||||
import { createInstallMaestroBuildFunction, installIdbFromBrew } from '../installMaestro';
|
||||
|
||||
jest.unmock('fs');
|
||||
jest.unmock('@expo/logger');
|
||||
@@ -57,3 +57,14 @@ describe('createInstallMaestroBuildFunction', () => {
|
||||
console.log(latestStep.outputs[0].value);
|
||||
}, 180_000);
|
||||
});
|
||||
|
||||
describe('installIdbFromBrew', () => {
|
||||
it('installs 1.1.8 version', async () => {
|
||||
const logger = createMockLogger({ logToConsole: true });
|
||||
|
||||
await installIdbFromBrew({
|
||||
logger,
|
||||
env: process.env,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,6 @@ const projectTargetDirectory = '/workingdir/project';
|
||||
|
||||
const GIT_PROJECT_ARCHIVE = {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/app.git',
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash: '0123456789abcdef0123456789abcdef01234567',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { spawnAsync } from '@expo/steps';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { Datadog } from '../../../datadog';
|
||||
import {
|
||||
compressCocoapodsCacheAsync,
|
||||
getCocoapodsCachePaths,
|
||||
resolveCocoapodsCacheKeyAsync,
|
||||
restoreCocoapodsCacheArchiveAsync,
|
||||
} from '../../../utils/cocoapodsCache';
|
||||
import { downloadCacheAsync } from '../restoreCache';
|
||||
import { restoreCocoapodsCacheAsync } from '../restoreBuildCache';
|
||||
import { uploadCacheAsync } from '../saveCache';
|
||||
import { saveCocoapodsCacheAsync } from '../saveBuildCache';
|
||||
|
||||
jest.mock('@expo/steps', () => ({
|
||||
...jest.requireActual('@expo/steps'),
|
||||
spawnAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../restoreCache', () => ({
|
||||
decompressCacheAsync: jest.fn(),
|
||||
downloadCacheAsync: jest.fn(),
|
||||
downloadPublicCacheAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../saveCache', () => ({
|
||||
compressCacheAsync: jest.fn(),
|
||||
uploadCacheAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../utils/cocoapodsCache', () => ({
|
||||
compressCocoapodsCacheAsync: jest.fn(),
|
||||
getCocoapodsCachePaths: jest.fn(),
|
||||
resolveCocoapodsCacheKeyAsync: jest.fn(),
|
||||
restoreCocoapodsCacheArchiveAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const logger = createMockLogger();
|
||||
const env = {
|
||||
EAS_PODS_CACHE: '1',
|
||||
EAS_BUILD_ID: 'build-id',
|
||||
__API_SERVER_URL: 'https://api.expo.test',
|
||||
};
|
||||
const secrets = { robotAccessToken: 'robot-token' };
|
||||
|
||||
describe('CocoaPods build cache', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
vol.fromJSON(
|
||||
{
|
||||
'/workingdir/ios/Pods/Manifest.lock': 'manifest',
|
||||
'/workingdir/ios/Podfile.lock': 'lockfile',
|
||||
'/tmp/cocoapods-cache.tar.gz': 'archive',
|
||||
},
|
||||
'/'
|
||||
);
|
||||
jest.mocked(getCocoapodsCachePaths).mockReturnValue({
|
||||
iosDirectory: '/workingdir/ios',
|
||||
podsDirectory: '/workingdir/ios/Pods',
|
||||
podfileLockPath: '/workingdir/ios/Podfile.lock',
|
||||
});
|
||||
jest.mocked(resolveCocoapodsCacheKeyAsync).mockResolvedValue({
|
||||
key: 'ios-pods-1.16.2-lock-hash',
|
||||
keyPrefix: 'ios-pods-1.16.2-',
|
||||
});
|
||||
jest.mocked(spawnAsync).mockResolvedValue({ stdout: '1.16.2\n' } as any);
|
||||
});
|
||||
|
||||
it('does nothing when the cache is disabled', async () => {
|
||||
await restoreCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory: '/workingdir',
|
||||
env: {},
|
||||
secrets,
|
||||
});
|
||||
await saveCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory: '/workingdir',
|
||||
env: {},
|
||||
secrets,
|
||||
});
|
||||
|
||||
expect(spawnAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores the newest matching CocoaPods cache', async () => {
|
||||
jest.mocked(downloadCacheAsync).mockResolvedValue({
|
||||
archivePath: '/tmp/cocoapods-cache.tar.gz',
|
||||
matchedKey: 'ios-pods-1.16.2-older-lock-hash',
|
||||
});
|
||||
const datadogLogSpy = jest.spyOn(Datadog, 'log');
|
||||
|
||||
await restoreCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory: '/workingdir',
|
||||
env,
|
||||
secrets,
|
||||
});
|
||||
|
||||
expect(downloadCacheAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: 'ios-pods-1.16.2-lock-hash',
|
||||
keyPrefixes: ['ios-pods-1.16.2-'],
|
||||
paths: ['/workingdir/ios/Pods'],
|
||||
})
|
||||
);
|
||||
expect(restoreCocoapodsCacheArchiveAsync).toHaveBeenCalledWith({
|
||||
archivePath: '/tmp/cocoapods-cache.tar.gz',
|
||||
workingDirectory: '/workingdir',
|
||||
});
|
||||
expect(datadogLogSpy).toHaveBeenCalledWith('CocoaPods cache restored (prefix_match)', {
|
||||
event: 'cocoapods_cache_restored',
|
||||
cache_hit_type: 'prefix_match',
|
||||
});
|
||||
});
|
||||
|
||||
it('compresses and saves the installed Pods directory', async () => {
|
||||
jest.mocked(compressCocoapodsCacheAsync).mockResolvedValue({
|
||||
archivePath: '/tmp/cocoapods-cache.tar.gz',
|
||||
});
|
||||
|
||||
await saveCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory: '/workingdir',
|
||||
env,
|
||||
secrets,
|
||||
});
|
||||
|
||||
expect(uploadCacheAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
archivePath: '/tmp/cocoapods-cache.tar.gz',
|
||||
key: 'ios-pods-1.16.2-lock-hash',
|
||||
paths: ['/workingdir/ios/Pods'],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import type { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../../utils/agentDeviceEvents';
|
||||
import { startAppiumEventCollectionAsync } from '../../utils/appiumEvents';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import {
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
waitForFileAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartAgentDeviceRemoteSessionBuildFunction } from '../startAgentDeviceRemoteSession';
|
||||
import { createStartAppiumRemoteSessionBuildFunction } from '../startAppiumRemoteSession';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../../../utils/AndroidEmulatorUtils');
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/agentDeviceEvents');
|
||||
jest.mock('../../utils/appiumEvents');
|
||||
jest.mock('../../utils/agentDeviceArtifacts');
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
spawnDetached: jest.fn(),
|
||||
startNgrokTunnelAsync: jest.fn(),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
waitForFileAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const finishHost = jest.fn();
|
||||
const closePreview = jest.fn();
|
||||
const stopTool = jest.fn();
|
||||
const stopTunnel = jest.fn();
|
||||
const stopEvents = jest.fn();
|
||||
const openPreview = jest.fn();
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// The daemon package is external. No installation or daemon process runs in these tests.
|
||||
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: '{}' } as never);
|
||||
jest
|
||||
.mocked(AndroidEmulatorUtils.getAttachedDevicesAsync)
|
||||
.mockResolvedValue([{ serialId: 'emulator-5554', state: 'device' } as never]);
|
||||
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as never);
|
||||
jest.mocked(waitForFileAsync).mockResolvedValue({ port: 4567, token: 'daemon-token' });
|
||||
for (const stop of [finishHost, closePreview, stopTool, stopTunnel, stopEvents]) {
|
||||
stop.mockResolvedValue(undefined);
|
||||
}
|
||||
openPreview.mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://preview.example.test',
|
||||
closeAsync: closePreview,
|
||||
});
|
||||
jest
|
||||
.mocked(startDeviceSessionHostAsync)
|
||||
.mockResolvedValue({ openPreviewAsync: openPreview, finishAsync: finishHost });
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: undefined,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: stopTool,
|
||||
});
|
||||
jest.mocked(startNgrokTunnelAsync).mockResolvedValue({
|
||||
url: 'https://tool.example.test',
|
||||
subdomainId: 'tool-id',
|
||||
stopAsync: stopTunnel,
|
||||
});
|
||||
const events = { stopAsync: stopEvents, getLastEventObservedAt: () => undefined };
|
||||
jest.mocked(startAgentDeviceEventCollectionAsync).mockResolvedValue(events);
|
||||
jest.mocked(startAppiumEventCollectionAsync).mockResolvedValue(events);
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe.each([
|
||||
['Appium', createStartAppiumRemoteSessionBuildFunction],
|
||||
['Agent Device', createStartAgentDeviceRemoteSessionBuildFunction],
|
||||
] as const)('%s host ownership', (name, createFunction) => {
|
||||
async function runAsync() {
|
||||
const fn = createFunction({} as CustomBuildContext);
|
||||
await fn.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {
|
||||
DEVICE_RUN_SESSION_ID: 'session-id',
|
||||
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'example.test',
|
||||
NGROK_AUTHTOKEN: 'token',
|
||||
},
|
||||
} as never
|
||||
);
|
||||
}
|
||||
|
||||
it('holds the host until session completion', async () => {
|
||||
await runAsync();
|
||||
expect(openPreview).toHaveBeenCalledWith({ baseDomain: 'example.test' });
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: expect.objectContaining({
|
||||
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
previewApiUrl: 'https://preview.example.test',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
expect(closePreview).not.toHaveBeenCalled();
|
||||
expect(finishHost.mock.invocationCallOrder[0]).toBeGreaterThan(
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(stopTool).toHaveBeenCalledTimes(1);
|
||||
expect(stopEvents).toHaveBeenCalledTimes(1);
|
||||
expect(stopTunnel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops automation while recording finalization or upload is pending', async () => {
|
||||
let release!: () => void;
|
||||
let stopped!: () => void;
|
||||
const pendingFinish = new Promise<void>(resolve => {
|
||||
release = resolve;
|
||||
});
|
||||
const toolStopped = new Promise<void>(resolve => {
|
||||
stopped = resolve;
|
||||
});
|
||||
finishHost.mockReturnValueOnce(pendingFinish);
|
||||
stopTool.mockImplementationOnce(async () => {
|
||||
stopped();
|
||||
});
|
||||
let completed = false;
|
||||
const running = runAsync().then(() => {
|
||||
completed = true;
|
||||
});
|
||||
try {
|
||||
await toolStopped;
|
||||
expect(stopTunnel).toHaveBeenCalledTimes(1);
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
expect(completed).toBe(false);
|
||||
} finally {
|
||||
release();
|
||||
await running;
|
||||
}
|
||||
});
|
||||
|
||||
it('finalizes recording and stops automation while the tunnel close is pending', async () => {
|
||||
let release!: () => void;
|
||||
let finished!: () => void;
|
||||
const pendingClose = new Promise<void>(resolve => {
|
||||
release = resolve;
|
||||
});
|
||||
const hostFinished = new Promise<void>(resolve => {
|
||||
finished = resolve;
|
||||
});
|
||||
const toolStopped = new Promise<void>(resolve => {
|
||||
stopTool.mockImplementationOnce(async () => resolve());
|
||||
});
|
||||
stopTunnel.mockReturnValueOnce(pendingClose);
|
||||
finishHost.mockImplementationOnce(async () => {
|
||||
finished();
|
||||
});
|
||||
const running = runAsync();
|
||||
try {
|
||||
// Both settle while the tunnel close is still held open.
|
||||
await Promise.all([hostFinished, toolStopped]);
|
||||
expect(stopTunnel).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
release();
|
||||
await running;
|
||||
}
|
||||
});
|
||||
|
||||
it('still stops the automation process when event collection stop rejects', async () => {
|
||||
stopEvents.mockRejectedValueOnce(new Error('collector failed'));
|
||||
// Agent Device wraps its collector stop in a catch-all helper; Appium does not.
|
||||
const outcome = await runAsync().then(
|
||||
() => 'resolved',
|
||||
(err: Error) => err.message
|
||||
);
|
||||
expect(outcome).toBe(name === 'Appium' ? 'collector failed' : 'resolved');
|
||||
expect(stopTool).toHaveBeenCalledTimes(1);
|
||||
expect(stopTunnel).toHaveBeenCalledTimes(1);
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('still stops automation when recording finalization rejects', async () => {
|
||||
finishHost.mockRejectedValueOnce(new Error('recording cleanup failed'));
|
||||
await expect(runAsync()).rejects.toThrow('recording cleanup failed');
|
||||
expect(stopTool).toHaveBeenCalledTimes(1);
|
||||
expect(stopTunnel).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['preview', 'config', 'wait'])('finishes the host after %s fails', async phase => {
|
||||
const error = new Error(`${phase} failed`);
|
||||
// A teardown failure must not replace the error that ended the session.
|
||||
finishHost.mockRejectedValueOnce(new Error('recording cleanup failed'));
|
||||
if (phase === 'preview') {
|
||||
openPreview.mockRejectedValueOnce(error);
|
||||
} else if (phase === 'config') {
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValueOnce(error);
|
||||
} else {
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValueOnce(error);
|
||||
}
|
||||
await expect(runAsync()).rejects.toBe(error);
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
// An agent-device preview failure aborts the daemon task, which then may not start its
|
||||
// daemon or tunnel. Everything that started must be stopped.
|
||||
const abortsDaemonTask = name === 'Agent Device' && phase === 'preview';
|
||||
expect(stopTool).toHaveBeenCalledTimes(
|
||||
abortsDaemonTask ? jest.mocked(spawnDetached).mock.calls.length : 1
|
||||
);
|
||||
expect(stopTunnel).toHaveBeenCalledTimes(
|
||||
abortsDaemonTask ? jest.mocked(startNgrokTunnelAsync).mock.calls.length : 1
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.objectContaining({ message: 'recording cleanup failed' }) },
|
||||
'Could not stop the session host during remote session teardown.'
|
||||
);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Could not stop the session host after the remote session failed',
|
||||
expect.objectContaining({ message: 'recording cleanup failed' }),
|
||||
{ level: 'warning' }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,11 +4,14 @@ import { Client, CombinedError } from '@urql/core';
|
||||
import fetch, { Response } from 'node-fetch';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import * as tar from 'tar';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import * as files from '../../../utils/files';
|
||||
import { createDownloadBuildFunction, downloadBuildAsync } from '../downloadBuild';
|
||||
|
||||
// contains a 'TestApp.app/TestApp' file with 'i am executable' content
|
||||
@@ -19,6 +22,9 @@ const APP_TAR_GZ_BUFFER = Buffer.from(
|
||||
'base64'
|
||||
);
|
||||
|
||||
const FLAT_APP_INFO_PLIST_BASE64 =
|
||||
'YnBsaXN0MDDSAQIDBF8QEkNGQnVuZGxlRXhlY3V0YWJsZV8QE0NGQnVuZGxlUGFja2FnZVR5cGVXVGVzdEFwcFRBUFBMCA0iOEAAAAAAAAABAQAAAAAAAAAFAAAAAAAAAAAAAAAAAAAARQ==';
|
||||
|
||||
const APPLICATION_ARCHIVE_URL = `https://expo.dev/artifacts/eas/${randomUUID()}.tar.gz`;
|
||||
|
||||
function createMockGraphqlClient({
|
||||
@@ -51,6 +57,57 @@ function createMockGraphqlClient({
|
||||
} as unknown as Client;
|
||||
}
|
||||
|
||||
function createSuccessfulResponse({
|
||||
body,
|
||||
url,
|
||||
headers,
|
||||
}: {
|
||||
body: Buffer;
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}): Response {
|
||||
const normalizedHeaders = Object.fromEntries(
|
||||
Object.entries(headers ?? {}).map(([name, value]) => [name.toLowerCase(), value])
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
body: Readable.from(body),
|
||||
url,
|
||||
headers: {
|
||||
get: (name: string): string | null => normalizedHeaders[name.toLowerCase()] ?? null,
|
||||
},
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
async function createFlatAppTarGzBufferAsync(): Promise<Buffer> {
|
||||
const sourceDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'flat-app-source-'));
|
||||
const archivePath = path.join(os.tmpdir(), `${randomUUID()}.tar.gz`);
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
fs.promises.writeFile(path.join(sourceDirectory, 'Info.plist'), FLAT_APP_INFO_PLIST_BASE64, {
|
||||
encoding: 'base64',
|
||||
}),
|
||||
fs.promises.writeFile(path.join(sourceDirectory, 'TestApp'), 'i am executable\n'),
|
||||
]);
|
||||
await tar.create(
|
||||
{
|
||||
cwd: sourceDirectory,
|
||||
file: archivePath,
|
||||
gzip: true,
|
||||
},
|
||||
['Info.plist', 'TestApp']
|
||||
);
|
||||
return await fs.promises.readFile(archivePath);
|
||||
} finally {
|
||||
await Promise.all([
|
||||
fs.promises.rm(sourceDirectory, { recursive: true, force: true }),
|
||||
fs.promises.rm(archivePath, { force: true }),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
describe('downloadBuild', () => {
|
||||
it('downloads from applicationArchiveUrl returned by GraphQL', async () => {
|
||||
const buildId = randomUUID();
|
||||
@@ -58,11 +115,12 @@ describe('downloadBuild', () => {
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
body: Readable.from(APP_TAR_GZ_BUFFER),
|
||||
url: APPLICATION_ARCHIVE_URL,
|
||||
} as unknown as Response);
|
||||
jest.mocked(fetch).mockResolvedValue(
|
||||
createSuccessfulResponse({
|
||||
body: APP_TAR_GZ_BUFFER,
|
||||
url: APPLICATION_ARCHIVE_URL,
|
||||
})
|
||||
);
|
||||
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
@@ -82,17 +140,39 @@ describe('downloadBuild', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('downloads an archive containing app bundle contents at its root', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(
|
||||
createSuccessfulResponse({
|
||||
body: await createFlatAppTarGzBufferAsync(),
|
||||
url: APPLICATION_ARCHIVE_URL,
|
||||
})
|
||||
);
|
||||
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
graphqlClient: createMockGraphqlClient({}),
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
});
|
||||
|
||||
expect(path.extname(artifactPath)).toBe('.app');
|
||||
expect(await fs.promises.readFile(path.join(artifactPath, 'TestApp'), 'utf8')).toBe(
|
||||
'i am executable\n'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle a straight-up file', async () => {
|
||||
const applicationArchiveUrl = `https://expo.dev/artifacts/eas/${randomUUID()}.apk`;
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl,
|
||||
});
|
||||
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
body: Readable.from(Buffer.from('hello')),
|
||||
url: applicationArchiveUrl,
|
||||
} as unknown as Response);
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValue(
|
||||
createSuccessfulResponse({ body: Buffer.from('hello'), url: applicationArchiveUrl })
|
||||
);
|
||||
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
@@ -106,6 +186,266 @@ describe('downloadBuild', () => {
|
||||
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
|
||||
});
|
||||
|
||||
it('downloads a direct application archive URL without querying GraphQL or forwarding the EAS token', async () => {
|
||||
const applicationArchiveUrl = `https://artifacts.example.test/${randomUUID()}.apk`;
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValue(
|
||||
createSuccessfulResponse({ body: Buffer.from('hello'), url: applicationArchiveUrl })
|
||||
);
|
||||
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl,
|
||||
graphqlClient,
|
||||
robotAccessToken: 'scoped-eas-token',
|
||||
extensions: ['apk'],
|
||||
});
|
||||
|
||||
expect(graphqlClient.query).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
applicationArchiveUrl,
|
||||
expect.objectContaining({ headers: undefined })
|
||||
);
|
||||
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
|
||||
});
|
||||
|
||||
it('uses the Content-Disposition filename for an extensionless URL', async () => {
|
||||
const applicationArchiveUrl = 'https://artifacts.example.test/download';
|
||||
jest.mocked(fetch).mockResolvedValue(
|
||||
createSuccessfulResponse({
|
||||
body: Buffer.from('hello'),
|
||||
url: applicationArchiveUrl,
|
||||
headers: { 'content-disposition': 'attachment; filename="app.apk"' },
|
||||
})
|
||||
);
|
||||
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl,
|
||||
graphqlClient: createMockGraphqlClient({}),
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
});
|
||||
|
||||
expect(path.basename(artifactPath)).toBe('app.apk');
|
||||
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
|
||||
});
|
||||
|
||||
it('uses the expected extension when the URL has no filename', async () => {
|
||||
const applicationArchiveUrl = 'https://artifacts.example.test/';
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValue(
|
||||
createSuccessfulResponse({ body: Buffer.from('hello'), url: applicationArchiveUrl })
|
||||
);
|
||||
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl,
|
||||
graphqlClient: createMockGraphqlClient({}),
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
});
|
||||
|
||||
expect(path.basename(artifactPath)).toBe('application.apk');
|
||||
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
|
||||
});
|
||||
|
||||
it('passes the abort signal to the build lookup', async () => {
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValue(
|
||||
createSuccessfulResponse({ body: APP_TAR_GZ_BUFFER, url: APPLICATION_ARCHIVE_URL })
|
||||
);
|
||||
const buildId = randomUUID();
|
||||
|
||||
await downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(graphqlClient.query).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ buildId },
|
||||
{ fetch: expect.any(Function) }
|
||||
);
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
APPLICATION_ARCHIVE_URL,
|
||||
expect.objectContaining({ signal: expect.any(Object) })
|
||||
);
|
||||
});
|
||||
|
||||
it('stops a pending build lookup when aborted', async () => {
|
||||
// The lookup request runs through the abort-aware fetch from graphqlAbortContext and
|
||||
// hangs until its signal fires, like a stalled API call.
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = jest.fn(
|
||||
(_input: unknown, init?: RequestInit) =>
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
init!.signal!.addEventListener('abort', () => reject(init!.signal!.reason));
|
||||
})
|
||||
) as typeof globalThis.fetch;
|
||||
const graphqlClient = {
|
||||
query: jest.fn((_query, _variables, context: { fetch: typeof globalThis.fetch }) => ({
|
||||
toPromise: () =>
|
||||
context.fetch('https://api.expo.dev/graphql', {}).then(
|
||||
() => ({ data: undefined }),
|
||||
(networkError: Error) => ({ error: new CombinedError({ networkError }) })
|
||||
),
|
||||
})),
|
||||
} as unknown as Client;
|
||||
const controller = new AbortController();
|
||||
const failure = new Error('daemon failed');
|
||||
|
||||
try {
|
||||
const download = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId: randomUUID(),
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
signal: controller.signal,
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
controller.abort(failure);
|
||||
|
||||
await expect(download).rejects.toBe(failure);
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('lets a started extraction finish before it reports an abort', async () => {
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValue(
|
||||
createSuccessfulResponse({ body: APP_TAR_GZ_BUFFER, url: APPLICATION_ARCHIVE_URL })
|
||||
);
|
||||
const { decompressTarAsync } = jest.requireActual<typeof files>('../../../utils/files');
|
||||
const controller = new AbortController();
|
||||
const failure = new Error('daemon failed');
|
||||
let extractionDirectory: string | undefined;
|
||||
let extractionFinished = false;
|
||||
const decompressSpy = jest.spyOn(files, 'decompressTarAsync').mockImplementation(async args => {
|
||||
extractionDirectory = args.destinationDirectory;
|
||||
// The startup fails while the archive is being extracted.
|
||||
controller.abort(failure);
|
||||
await decompressTarAsync(args);
|
||||
extractionFinished = true;
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
graphqlClient: createMockGraphqlClient({}),
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
signal: controller.signal,
|
||||
})
|
||||
).rejects.toBe(failure);
|
||||
|
||||
// The download settled only after the extraction wrote its last file.
|
||||
expect(extractionFinished).toBe(true);
|
||||
expect(
|
||||
await fs.promises.readFile(
|
||||
path.join(extractionDirectory!, 'TestApp.app', 'TestApp'),
|
||||
'utf8'
|
||||
)
|
||||
).toBe('i am executable\n');
|
||||
} finally {
|
||||
decompressSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not look up the build when already aborted', async () => {
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
const failure = new Error('boot failed');
|
||||
|
||||
await expect(
|
||||
downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId: randomUUID(),
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
signal: AbortSignal.abort(failure),
|
||||
})
|
||||
).rejects.toBe(failure);
|
||||
expect(graphqlClient.query).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects missing or ambiguous build sources', async () => {
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
|
||||
await expect(
|
||||
// @ts-expect-error Verify the runtime guard for callers without TypeScript.
|
||||
downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
errorCode: 'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
|
||||
message: 'Pass buildId or applicationArchiveUrl.',
|
||||
});
|
||||
|
||||
await expect(
|
||||
// @ts-expect-error Verify the runtime guard for callers without TypeScript.
|
||||
downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId: randomUUID(),
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
errorCode: 'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
|
||||
message: 'Pass only one of buildId or applicationArchiveUrl.',
|
||||
});
|
||||
|
||||
expect(graphqlClient.query).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a non-HTTP application archive URL', async () => {
|
||||
await expect(
|
||||
downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl: 'file:///tmp/app.apk',
|
||||
graphqlClient: createMockGraphqlClient({}),
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
errorCode: 'EAS_DOWNLOAD_BUILD_INVALID_APPLICATION_ARCHIVE_URL',
|
||||
message: 'application_archive_url must be a valid HTTP or HTTPS URL.',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws UserError when the build has no application archive url', async () => {
|
||||
const graphqlClient = createMockGraphqlClient({ applicationArchiveUrl: null });
|
||||
|
||||
@@ -203,11 +543,12 @@ describe('downloadBuild', () => {
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
body: Readable.from(APP_TAR_GZ_BUFFER),
|
||||
url: APPLICATION_ARCHIVE_URL,
|
||||
} as unknown as Response);
|
||||
jest.mocked(fetch).mockResolvedValue(
|
||||
createSuccessfulResponse({
|
||||
body: APP_TAR_GZ_BUFFER,
|
||||
url: APPLICATION_ARCHIVE_URL,
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
downloadBuildAsync({
|
||||
@@ -222,6 +563,34 @@ describe('downloadBuild', () => {
|
||||
});
|
||||
|
||||
describe('createDownloadBuildFunction', () => {
|
||||
it('aborts a pending archive request when the step times out', async () => {
|
||||
let requestAborted = false;
|
||||
jest.mocked(fetch).mockImplementation(
|
||||
async (_url, options) =>
|
||||
await new Promise<Response>((_resolve, reject) => {
|
||||
options?.signal?.addEventListener('abort', () => {
|
||||
requestAborted = true;
|
||||
reject(new Error('Archive request aborted'));
|
||||
});
|
||||
})
|
||||
);
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
const step = createDownloadBuildFunction({
|
||||
graphqlClient,
|
||||
} as any).createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({ staticContextContent: { job: {} } }),
|
||||
{
|
||||
callInputs: { application_archive_url: APPLICATION_ARCHIVE_URL, extensions: ['ipa'] },
|
||||
timeoutMs: 100,
|
||||
}
|
||||
);
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow('timed out');
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(requestAborted).toBe(true);
|
||||
expect(graphqlClient.query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should download a build', async () => {
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { createInstallBuildFunction, installBuildAsync } from '../installBuild';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
async function makeTemporaryDirectoryAsync(): Promise<string> {
|
||||
const temporaryDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'install-build-test-')
|
||||
);
|
||||
temporaryDirectories.push(temporaryDirectory);
|
||||
return temporaryDirectory;
|
||||
}
|
||||
|
||||
describe(installBuildAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.map(temporaryDirectory =>
|
||||
fs.promises.rm(temporaryDirectory, { recursive: true, force: true })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('installs an iOS Simulator .app', async () => {
|
||||
const temporaryDirectory = await makeTemporaryDirectoryAsync();
|
||||
const artifactPath = path.join(temporaryDirectory, 'Example.app');
|
||||
await fs.promises.mkdir(artifactPath);
|
||||
const logger = createMockLogger();
|
||||
mockedSpawn.mockResolvedValueOnce({ stdout: 'com.example.app\n', stderr: '' } as any);
|
||||
|
||||
const result = await installBuildAsync({
|
||||
artifactPath,
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ applicationIdentifier: 'com.example.app' });
|
||||
expect(mockedSpawn.mock.calls).toEqual([
|
||||
[
|
||||
'plutil',
|
||||
['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', path.join(artifactPath, 'Info.plist')],
|
||||
{ stdio: 'pipe', env: {} },
|
||||
],
|
||||
['xcrun', ['simctl', 'install', 'booted', artifactPath], { env: {}, logger }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('installs an Android Emulator .apk', async () => {
|
||||
const temporaryDirectory = await makeTemporaryDirectoryAsync();
|
||||
const artifactPath = path.join(temporaryDirectory, 'example.apk');
|
||||
await fs.promises.writeFile(artifactPath, 'apk');
|
||||
const logger = createMockLogger();
|
||||
mockedSpawn.mockResolvedValueOnce({
|
||||
stdout:
|
||||
"package: name='com.example.app'\nlaunchable-activity: name='com.example.app.MainActivity'\n",
|
||||
stderr: '',
|
||||
} as any);
|
||||
|
||||
const result = await installBuildAsync({
|
||||
artifactPath,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
});
|
||||
expect(mockedSpawn.mock.calls).toEqual([
|
||||
['aapt2', ['dump', 'badging', artifactPath], { stdio: 'pipe', env: {} }],
|
||||
['adb', ['install', '-r', artifactPath], { env: {}, logger }],
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[BuildRuntimePlatform.DARWIN, 'example.ipa'],
|
||||
[BuildRuntimePlatform.LINUX, 'example.aab'],
|
||||
])('rejects an incompatible %s artifact', async (runtimePlatform, filename) => {
|
||||
const temporaryDirectory = await makeTemporaryDirectoryAsync();
|
||||
const artifactPath = path.join(temporaryDirectory, filename);
|
||||
await fs.promises.writeFile(artifactPath, 'artifact');
|
||||
|
||||
await expect(
|
||||
installBuildAsync({
|
||||
artifactPath,
|
||||
runtimePlatform,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
})
|
||||
).rejects.toMatchObject({ errorCode: 'EAS_INSTALL_BUILD_INVALID_ARTIFACT' });
|
||||
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('installs an Android artifact without a launchable activity', async () => {
|
||||
const temporaryDirectory = await makeTemporaryDirectoryAsync();
|
||||
const artifactPath = path.join(temporaryDirectory, 'example.apk');
|
||||
await fs.promises.writeFile(artifactPath, 'apk');
|
||||
const logger = createMockLogger();
|
||||
mockedSpawn.mockResolvedValue({
|
||||
stdout: "package: name='com.example.app'\n",
|
||||
stderr: '',
|
||||
} as any);
|
||||
|
||||
const result = await installBuildAsync({
|
||||
artifactPath,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ applicationIdentifier: 'com.example.app' });
|
||||
expect(mockedSpawn.mock.calls).toEqual([
|
||||
['aapt2', ['dump', 'badging', artifactPath], { stdio: 'pipe', env: {} }],
|
||||
['adb', ['install', '-r', artifactPath], { env: {}, logger }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('exposes the installed application identifier and activity as step outputs', async () => {
|
||||
const temporaryDirectory = await makeTemporaryDirectoryAsync();
|
||||
const artifactPath = path.join(temporaryDirectory, 'example.apk');
|
||||
await fs.promises.writeFile(artifactPath, 'apk');
|
||||
mockedSpawn.mockResolvedValueOnce({
|
||||
stdout:
|
||||
"package: name='com.example.app'\nlaunchable-activity: name='com.example.app.MainActivity'\n",
|
||||
stderr: '',
|
||||
} as any);
|
||||
|
||||
const installBuild = createInstallBuildFunction();
|
||||
const buildStep = installBuild.createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX }),
|
||||
{ callInputs: { artifact_path: artifactPath } }
|
||||
);
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(buildStep.outputById.application_identifier.value).toBe('com.example.app');
|
||||
expect(buildStep.outputById.activity_name.value).toBe('com.example.app.MainActivity');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,10 @@
|
||||
import downloadFile from '@expo/downloader';
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { Datadog } from '../../../datadog';
|
||||
@@ -9,6 +15,11 @@ jest.mock('@expo/turtle-spawn', () => ({
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@expo/downloader', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../datadog', () => ({
|
||||
Datadog: {
|
||||
distribution: jest.fn(),
|
||||
@@ -16,10 +27,24 @@ jest.mock('../../../datadog', () => ({
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedDownloadFile = jest.mocked(downloadFile);
|
||||
|
||||
async function writeInstalledWdaVersion(homeDirectory: string, version: string): Promise<void> {
|
||||
const wdaDirectory = path.join(
|
||||
homeDirectory,
|
||||
'.maestro-runner',
|
||||
'drivers',
|
||||
'ios',
|
||||
'WebDriverAgent'
|
||||
);
|
||||
await fs.promises.mkdir(wdaDirectory, { recursive: true });
|
||||
await fs.promises.writeFile(path.join(wdaDirectory, 'package.json'), JSON.stringify({ version }));
|
||||
}
|
||||
|
||||
describe('createInstallMaestroBuildFunction', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockedDownloadFile.mockResolvedValue(undefined);
|
||||
// `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) => ({
|
||||
@@ -38,6 +63,7 @@ describe('createInstallMaestroBuildFunction', () => {
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('1.41.0');
|
||||
expect(Datadog.distribution).toHaveBeenCalledWith('eas.maestro.install', 1, {
|
||||
maestro_version: '1.41.0',
|
||||
maestro_backend: 'maestro',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,6 +96,7 @@ describe('createInstallMaestroBuildFunction', () => {
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('2.0.10');
|
||||
expect(Datadog.distribution).toHaveBeenCalledWith('eas.maestro.install', 1, {
|
||||
maestro_version: '2.0.10',
|
||||
maestro_backend: 'maestro',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,4 +114,532 @@ describe('createInstallMaestroBuildFunction', () => {
|
||||
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('2.0.10');
|
||||
});
|
||||
|
||||
it('checks maestro-runner when selected by input', async () => {
|
||||
mockedSpawn.mockImplementation((async (command: string) => ({
|
||||
stdout:
|
||||
command === 'maestro-runner'
|
||||
? 'maestro-runner 1.2.3\n Commit: abc123\n Built: 2026-08-13\n Go: go1.26.5\n OS/Arch: darwin/arm64\n'
|
||||
: '',
|
||||
})) as any);
|
||||
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock();
|
||||
globalCtx.updateEnv({ EAS_BUILD_RUNNER: 'eas-build' });
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner' },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(2);
|
||||
expect(mockedSpawn.mock.calls.map(([command]) => command)).toEqual([
|
||||
'maestro-runner',
|
||||
'maestro-runner',
|
||||
]);
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('1.2.3');
|
||||
expect(Datadog.distribution).toHaveBeenCalledWith('eas.maestro.install', 1, {
|
||||
maestro_version: '1.2.3',
|
||||
maestro_backend: 'maestro-runner',
|
||||
});
|
||||
});
|
||||
|
||||
it('selects maestro-runner from EAS_MAESTRO_BACKEND', async () => {
|
||||
mockedSpawn.mockImplementation((async (command: string) => ({
|
||||
stdout: command === 'maestro-runner' ? 'maestro-runner 1.2.3\n' : '',
|
||||
})) as any);
|
||||
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock();
|
||||
globalCtx.updateEnv({
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
EAS_MAESTRO_BACKEND: 'maestro-runner',
|
||||
});
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, { callInputs: {} });
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn.mock.calls.map(([command]) => command)).toEqual([
|
||||
'maestro-runner',
|
||||
'maestro-runner',
|
||||
]);
|
||||
});
|
||||
|
||||
it('installs the latest maestro-runner version when it is missing', async () => {
|
||||
let versionChecks = 0;
|
||||
mockedSpawn.mockImplementation((async (command: string) => {
|
||||
if (command === 'maestro-runner' && versionChecks++ === 0) {
|
||||
throw Object.assign(new Error('not found'), { code: 'ENOENT' });
|
||||
}
|
||||
return { stdout: command === 'maestro-runner' ? 'maestro-runner 1.2.3\n' : '' };
|
||||
}) as any);
|
||||
const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => '#!/bin/sh\n',
|
||||
} as Response);
|
||||
const originalPath = process.env.PATH;
|
||||
|
||||
try {
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock();
|
||||
globalCtx.updateEnv({
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
HOME: '/home/expo',
|
||||
PATH: '/usr/bin',
|
||||
});
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner' },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith('https://open.devicelab.dev/install/maestro-runner');
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/install_maestro_runner.*\/install_maestro_runner\.sh$/),
|
||||
[],
|
||||
expect.objectContaining({ env: expect.objectContaining({ HOME: '/home/expo' }) })
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenLastCalledWith(
|
||||
'maestro-runner',
|
||||
['--version'],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
PATH: expect.stringMatching(/^\/home\/expo\/\.maestro-runner\/bin:/),
|
||||
}),
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
});
|
||||
|
||||
it('installs maestro-runner 1.1.15 on Xcode versions below 26', async () => {
|
||||
let versionChecks = 0;
|
||||
mockedSpawn.mockImplementation((async (command: string) => {
|
||||
switch (command) {
|
||||
case 'xcodebuild':
|
||||
return { stdout: 'Xcode 16.4\nBuild version 16F6\n' };
|
||||
case 'maestro-runner':
|
||||
return {
|
||||
stdout: versionChecks++ === 0 ? 'maestro-runner 1.1.23\n' : 'maestro-runner 1.1.15\n',
|
||||
};
|
||||
default:
|
||||
return { stdout: '' };
|
||||
}
|
||||
}) as any);
|
||||
const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||
text: async () => '#!/bin/sh\n',
|
||||
} as Response);
|
||||
const originalPath = process.env.PATH;
|
||||
|
||||
try {
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
globalCtx.updateEnv({
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
HOME: '/home/expo',
|
||||
PATH: '/usr/bin',
|
||||
});
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner' },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/install_maestro_runner.*\/install_maestro_runner\.sh$/),
|
||||
['--version', '1.1.15'],
|
||||
expect.objectContaining({ env: expect.objectContaining({ HOME: '/home/expo' }) })
|
||||
);
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('1.1.15');
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
});
|
||||
|
||||
it('does not check Xcode when a compatible maestro-runner version is installed', async () => {
|
||||
mockedSpawn.mockImplementation((async (command: string) => {
|
||||
if (command === 'xcodebuild') {
|
||||
throw new Error('xcodebuild should not be called');
|
||||
}
|
||||
return { stdout: command === 'maestro-runner' ? 'maestro-runner 1.1.15\n' : '' };
|
||||
}) as any);
|
||||
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
globalCtx.updateEnv({
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
HOME: '/home/expo',
|
||||
PATH: '/usr/bin',
|
||||
});
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner' },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn).not.toHaveBeenCalledWith(
|
||||
'xcodebuild',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('1.1.15');
|
||||
});
|
||||
|
||||
it('checks Xcode for latest when a compatible maestro-runner version is installed', async () => {
|
||||
mockedSpawn.mockImplementation((async (command: string) => {
|
||||
switch (command) {
|
||||
case 'xcodebuild':
|
||||
return { stdout: 'Xcode 16.4\nBuild version 16F6\n' };
|
||||
case 'maestro-runner':
|
||||
return { stdout: 'maestro-runner 1.1.15\n' };
|
||||
default:
|
||||
return { stdout: '' };
|
||||
}
|
||||
}) as any);
|
||||
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
globalCtx.updateEnv({
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
HOME: '/home/expo',
|
||||
PATH: '/usr/bin',
|
||||
});
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner', maestro_version: 'latest' },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'xcodebuild',
|
||||
['-version'],
|
||||
expect.objectContaining({ env: expect.anything() })
|
||||
);
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('1.1.15');
|
||||
});
|
||||
|
||||
it('installs the latest maestro-runner version on Xcode 26', async () => {
|
||||
let versionChecks = 0;
|
||||
mockedSpawn.mockImplementation((async (command: string) => {
|
||||
if (command === 'xcodebuild') {
|
||||
return { stdout: 'Xcode 26.0\nBuild version 17A324\n' };
|
||||
}
|
||||
if (command === 'maestro-runner' && versionChecks++ === 0) {
|
||||
throw Object.assign(new Error('not found'), { code: 'ENOENT' });
|
||||
}
|
||||
return { stdout: command === 'maestro-runner' ? 'maestro-runner 1.1.23\n' : '' };
|
||||
}) as any);
|
||||
const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||
text: async () => '#!/bin/sh\n',
|
||||
} as Response);
|
||||
const originalPath = process.env.PATH;
|
||||
|
||||
try {
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
globalCtx.updateEnv({
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
HOME: '/home/expo',
|
||||
PATH: '/usr/bin',
|
||||
});
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner' },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/install_maestro_runner.*\/install_maestro_runner\.sh$/),
|
||||
[],
|
||||
expect.objectContaining({ env: expect.objectContaining({ HOME: '/home/expo' }) })
|
||||
);
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('1.1.23');
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
});
|
||||
|
||||
it('throws a system error when the Xcode version cannot be determined', async () => {
|
||||
mockedSpawn.mockImplementation((async (command: string) => {
|
||||
if (command === 'xcodebuild') {
|
||||
return { stdout: 'unexpected output' };
|
||||
}
|
||||
throw Object.assign(new Error('not found'), { code: 'ENOENT' });
|
||||
}) as any);
|
||||
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
globalCtx.updateEnv({
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
HOME: '/home/expo',
|
||||
PATH: '/usr/bin',
|
||||
});
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner' },
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(SystemError);
|
||||
});
|
||||
|
||||
it('rejects a requested maestro-runner version that is incompatible with Xcode', async () => {
|
||||
mockedSpawn.mockImplementation((async (command: string) => {
|
||||
if (command === 'xcodebuild') {
|
||||
return { stdout: 'Xcode 16.4\nBuild version 16F6\n' };
|
||||
}
|
||||
throw Object.assign(new Error('not found'), { code: 'ENOENT' });
|
||||
}) as any);
|
||||
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
globalCtx.updateEnv({
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
HOME: '/home/expo',
|
||||
PATH: '/usr/bin',
|
||||
});
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner', maestro_version: '1.1.23' },
|
||||
});
|
||||
|
||||
const executePromise = step.executeAsync();
|
||||
await expect(executePromise).rejects.toThrow(UserError);
|
||||
await expect(executePromise).rejects.toMatchObject({
|
||||
errorCode: 'ERR_MAESTRO_INVALID_INPUT',
|
||||
message:
|
||||
'maestro-runner 1.1.23 is not compatible with Xcode 16.4.0. Use maestro-runner 1.1.15 or an Xcode 26+ image.',
|
||||
});
|
||||
});
|
||||
|
||||
it('installs the requested maestro-runner version', async () => {
|
||||
let versionChecks = 0;
|
||||
mockedSpawn.mockImplementation((async (command: string) => {
|
||||
if (command === 'maestro-runner' && versionChecks++ === 0) {
|
||||
throw Object.assign(new Error('not found'), { code: 'ENOENT' });
|
||||
}
|
||||
return { stdout: command === 'maestro-runner' ? 'maestro-runner 1.0.9\n' : '' };
|
||||
}) as any);
|
||||
jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||
text: async () => '#!/bin/sh\n',
|
||||
} as Response);
|
||||
const originalPath = process.env.PATH;
|
||||
|
||||
try {
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock();
|
||||
globalCtx.updateEnv({
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
HOME: '/home/expo',
|
||||
PATH: '/usr/bin',
|
||||
});
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner', maestro_version: '1.0.9' },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/install_maestro_runner.*\/install_maestro_runner\.sh$/),
|
||||
['--version', '1.0.9'],
|
||||
expect.objectContaining({ env: expect.objectContaining({ HOME: '/home/expo' }) })
|
||||
);
|
||||
} finally {
|
||||
jest.restoreAllMocks();
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
});
|
||||
|
||||
it('installs the prebuilt WDA cache for each available iOS runtime', async () => {
|
||||
const homeDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'install-maestro-test-')
|
||||
);
|
||||
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
|
||||
if (command === 'maestro-runner') {
|
||||
return { stdout: 'maestro-runner 1.2.3\n' };
|
||||
}
|
||||
if (command === 'xcrun') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
runtimes: [
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-18-3',
|
||||
isAvailable: true,
|
||||
version: '18.3.1',
|
||||
},
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-0',
|
||||
isAvailable: true,
|
||||
version: '26.0',
|
||||
},
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-17-5',
|
||||
isAvailable: false,
|
||||
version: '17.5',
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (command === 'xcodebuild') {
|
||||
return { stdout: 'Xcode 26.0\nBuild version 17A324\n' };
|
||||
}
|
||||
if (command === 'tar') {
|
||||
const maestroRunnerHome = args[args.indexOf('-C') + 1];
|
||||
const productsDirectory = path.join(
|
||||
maestroRunnerHome,
|
||||
'cache',
|
||||
'wda-builds',
|
||||
'generic',
|
||||
'DerivedData',
|
||||
'Build',
|
||||
'Products'
|
||||
);
|
||||
await fs.promises.mkdir(productsDirectory, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(productsDirectory, 'WebDriverAgentRunner.xctestrun'),
|
||||
'cached'
|
||||
);
|
||||
}
|
||||
return { stdout: '' };
|
||||
}) as any);
|
||||
|
||||
try {
|
||||
await writeInstalledWdaVersion(homeDirectory, '11.1.3');
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
globalCtx.updateEnv({
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
EAS_BUILD_COCOAPODS_CACHE_URL: 'https://cache.example.com',
|
||||
HOME: homeDirectory,
|
||||
});
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner' },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedDownloadFile).toHaveBeenCalledWith(
|
||||
'https://cache.example.com/storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.0-wda-11.1.3.tar.gz',
|
||||
expect.stringMatching(/install_maestro_runner_wda_cache.*\/wda-cache\.tar\.gz$/),
|
||||
{ retry: 3, timeout: 20_000 }
|
||||
);
|
||||
for (const runtimeVersion of ['18.3', '26.0']) {
|
||||
await expect(
|
||||
fs.promises.readFile(
|
||||
path.join(
|
||||
homeDirectory,
|
||||
'.maestro-runner',
|
||||
'cache',
|
||||
'wda-builds',
|
||||
`sim-ios${runtimeVersion}-iphone`,
|
||||
'DerivedData',
|
||||
'Build',
|
||||
'Products',
|
||||
'WebDriverAgentRunner.xctestrun'
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
).resolves.toBe('cached');
|
||||
}
|
||||
await expect(
|
||||
fs.promises.access(
|
||||
path.join(homeDirectory, '.maestro-runner', 'cache', 'wda-builds', 'sim-ios17.5-iphone')
|
||||
)
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
await fs.promises.rm(homeDirectory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('does not download the WDA cache when the installed WDA version is unknown', async () => {
|
||||
mockedSpawn.mockImplementation((async (command: string) => ({
|
||||
stdout:
|
||||
command === 'xcodebuild' ? 'Xcode 26.5\nBuild version 17F90\n' : 'maestro-runner 1.2.3\n',
|
||||
})) as any);
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
globalCtx.updateEnv({ EAS_BUILD_RUNNER: 'eas-build', HOME: '/home/expo' });
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner' },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
// The installed WDA version is unknown (no package.json under $HOME), so the
|
||||
// cache download is skipped before resolving the Xcode/runtime versions.
|
||||
expect(mockedDownloadFile).not.toHaveBeenCalled();
|
||||
expect(mockedSpawn.mock.calls.map(([command]) => command)).not.toContain('xcrun');
|
||||
});
|
||||
|
||||
it('continues when the prebuilt WDA cache is not available', async () => {
|
||||
mockedSpawn.mockImplementation((async (command: string) => {
|
||||
switch (command) {
|
||||
case 'maestro-runner':
|
||||
return { stdout: 'maestro-runner 1.2.3\n' };
|
||||
case 'xcodebuild':
|
||||
return { stdout: 'Xcode 26.5\nBuild version 17F90\n' };
|
||||
case 'xcrun':
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
runtimes: [
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5',
|
||||
isAvailable: true,
|
||||
version: '26.5',
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
default:
|
||||
return { stdout: '' };
|
||||
}
|
||||
}) as any);
|
||||
mockedDownloadFile.mockRejectedValue(new Error('HTTP 404'));
|
||||
const homeDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'install-maestro-test-')
|
||||
);
|
||||
|
||||
try {
|
||||
await writeInstalledWdaVersion(homeDirectory, '11.1.3');
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
globalCtx.updateEnv({
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
EAS_BUILD_COCOAPODS_CACHE_URL: 'https://cache.example.com',
|
||||
HOME: homeDirectory,
|
||||
});
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner' },
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).resolves.toBeUndefined();
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('1.2.3');
|
||||
expect(mockedDownloadFile.mock.calls.map(([url]) => url)).toEqual([
|
||||
'https://cache.example.com/storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.5-wda-11.1.3.tar.gz',
|
||||
'https://storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.5-wda-11.1.3.tar.gz',
|
||||
]);
|
||||
} finally {
|
||||
await fs.promises.rm(homeDirectory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { BuildRuntimePlatform, BuildStep } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createInstallMitmproxyBuildFunction } from '../installMitmproxy';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
|
||||
describe('createInstallMitmproxyBuildFunction', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function createStep(env: Record<string, string>): BuildStep {
|
||||
const globalCtx = createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.DARWIN });
|
||||
globalCtx.updateEnv(env);
|
||||
return createInstallMitmproxyBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: {},
|
||||
});
|
||||
}
|
||||
|
||||
it('does not install when mitmdump is already on PATH', async () => {
|
||||
mockedSpawn.mockResolvedValueOnce({} as never);
|
||||
|
||||
await createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('mitmdump', ['--version'], expect.anything());
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('warns instead of installing outside EAS Build VMs', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(new Error('not found'));
|
||||
|
||||
await createStep({}).executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('installs the cask with Homebrew and leaves auto-update off', async () => {
|
||||
mockedSpawn
|
||||
.mockRejectedValueOnce(new Error('not found'))
|
||||
.mockResolvedValueOnce({} as never)
|
||||
.mockResolvedValueOnce({} as never);
|
||||
|
||||
await createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'brew',
|
||||
['install', '--cask', 'mitmproxy'],
|
||||
expect.objectContaining({ env: expect.objectContaining({ HOMEBREW_NO_AUTO_UPDATE: '1' }) })
|
||||
);
|
||||
});
|
||||
|
||||
it('does not fail the job when mitmdump is still not runnable', async () => {
|
||||
mockedSpawn
|
||||
.mockRejectedValueOnce(new Error('not found'))
|
||||
.mockResolvedValueOnce({} as never)
|
||||
.mockRejectedValueOnce(new Error('not found'));
|
||||
|
||||
await expect(
|
||||
createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync()
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,320 @@
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { createLaunchApplicationFunction, launchApplicationAsync } from '../launchApplication';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
|
||||
describe(launchApplicationAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
});
|
||||
|
||||
it('launches an iOS Simulator application by bundle identifier', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
['simctl', 'launch', 'booted', 'com.example.app'],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
});
|
||||
|
||||
it('passes launch arguments and opens a URL on iOS', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'host.exp.Exponent',
|
||||
launchArgs: ['--uitesting', 'true'],
|
||||
openUrl: 'exp://example.test',
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'xcrun',
|
||||
['simctl', 'launch', 'booted', 'host.exp.Exponent', '--uitesting', 'true'],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'xcrun',
|
||||
[
|
||||
'simctl',
|
||||
'spawn',
|
||||
'booted',
|
||||
'defaults',
|
||||
'write',
|
||||
'com.apple.launchservices.schemeapproval',
|
||||
'com.apple.CoreSimulator.CoreSimulatorBridge-->exp',
|
||||
'-string',
|
||||
'host.exp.Exponent',
|
||||
],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'xcrun',
|
||||
['simctl', 'openurl', 'booted', 'exp://example.test'],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
});
|
||||
|
||||
it('opens a web URL on iOS without preapproving its scheme', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
openUrl: 'https://example.test',
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'xcrun',
|
||||
['simctl', 'launch', 'booted', 'com.example.app'],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'xcrun',
|
||||
['simctl', 'openurl', 'booted', 'https://example.test'],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('opens a custom URL on iOS when scheme preapproval fails', async () => {
|
||||
const logger = createMockLogger();
|
||||
const approvalError = new Error('Scheme approval is unavailable.');
|
||||
mockedSpawn
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any)
|
||||
.mockRejectedValueOnce(approvalError);
|
||||
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'host.exp.Exponent',
|
||||
openUrl: 'exp://example.test',
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'xcrun',
|
||||
['simctl', 'openurl', 'booted', 'exp://example.test'],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: approvalError },
|
||||
'Could not preapprove the exp URL scheme for host.exp.Exponent. Opening the URL anyway; the Simulator might require manual confirmation.'
|
||||
);
|
||||
});
|
||||
|
||||
it('launches an Android Emulator application by package and activity', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'adb',
|
||||
['shell', 'am', 'start', '-n', 'com.example.app/com.example.app.MainActivity'],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
});
|
||||
|
||||
it('passes launch arguments and opens a URL on Android', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'host.exp.exponent',
|
||||
activityName: 'host.exp.exponent.MainActivity',
|
||||
launchArgs: ['--ez', 'isTest', 'true'],
|
||||
openUrl: 'exp://example.test',
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'--ez',
|
||||
'isTest',
|
||||
'true',
|
||||
'-n',
|
||||
'host.exp.exponent/host.exp.exponent.MainActivity',
|
||||
],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'-a',
|
||||
'android.intent.action.VIEW',
|
||||
'-d',
|
||||
'exp://example.test',
|
||||
'-n',
|
||||
'host.exp.exponent/host.exp.exponent.MainActivity',
|
||||
],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(logger.info).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'Launching host.exp.exponent with arguments ["--ez","isTest","true"].'
|
||||
);
|
||||
});
|
||||
|
||||
it('requires an activity when launching an Android application', async () => {
|
||||
await expect(
|
||||
launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
})
|
||||
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_MISSING_ACTIVITY' });
|
||||
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('launches using application metadata passed to the build step', async () => {
|
||||
const launchApplication = createLaunchApplicationFunction();
|
||||
const buildStep = launchApplication.createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX }),
|
||||
{
|
||||
callInputs: {
|
||||
application_identifier: 'com.example.app',
|
||||
activity_name: 'com.example.app.MainActivity',
|
||||
launch_args: ['--ez', 'isTest', 'true'],
|
||||
open_url: 'exp://example.test',
|
||||
},
|
||||
}
|
||||
);
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'--ez',
|
||||
'isTest',
|
||||
'true',
|
||||
'-n',
|
||||
'com.example.app/com.example.app.MainActivity',
|
||||
],
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'-a',
|
||||
'android.intent.action.VIEW',
|
||||
'-d',
|
||||
'exp://example.test',
|
||||
'-n',
|
||||
'com.example.app/com.example.app.MainActivity',
|
||||
],
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it.each([{}, ['valid', 42]])('rejects invalid launch arguments (%j)', async launchArgs => {
|
||||
const launchApplication = createLaunchApplicationFunction();
|
||||
const buildStep = launchApplication.createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.DARWIN }),
|
||||
{
|
||||
callInputs: {
|
||||
application_identifier: 'com.example.app',
|
||||
launch_args: launchArgs,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await expect(buildStep.executeAsync()).rejects.toMatchObject({
|
||||
errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
message: 'Input "launch_args" must be an array of strings.',
|
||||
});
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an invalid URL to open', async () => {
|
||||
const launchApplication = createLaunchApplicationFunction();
|
||||
const buildStep = launchApplication.createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.DARWIN }),
|
||||
{
|
||||
callInputs: {
|
||||
application_identifier: 'com.example.app',
|
||||
open_url: 'not a URL',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await expect(buildStep.executeAsync()).rejects.toMatchObject({
|
||||
errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
message: 'Input "open_url" must be a valid URL.',
|
||||
});
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['application_identifier', 'activity_name'])(
|
||||
'reports an empty %s input as a user error',
|
||||
async inputName => {
|
||||
const launchApplication = createLaunchApplicationFunction();
|
||||
const buildStep = launchApplication.createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX }),
|
||||
{
|
||||
callInputs: {
|
||||
application_identifier: 'com.example.app',
|
||||
activity_name: 'com.example.app.MainActivity',
|
||||
[inputName]: '',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await expect(buildStep.executeAsync()).rejects.toMatchObject({
|
||||
errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
message: `Input "${inputName}" must be a non-empty string. Pass the "${inputName}" output from eas/install_build.`,
|
||||
});
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -10,11 +10,92 @@ import {
|
||||
parseFailedFlowNamesFromJUnitFile,
|
||||
parseFailedFlowsFromFileAttrs,
|
||||
parseFailedFlowsFromJUnit,
|
||||
parseFailedFlowsFromMaestroRunnerReport,
|
||||
parseJUnitTestCases,
|
||||
parseMaestroResults,
|
||||
parseMaestroResultsFromFileAttrs,
|
||||
parseMaestroRunnerReport,
|
||||
} from '../maestroResultParser';
|
||||
|
||||
describe(parseFailedFlowsFromMaestroRunnerReport, () => {
|
||||
it('returns exact sourceFile paths for failed flows', async () => {
|
||||
vol.fromJSON({
|
||||
'/project/flows/pass.yml': '',
|
||||
'/project/flows/nested/fail.yml': '',
|
||||
'/reports/attempt-0/report.json': JSON.stringify({
|
||||
flows: [
|
||||
{ name: 'Passing flow', sourceFile: 'flows/pass.yml', status: 'passed' },
|
||||
{
|
||||
name: 'Failing flow',
|
||||
sourceFile: 'flows/nested/fail.yml',
|
||||
status: 'failed',
|
||||
},
|
||||
{ status: 'skipped' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
parseFailedFlowsFromMaestroRunnerReport({
|
||||
reportDirectory: '/reports/attempt-0',
|
||||
workingDirectory: '/project',
|
||||
})
|
||||
).resolves.toEqual(['flows/nested/fail.yml']);
|
||||
await expect(parseMaestroRunnerReport('/reports/attempt-0')).resolves.toEqual({
|
||||
flows: [
|
||||
{ name: 'Passing flow', sourceFile: 'flows/pass.yml', status: 'passed' },
|
||||
{
|
||||
name: 'Failing flow',
|
||||
sourceFile: 'flows/nested/fail.yml',
|
||||
status: 'failed',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'incomplete flow',
|
||||
report: {
|
||||
version: '1.0.0',
|
||||
status: 'failed',
|
||||
summary: { total: 1 },
|
||||
flows: [{ name: 'Failing flow', sourceFile: 'flows/fail.yml', status: 'running' }],
|
||||
},
|
||||
},
|
||||
])('returns null for an $name', async ({ report }) => {
|
||||
vol.fromJSON({
|
||||
'/project/flows/fail.yml': '',
|
||||
'/reports/attempt-0/report.json': JSON.stringify(report),
|
||||
});
|
||||
|
||||
await expect(
|
||||
parseFailedFlowsFromMaestroRunnerReport({
|
||||
reportDirectory: '/reports/attempt-0',
|
||||
workingDirectory: '/project',
|
||||
})
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when a failed sourceFile does not exist', async () => {
|
||||
vol.fromJSON({
|
||||
'/reports/attempt-0/report.json': JSON.stringify({
|
||||
version: '1.0.0',
|
||||
status: 'failed',
|
||||
summary: { total: 1 },
|
||||
flows: [{ name: 'Failing flow', sourceFile: 'flows/missing.yml', status: 'failed' }],
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
parseFailedFlowsFromMaestroRunnerReport({
|
||||
reportDirectory: '/reports/attempt-0',
|
||||
workingDirectory: '/project',
|
||||
})
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseFailedFlowNamesFromJUnitFile, () => {
|
||||
it('returns the names of failed testcases, keeping slashes', async () => {
|
||||
vol.fromJSON({
|
||||
@@ -453,6 +534,20 @@ describe('junitFileHasFileAttrs', () => {
|
||||
expect(await junitFileHasFileAttrs('/junit/report.xml')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for a maestro-runner report with a file property', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="a" time="1.0">',
|
||||
' <properties><property name="file" value="flows/a.yaml"/></properties>',
|
||||
' </testcase>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
expect(await junitFileHasFileAttrs('/junit/report.xml')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when the file cannot be read', async () => {
|
||||
expect(await junitFileHasFileAttrs('/missing.xml')).toBe(false);
|
||||
});
|
||||
@@ -478,6 +573,41 @@ describe(parseJUnitTestCases, () => {
|
||||
expect(results[0].file).toBe('.maestro/login.yaml');
|
||||
});
|
||||
|
||||
it('parses maestro-runner file properties and standard JUnit pass status', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="login" time="1.0">',
|
||||
' <properties><property name="file" value="flows/login.yaml"/></properties>',
|
||||
' </testcase>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
|
||||
expect(results[0]).toEqual(
|
||||
expect.objectContaining({ file: 'flows/login.yaml', status: 'passed' })
|
||||
);
|
||||
});
|
||||
|
||||
it('excludes skipped testcases instead of counting them as passed', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="passing" time="1.0"/>',
|
||||
' <testcase name="skipped-flow" time="0"><skipped/></testcase>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
|
||||
expect(results.map(r => r.name)).toEqual(['passing']);
|
||||
});
|
||||
|
||||
it('treats a missing or empty file= attribute as undefined', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
|
||||
@@ -3,14 +3,160 @@ import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import {
|
||||
type HarvestedScreenshot,
|
||||
computePureFailureFlowNames,
|
||||
harvestFailureScreenshotsAsync,
|
||||
harvestMaestroRunnerFailureScreenshotsAsync,
|
||||
parseFailureScreenshotFilename,
|
||||
selectFailureScreenshots,
|
||||
} from '../maestroScreenshots';
|
||||
|
||||
describe(harvestMaestroRunnerFailureScreenshotsAsync, () => {
|
||||
const logger = createMockLogger();
|
||||
let reportDirectory: string;
|
||||
let captureSpy: jest.SpiedFunction<typeof Sentry.capture>;
|
||||
|
||||
beforeEach(async () => {
|
||||
reportDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'maestro-runner-harvest-test-'));
|
||||
await fs.mkdir(path.join(reportDirectory, 'flows'));
|
||||
await fs.mkdir(path.join(reportDirectory, 'assets', 'flow-000'), { recursive: true });
|
||||
captureSpy = jest.spyOn(Sentry, 'capture').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(reportDirectory, { recursive: true, force: true });
|
||||
captureSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('reads the failed command screenshot from a maestro-runner report bundle', async () => {
|
||||
const capturedSinceMs = Date.now() - 1_000;
|
||||
await fs.writeFile(
|
||||
path.join(reportDirectory, 'report.json'),
|
||||
JSON.stringify({
|
||||
flows: [
|
||||
{
|
||||
name: 'authentication/Login',
|
||||
status: 'failed',
|
||||
dataFile: 'flows/flow-000.json',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(reportDirectory, 'flows', 'flow-000.json'),
|
||||
JSON.stringify({
|
||||
commands: [
|
||||
{
|
||||
status: 'failed',
|
||||
artifacts: { screenshotAfter: 'assets/flow-000/cmd-001-after.png' },
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
const screenshotPath = path.join(reportDirectory, 'assets', 'flow-000', 'cmd-001-after.png');
|
||||
await fs.writeFile(screenshotPath, 'png');
|
||||
|
||||
const shots = await harvestMaestroRunnerFailureScreenshotsAsync({
|
||||
reportDirectory,
|
||||
capturedSinceMs,
|
||||
attemptIndex: 1,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots).toEqual([
|
||||
{
|
||||
fileAbsPath: screenshotPath,
|
||||
displayName: 'Failure Screenshot: authentication_Login (attempt 2)',
|
||||
metadata: {
|
||||
kind: 'maestro-test-screenshot',
|
||||
flowName: 'authentication_Login',
|
||||
attemptIndex: 1,
|
||||
capturedAtMs: expect.any(Number),
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects a screenshot path that resolves to the parent directory (exact "..")', async () => {
|
||||
await fs.writeFile(
|
||||
path.join(reportDirectory, 'report.json'),
|
||||
JSON.stringify({
|
||||
flows: [{ name: 'Login', status: 'failed', dataFile: 'flows/flow-000.json' }],
|
||||
})
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(reportDirectory, 'flows', 'flow-000.json'),
|
||||
JSON.stringify({
|
||||
commands: [{ status: 'failed', artifacts: { screenshotAfter: '..' } }],
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
harvestMaestroRunnerFailureScreenshotsAsync({
|
||||
reportDirectory,
|
||||
capturedSinceMs: 0,
|
||||
attemptIndex: 0,
|
||||
logger,
|
||||
})
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores passing flows and paths outside the report directory', async () => {
|
||||
await fs.writeFile(
|
||||
path.join(reportDirectory, 'report.json'),
|
||||
JSON.stringify({
|
||||
flows: [
|
||||
{ name: 'Passing', status: 'passed', dataFile: 'flows/flow-000.json' },
|
||||
{ name: 'Unsafe', status: 'failed', dataFile: '../outside.json' },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
harvestMaestroRunnerFailureScreenshotsAsync({
|
||||
reportDirectory,
|
||||
capturedSinceMs: 0,
|
||||
attemptIndex: 0,
|
||||
logger,
|
||||
})
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['null', 'null'],
|
||||
['a non-object flows value', JSON.stringify({ flows: 5 })],
|
||||
['a null flow entry', JSON.stringify({ flows: [null] })],
|
||||
])('reports to Sentry and returns [] when report.json is %s', async (_label, contents) => {
|
||||
await fs.writeFile(path.join(reportDirectory, 'report.json'), contents);
|
||||
|
||||
await expect(
|
||||
harvestMaestroRunnerFailureScreenshotsAsync({
|
||||
reportDirectory,
|
||||
capturedSinceMs: 0,
|
||||
attemptIndex: 0,
|
||||
logger,
|
||||
})
|
||||
).resolves.toEqual([]);
|
||||
expect(captureSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns [] without reporting to Sentry when report.json is not valid JSON', async () => {
|
||||
await fs.writeFile(path.join(reportDirectory, 'report.json'), 'not-json');
|
||||
|
||||
await expect(
|
||||
harvestMaestroRunnerFailureScreenshotsAsync({
|
||||
reportDirectory,
|
||||
capturedSinceMs: 0,
|
||||
attemptIndex: 0,
|
||||
logger,
|
||||
})
|
||||
).resolves.toEqual([]);
|
||||
expect(captureSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseFailureScreenshotFilename, () => {
|
||||
it('parses a plain failure screenshot', () => {
|
||||
expect(parseFailureScreenshotFilename('screenshot-❌-1781186692250-(Login Flow).png')).toEqual({
|
||||
|
||||
@@ -27,10 +27,14 @@ jest.mock('../../../utils/retry', () => ({
|
||||
jest.mock('../maestroScreenshots', () => ({
|
||||
...jest.requireActual('../maestroScreenshots'),
|
||||
harvestFailureScreenshotsAsync: jest.fn(),
|
||||
harvestMaestroRunnerFailureScreenshotsAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedHarvest = jest.mocked(maestroScreenshots.harvestFailureScreenshotsAsync);
|
||||
const mockedRunnerHarvest = jest.mocked(
|
||||
maestroScreenshots.harvestMaestroRunnerFailureScreenshotsAsync
|
||||
);
|
||||
const mockUploadArtifact = jest.fn();
|
||||
|
||||
function makeShot(index: number): maestroScreenshots.HarvestedScreenshot {
|
||||
@@ -63,11 +67,14 @@ const rejectExit1 = (): Error => {
|
||||
|
||||
function createStep(
|
||||
callInputs?: Record<string, unknown>,
|
||||
options: { env?: Record<string, string | undefined> } = {}
|
||||
options: {
|
||||
env?: Record<string, string | undefined>;
|
||||
logger?: ReturnType<typeof createMockLogger>;
|
||||
} = {}
|
||||
): ReturnType<
|
||||
ReturnType<typeof createMaestroTestsBuildFunction>['createBuildStepFromFunctionCall']
|
||||
> {
|
||||
const logger = createMockLogger();
|
||||
const logger = options.logger ?? createMockLogger();
|
||||
const ctx = {
|
||||
runtimeApi: { uploadArtifact: mockUploadArtifact },
|
||||
} as unknown as CustomBuildContext;
|
||||
@@ -86,6 +93,8 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
|
||||
mockedHarvest.mockReset();
|
||||
mockedHarvest.mockResolvedValue([]);
|
||||
mockedRunnerHarvest.mockReset();
|
||||
mockedRunnerHarvest.mockResolvedValue([]);
|
||||
mockUploadArtifact.mockReset();
|
||||
mockUploadArtifact.mockResolvedValue({ artifactId: 'artifact-1' });
|
||||
});
|
||||
@@ -134,6 +143,523 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
expect(args!.join(' ')).toMatch(/--output=.*android-maestro-junit-attempt-0\.xml/);
|
||||
});
|
||||
|
||||
it('runs maestro-runner and collects its JUnit report when selected by input', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const copyFileSpy = jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
const junitFailureNamesSpy = jest.spyOn(parser, 'parseFailedFlowNamesFromJUnitFile');
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'ios',
|
||||
backend: 'maestro-runner',
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
const [command, args] = mockedSpawn.mock.calls[0];
|
||||
expect(command).toBe('maestro-runner');
|
||||
expect(args).toEqual([
|
||||
'--platform=ios',
|
||||
'test',
|
||||
'--output=/home/expo/.maestro/tests/ios-maestro-runner-attempt-0',
|
||||
'--flatten',
|
||||
'flows/a.yaml',
|
||||
'flows/b.yaml',
|
||||
]);
|
||||
expect(copyFileSpy).toHaveBeenCalledWith(
|
||||
'/home/expo/.maestro/tests/ios-maestro-runner-attempt-0/junit-report.xml',
|
||||
'/home/expo/.maestro/tests/junit-reports/ios-maestro-junit-attempt-0.xml'
|
||||
);
|
||||
expect(mockedHarvest).not.toHaveBeenCalled();
|
||||
expect(junitFailureNamesSpy).not.toHaveBeenCalled();
|
||||
expect(mockedRunnerHarvest).toHaveBeenCalledWith({
|
||||
reportDirectory: '/home/expo/.maestro/tests/ios-maestro-runner-attempt-0',
|
||||
capturedSinceMs: expect.any(Number),
|
||||
attemptIndex: 0,
|
||||
logger: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the deterministic maestro-runner output directory before each attempt', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
const rmSpy = jest.spyOn(fs, 'rm').mockResolvedValue();
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'ios',
|
||||
backend: 'maestro-runner',
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(rmSpy).toHaveBeenCalledWith('/home/expo/.maestro/tests/ios-maestro-runner-attempt-0', {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
// Cleared before the run, so a crash before fresh output can't resurrect stale results.
|
||||
expect(rmSpy.mock.invocationCallOrder[0]).toBeLessThan(mockedSpawn.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it('selects maestro-runner from EAS_MAESTRO_BACKEND', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
const step = createStep(
|
||||
{ flow_path: ['flows/a.yaml'], platform: 'android' },
|
||||
{ env: { HOME: '/home/expo', EAS_MAESTRO_BACKEND: 'maestro-runner' } }
|
||||
);
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn.mock.calls[0][0]).toBe('maestro-runner');
|
||||
});
|
||||
|
||||
it('prefers the backend input over EAS_MAESTRO_BACKEND', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const step = createStep(
|
||||
{ flow_path: ['flows/a.yaml'], platform: 'android', backend: 'maestro' },
|
||||
{ env: { HOME: '/home/expo', EAS_MAESTRO_BACKEND: 'maestro-runner' } }
|
||||
);
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn.mock.calls[0][0]).toBe('maestro');
|
||||
});
|
||||
|
||||
it('passes tags to maestro-runner', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
backend: 'maestro-runner',
|
||||
include_tags: 'smoke',
|
||||
exclude_tags: 'slow',
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn.mock.calls[0][1]).toEqual(
|
||||
expect.arrayContaining(['--include-tags=smoke', '--exclude-tags=slow'])
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid backend values', async () => {
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
backend: 'other',
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes shards to maestro-runner as parallel devices', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
backend: 'maestro-runner',
|
||||
shards: 2,
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn.mock.calls[0][1]).toContain('--parallel=2');
|
||||
});
|
||||
|
||||
it.each(['html', 'allure'])(
|
||||
'accepts %s output_format with maestro-runner and keeps JUnit',
|
||||
async format => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const copyFileSpy = jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
backend: 'maestro-runner',
|
||||
output_format: format,
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn.mock.calls[0][1]).toEqual(
|
||||
expect.arrayContaining([
|
||||
'--output=/home/expo/.maestro/tests/android-maestro-runner-attempt-0',
|
||||
])
|
||||
);
|
||||
expect(copyFileSpy).toHaveBeenCalledWith(
|
||||
'/home/expo/.maestro/tests/android-maestro-runner-attempt-0/junit-report.xml',
|
||||
'/home/expo/.maestro/tests/junit-reports/android-maestro-junit-attempt-0.xml'
|
||||
);
|
||||
expect(parser.mergeJUnitReports).not.toHaveBeenCalled();
|
||||
expect(step.getOutputValueByName('final_report_path')).toBe(
|
||||
format === 'html'
|
||||
? '/home/expo/.maestro/tests/android-maestro-runner-attempt-0/report.html'
|
||||
: '/home/expo/.maestro/tests/android-maestro-runner-attempt-0/allure-results'
|
||||
);
|
||||
expect(mockedRunnerHarvest).not.toHaveBeenCalled();
|
||||
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
|
||||
expect(mockUploadArtifact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
artifact: expect.objectContaining({
|
||||
type: GenericArtifactType.OTHER,
|
||||
name:
|
||||
format === 'html' ? 'Maestro Runner HTML Report' : 'Maestro Runner Allure Results',
|
||||
paths: [
|
||||
format === 'html'
|
||||
? '/home/expo/.maestro/tests/android-maestro-runner-attempt-0'
|
||||
: '/home/expo/.maestro/tests/android-maestro-runner-attempt-0/allure-results',
|
||||
],
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('does not expose a selected runner report before an attempt starts', async () => {
|
||||
const step = createStep({
|
||||
flow_path: [],
|
||||
platform: 'android',
|
||||
backend: 'maestro-runner',
|
||||
output_format: 'html',
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
expect(step.getOutputValueByName('final_report_path')).toBeUndefined();
|
||||
expect(step.getOutputValueByName('junit_report_directory')).toBe(
|
||||
'/home/expo/.maestro/tests/junit-reports'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an unknown output_format with maestro-runner', async () => {
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
backend: 'maestro-runner',
|
||||
output_format: 'csv',
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(
|
||||
'maestro-runner supports "junit", "html", and "allure" output_format values'
|
||||
);
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uploads the last runner HTML report even when the test fails', async () => {
|
||||
mockedSpawn.mockRejectedValue(rejectExit1());
|
||||
jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
backend: 'maestro-runner',
|
||||
output_format: 'html',
|
||||
retries: 1,
|
||||
retry_failed_only: false,
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
|
||||
expect(mockUploadArtifact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
artifact: expect.objectContaining({
|
||||
name: 'Maestro Runner HTML Report',
|
||||
paths: ['/home/expo/.maestro/tests/android-maestro-runner-attempt-1'],
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(step.getOutputValueByName('final_report_path')).toBe(
|
||||
'/home/expo/.maestro/tests/android-maestro-runner-attempt-1/report.html'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not fail a passing test when the runner report upload fails', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
mockUploadArtifact.mockRejectedValue(new Error('upload failed'));
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
backend: 'maestro-runner',
|
||||
output_format: 'allure',
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('logs that maestro-runner does not support direct DADB', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
const logger = createMockLogger();
|
||||
jest.mocked(logger.child).mockReturnValue(logger);
|
||||
const step = createStep(
|
||||
{
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
backend: 'maestro-runner',
|
||||
android_connection_mode: 'dadb',
|
||||
},
|
||||
{ env: { HOME: '/home/expo', PATH: '/usr/bin' }, logger }
|
||||
);
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
expect(mockedSpawn.mock.calls[0][0]).toBe('maestro-runner');
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'maestro-runner does not support DADB. Using the default ADB connection.'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses direct DADB when android_connection_mode is dadb without cleanup', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const writeFileSpy = jest.spyOn(fs, 'writeFile');
|
||||
const rmSpy = jest.spyOn(fs, 'rm');
|
||||
const logger = createMockLogger();
|
||||
jest.mocked(logger.child).mockReturnValue(logger);
|
||||
const step = createStep(
|
||||
{
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
android_connection_mode: 'dadb',
|
||||
},
|
||||
{ env: { HOME: '/home/expo', PATH: '/usr/bin' }, logger }
|
||||
);
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(2);
|
||||
expect(mockedSpawn.mock.calls[0].slice(0, 2)).toEqual(['adb', ['kill-server']]);
|
||||
expect(mockedSpawn.mock.calls[1][0]).toBe('maestro');
|
||||
expect(mockedSpawn.mock.calls[0][2]?.signal).toBe(mockedSpawn.mock.calls[1][2]?.signal);
|
||||
|
||||
const maestroEnv = mockedSpawn.mock.calls[1][2]?.env;
|
||||
expect(maestroEnv?.PATH).toMatch(
|
||||
new RegExp(`^${os.tmpdir()}/maestro-tests-adb-override-.+${path.delimiter}/usr/bin$`)
|
||||
);
|
||||
expect(mockedSpawn.mock.calls[0][2]?.env?.PATH).not.toContain('maestro-tests-adb-override-');
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Using a direct DADB connection for Android Maestro tests after stopping the ADB server.'
|
||||
);
|
||||
|
||||
expect(writeFileSpy).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/maestro-tests-adb-override-.+\/adb$/),
|
||||
'#!/bin/sh\nexit 1\n',
|
||||
{ mode: 0o755 }
|
||||
);
|
||||
expect(rmSpy).not.toHaveBeenCalledWith(
|
||||
expect.stringMatching(/maestro-tests-adb-override-/),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('continues with direct DADB and warns when adb kill-server fails', async () => {
|
||||
const killServerError = new Error('ADB server is unavailable');
|
||||
mockedSpawn.mockImplementation((async (command: string) => {
|
||||
if (command === 'adb') {
|
||||
throw killServerError;
|
||||
}
|
||||
return SPAWN_SUCCESS;
|
||||
}) as any);
|
||||
const logger = createMockLogger();
|
||||
jest.mocked(logger.child).mockReturnValue(logger);
|
||||
const step = createStep(
|
||||
{
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
android_connection_mode: 'dadb',
|
||||
},
|
||||
{ env: { HOME: '/home/expo', PATH: '/usr/bin' }, logger }
|
||||
);
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn.mock.calls.map(([command]) => command)).toEqual(['adb', 'maestro']);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: killServerError },
|
||||
'Using a direct DADB connection for Android Maestro tests, but failed to stop the ADB server.'
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
source: 'omitted',
|
||||
callInputs: { flow_path: ['flows/a.yaml'], platform: 'android' },
|
||||
env: { HOME: '/home/expo' },
|
||||
},
|
||||
{
|
||||
source: 'empty input',
|
||||
callInputs: { flow_path: ['flows/a.yaml'], platform: 'android', android_connection_mode: '' },
|
||||
env: { HOME: '/home/expo' },
|
||||
},
|
||||
{
|
||||
source: 'empty environment variable',
|
||||
callInputs: { flow_path: ['flows/a.yaml'], platform: 'android' },
|
||||
env: { HOME: '/home/expo', EAS_MAESTRO_ANDROID_CONNECTION_MODE: '' },
|
||||
},
|
||||
])('treats an $source connection mode value as adb', async ({ callInputs, env }) => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const step = createStep(callInputs, { env });
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
expect(mockedSpawn.mock.calls[0][0]).toBe('maestro');
|
||||
});
|
||||
|
||||
it('uses direct DADB when EAS_MAESTRO_ANDROID_CONNECTION_MODE is dadb', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const step = createStep(
|
||||
{
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
},
|
||||
{
|
||||
env: {
|
||||
HOME: '/home/expo',
|
||||
PATH: '/usr/bin',
|
||||
EAS_MAESTRO_ANDROID_CONNECTION_MODE: 'dadb',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn.mock.calls[0].slice(0, 2)).toEqual(['adb', ['kill-server']]);
|
||||
expect(mockedSpawn.mock.calls[1][0]).toBe('maestro');
|
||||
});
|
||||
|
||||
it('prefers android_connection_mode over EAS_MAESTRO_ANDROID_CONNECTION_MODE', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const step = createStep(
|
||||
{
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
android_connection_mode: 'adb',
|
||||
},
|
||||
{
|
||||
env: {
|
||||
HOME: '/home/expo',
|
||||
PATH: '/usr/bin',
|
||||
EAS_MAESTRO_ANDROID_CONNECTION_MODE: 'dadb',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
expect(mockedSpawn.mock.calls[0][0]).toBe('maestro');
|
||||
});
|
||||
|
||||
it('uses the same direct DADB environment for all retries', async () => {
|
||||
let maestroAttempt = 0;
|
||||
mockedSpawn.mockImplementation((async (command: string) => {
|
||||
if (command === 'maestro' && maestroAttempt++ === 0) {
|
||||
throw rejectExit1();
|
||||
}
|
||||
return SPAWN_SUCCESS;
|
||||
}) as any);
|
||||
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(null);
|
||||
const step = createStep(
|
||||
{
|
||||
flow_path: ['flows/a.yaml'],
|
||||
retries: 1,
|
||||
platform: 'android',
|
||||
android_connection_mode: 'dadb',
|
||||
},
|
||||
{ env: { HOME: '/home/expo', PATH: '/usr/bin' } }
|
||||
);
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
const maestroCalls = mockedSpawn.mock.calls.filter(([command]) => command === 'maestro');
|
||||
expect(maestroCalls).toHaveLength(2);
|
||||
expect(maestroCalls[0][2]?.env?.PATH).toBe(maestroCalls[1][2]?.env?.PATH);
|
||||
expect(mockedSpawn.mock.calls.filter(([command]) => command === 'adb')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects invalid android_connection_mode values', async () => {
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
android_connection_mode: 'automatic',
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects invalid EAS_MAESTRO_ANDROID_CONNECTION_MODE values', async () => {
|
||||
const step = createStep(
|
||||
{
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
},
|
||||
{ env: { HOME: '/home/expo', EAS_MAESTRO_ANDROID_CONNECTION_MODE: 'automatic' } }
|
||||
);
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores direct DADB input mode on iOS', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'ios',
|
||||
android_connection_mode: 'dadb',
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
expect(mockedSpawn.mock.calls[0][0]).toBe('maestro');
|
||||
});
|
||||
|
||||
it('ignores direct DADB environment mode on iOS', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const step = createStep(
|
||||
{
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'ios',
|
||||
},
|
||||
{ env: { HOME: '/home/expo', EAS_MAESTRO_ANDROID_CONNECTION_MODE: 'dadb' } }
|
||||
);
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
expect(mockedSpawn.mock.calls[0][0]).toBe('maestro');
|
||||
});
|
||||
|
||||
it('does not clean up direct DADB mode when Maestro fails', async () => {
|
||||
mockedSpawn.mockImplementation((async (command: string) => {
|
||||
if (command === 'maestro') {
|
||||
throw new Error('maestro crashed');
|
||||
}
|
||||
return SPAWN_SUCCESS;
|
||||
}) as any);
|
||||
const rmSpy = jest.spyOn(fs, 'rm');
|
||||
const step = createStep(
|
||||
{
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
android_connection_mode: 'dadb',
|
||||
},
|
||||
{ env: { HOME: '/home/expo', PATH: '/usr/bin' } }
|
||||
);
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow('Unexpected spawn failure invoking maestro');
|
||||
expect(mockedSpawn.mock.calls.filter(([command]) => command === 'adb')).toHaveLength(1);
|
||||
expect(rmSpy).not.toHaveBeenCalledWith(
|
||||
expect.stringMatching(/maestro-tests-adb-override-/),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
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' });
|
||||
@@ -203,6 +729,36 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
expect(a1Args).not.toContain('flows/c.yaml');
|
||||
});
|
||||
|
||||
it('uses maestro-runner report.json for junit retries', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
const runnerReportSpy = jest
|
||||
.spyOn(parser, 'parseFailedFlowsFromMaestroRunnerReport')
|
||||
.mockResolvedValue(['flows/b.yaml']);
|
||||
const junitParseSpy = jest.spyOn(parser, 'parseFailedFlowsFromJUnit');
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'junit',
|
||||
platform: 'ios',
|
||||
backend: 'maestro-runner',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(runnerReportSpy).toHaveBeenCalledWith({
|
||||
reportDirectory: '/home/expo/.maestro/tests/ios-maestro-runner-attempt-0',
|
||||
workingDirectory: expect.any(String),
|
||||
});
|
||||
expect(junitParseSpy).not.toHaveBeenCalled();
|
||||
expect(mockedSpawn.mock.calls[0][1]).toEqual(
|
||||
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml'])
|
||||
);
|
||||
const retryArgs = mockedSpawn.mock.calls[1][1]!;
|
||||
expect(retryArgs).toContain('flows/b.yaml');
|
||||
expect(retryArgs).not.toContain('flows/a.yaml');
|
||||
});
|
||||
|
||||
it('retries all flows when parseFailedFlowsFromJUnit returns null', async () => {
|
||||
const spawnMock = mockedSpawn
|
||||
.mockRejectedValueOnce(rejectExit1())
|
||||
@@ -514,7 +1070,7 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('uses $HOME/.maestro/tests output path for non-junit formats (e.g. html)', async () => {
|
||||
it('uses $HOME/.maestro/tests output path and uploads the Maestro CLI HTML report', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({
|
||||
@@ -525,11 +1081,119 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
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.
|
||||
// Non-JUnit uses a fixed path inside $HOME/.maestro/tests.
|
||||
const outputArg = args.find(a => a.startsWith('--output='));
|
||||
expect(outputArg).toMatch(/\.maestro\/tests\/android-maestro-html\.html$/);
|
||||
expect(outputArg).not.toMatch(/junit-reports/);
|
||||
expect(step.getOutputValueByName('final_report_path')).toBe(
|
||||
'/home/expo/.maestro/tests/android-maestro-html.html'
|
||||
);
|
||||
expect(mockUploadArtifact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
artifact: expect.objectContaining({
|
||||
type: GenericArtifactType.OTHER,
|
||||
name: 'Maestro HTML Report',
|
||||
paths: ['/home/expo/.maestro/tests/android-maestro-html.html'],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads the Maestro CLI HTML report even when tests fail', async () => {
|
||||
mockedSpawn.mockRejectedValue(rejectExit1());
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'html',
|
||||
platform: 'android',
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
|
||||
expect(mockUploadArtifact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
artifact: expect.objectContaining({
|
||||
name: 'Maestro HTML Report',
|
||||
paths: ['/home/expo/.maestro/tests/android-maestro-html.html'],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('warns that Maestro CLI does not support Allure reports', async () => {
|
||||
mockedSpawn.mockRejectedValue(rejectExit1());
|
||||
const logger = createMockLogger();
|
||||
jest.mocked(logger.child).mockReturnValue(logger);
|
||||
const step = createStep(
|
||||
{
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'allure',
|
||||
platform: 'android',
|
||||
},
|
||||
{ logger }
|
||||
);
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Maestro CLI does not support Allure reports; no Allure artifact was uploaded.'
|
||||
);
|
||||
expect(mockUploadArtifact).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears the Maestro CLI HTML report before every retry attempt', async () => {
|
||||
const removeSpy = jest.spyOn(fs, 'rm').mockResolvedValue();
|
||||
mockedSpawn.mockRejectedValue(rejectExit1());
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'html',
|
||||
platform: 'android',
|
||||
retries: 1,
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
|
||||
expect(
|
||||
removeSpy.mock.calls.filter(
|
||||
([target]) => target === '/home/expo/.maestro/tests/android-maestro-html.html'
|
||||
)
|
||||
).toHaveLength(2);
|
||||
expect(removeSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/home/expo/.maestro/tests/android-maestro-html.html',
|
||||
{
|
||||
force: true,
|
||||
}
|
||||
);
|
||||
expect(removeSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/home/expo/.maestro/tests/android-maestro-html.html',
|
||||
{
|
||||
force: true,
|
||||
}
|
||||
);
|
||||
expect(removeSpy.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockedSpawn.mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(removeSpy.mock.invocationCallOrder[1]).toBeLessThan(
|
||||
mockedSpawn.mock.invocationCallOrder[1]
|
||||
);
|
||||
});
|
||||
|
||||
it('does not run Maestro CLI or upload old HTML if the report cannot be cleared', async () => {
|
||||
jest.spyOn(fs, 'rm').mockImplementation(async target => {
|
||||
if (target === '/home/expo/.maestro/tests/android-maestro-html.html') {
|
||||
throw new Error('permission denied');
|
||||
}
|
||||
});
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'html',
|
||||
platform: 'android',
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(SystemError);
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
expect(mockUploadArtifact).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses lowercase extension for non-junit formats regardless of input casing', async () => {
|
||||
@@ -764,6 +1428,32 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['junit', undefined])(
|
||||
'uploads runner screenshots with output_format=%s',
|
||||
async format => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const shot = makeShot(0);
|
||||
jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
mockedRunnerHarvest.mockResolvedValue([shot]);
|
||||
const parseFlowResultsSpy = jest.spyOn(parser, 'parseMaestroRunnerReport').mockResolvedValue({
|
||||
flows: [{ name: 'Login', sourceFile: 'a.yaml', status: 'failed' }],
|
||||
});
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['a.yaml'],
|
||||
platform: 'ios',
|
||||
...(format === undefined ? {} : { output_format: format }),
|
||||
backend: 'maestro-runner',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(parseFlowResultsSpy).toHaveBeenCalledWith(
|
||||
'/home/expo/.maestro/tests/ios-maestro-runner-attempt-0'
|
||||
);
|
||||
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('uploads screenshots even when all attempts fail, before throwing ERR_MAESTRO_TESTS_FAILED', async () => {
|
||||
mockedSpawn.mockRejectedValue(rejectExit1());
|
||||
const shot = makeShot(0);
|
||||
@@ -826,14 +1516,15 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
expect(mockUploadArtifact).toHaveBeenCalledTimes(30);
|
||||
});
|
||||
|
||||
it('does not harvest or upload screenshots when output_format is not junit', async () => {
|
||||
it('does not harvest or upload screenshots for Maestro CLI HTML', 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();
|
||||
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
|
||||
expect(mockUploadArtifact.mock.calls[0][0].artifact.name).toBe('Maestro HTML Report');
|
||||
});
|
||||
|
||||
it('uploads only the final attempt for a pure-failure flow, but every attempt for a flaky flow', async () => {
|
||||
|
||||
@@ -26,6 +26,7 @@ describe(readIpaInfoAsync, () => {
|
||||
bundleIdentifier: 'dev.expo.SmallestAppExample',
|
||||
bundleShortVersion: '1.0',
|
||||
bundleVersion: '1',
|
||||
dtPlatformName: 'iphoneos',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { isProcessDescendantOfAsync } from '../../../utils/processes';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import {
|
||||
monitorLocalEgressAsync,
|
||||
readLocalEgressHandoffAsync,
|
||||
registerLocalEgressResources,
|
||||
stopLocalEgressResourcesAsync,
|
||||
} from '../../utils/localEgress';
|
||||
import {
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartAgentDeviceRemoteSessionBuildFunction } from '../startAgentDeviceRemoteSession';
|
||||
import { createStartAppiumRemoteSessionBuildFunction } from '../startAppiumRemoteSession';
|
||||
import { createStartArgentRemoteSessionBuildFunction } from '../startArgentRemoteSession';
|
||||
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../../utils/IosSimulatorUtils');
|
||||
jest.mock('../../../utils/processes');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/agentDeviceArtifacts');
|
||||
jest.mock('../../utils/argentArtifacts');
|
||||
jest.mock('../../utils/agentDeviceEvents', () => ({
|
||||
startAgentDeviceEventCollectionAsync: async () => ({ stopAsync: jest.fn() }),
|
||||
}));
|
||||
jest.mock('../../utils/argentEvents', () => ({
|
||||
ARGENT_EVENT_LOG_FILENAME: 'events.jsonl',
|
||||
startArgentEventCollectionAsync: async () => ({ stopAsync: jest.fn() }),
|
||||
}));
|
||||
jest.mock('../../utils/appiumEvents', () => ({
|
||||
startAppiumEventCollectionAsync: async () => ({ stopAsync: jest.fn() }),
|
||||
}));
|
||||
jest.mock('../../utils/localEgress', () => ({
|
||||
...jest.requireActual('../../utils/localEgress'),
|
||||
readLocalEgressHandoffAsync: jest.fn(),
|
||||
monitorLocalEgressAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
getDeviceRunSessionIdOrThrow: () => 'session-id',
|
||||
getNgrokTunnelDomainOrThrow: () => 'example.test',
|
||||
getNgrokAuthtokenOrThrow: () => 'ngrok-token',
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
ensureFfmpegInstalledOnceAsync: jest.fn(),
|
||||
spawnDetached: () => ({ pid: 123, getOutput: () => '', stopAsync: jest.fn() }),
|
||||
waitForFileAsync: async () => ({ port: 1234, token: 'controller-token' }),
|
||||
startNgrokTunnelAsync: async () => ({
|
||||
url: 'https://controller.test',
|
||||
subdomainId: 'controller-id',
|
||||
stopAsync: jest.fn(),
|
||||
}),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
child: jest.fn().mockReturnThis(),
|
||||
} as unknown as bunyan;
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const env = { DEVICE_RUN_SESSION_ID: 'session-id' };
|
||||
const handoff = { url: 'https://egress.test', token: 'secret', fingerprint: 'key=', port: 8899 };
|
||||
const fields = {
|
||||
egressUrl: handoff.url,
|
||||
egressToken: handoff.token,
|
||||
egressFingerprint: handoff.fingerprint,
|
||||
egressPort: handoff.port,
|
||||
};
|
||||
const controllers = [
|
||||
['agent-device', createStartAgentDeviceRemoteSessionBuildFunction, 'agentDeviceRemoteSessionUrl'],
|
||||
['Appium', createStartAppiumRemoteSessionBuildFunction, 'appiumUrl'],
|
||||
['Argent', createStartArgentRemoteSessionBuildFunction, 'toolsUrl'],
|
||||
['web preview', createStartWebPreviewRemoteSessionBuildFunction, 'previewUrl'],
|
||||
] as const;
|
||||
|
||||
describe.each(controllers)('%s local egress', (_name, createFunction, controllerField) => {
|
||||
const stopPreview = jest.fn();
|
||||
const openPreview = jest.fn();
|
||||
const stopEgress = jest.fn();
|
||||
let lifetime: AbortSignal;
|
||||
async function run(signal?: AbortSignal): Promise<void> {
|
||||
await createFunction(ctx).fn!(
|
||||
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env,
|
||||
signal,
|
||||
} as never
|
||||
);
|
||||
}
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
lifetime = registerLocalEgressResources(stopEgress);
|
||||
stopEgress.mockResolvedValue(undefined);
|
||||
stopPreview.mockResolvedValue(undefined);
|
||||
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
jest.spyOn(fs.promises, 'mkdtemp').mockResolvedValue('/tmp/appium-egress-test');
|
||||
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
|
||||
jest.spyOn(fs.promises, 'rm').mockResolvedValue(undefined);
|
||||
jest.spyOn(fs.promises, 'readdir').mockResolvedValue(['tool-server.json'] as never);
|
||||
jest
|
||||
.spyOn(fs.promises, 'readFile')
|
||||
.mockResolvedValue(JSON.stringify({ port: 1234, pid: 124, token: 'controller-token' }));
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: '{}' } as never);
|
||||
jest.mocked(isProcessDescendantOfAsync).mockResolvedValue(true);
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([{ udid: 'sim' }] as never);
|
||||
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as never);
|
||||
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(handoff);
|
||||
jest.mocked(monitorLocalEgressAsync).mockResolvedValue(undefined);
|
||||
openPreview.mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://preview.test',
|
||||
previewToken: 'preview-secret',
|
||||
closeAsync: jest.fn(),
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: openPreview,
|
||||
finishAsync: stopPreview,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => {
|
||||
expect(lifetime.aborted).toBe(false);
|
||||
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
afterEach(async () => {
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('reports credentials and starts monitoring before waiting, then releases resources', async () => {
|
||||
await run();
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
|
||||
ctx,
|
||||
deviceRunSessionId: 'session-id',
|
||||
logger,
|
||||
remoteConfig: expect.objectContaining({
|
||||
...fields,
|
||||
[controllerField]: expect.any(String),
|
||||
[controllerField === 'previewUrl' ? 'previewToken' : 'webPreviewToken']: 'preview-secret',
|
||||
}),
|
||||
});
|
||||
expect(monitorLocalEgressAsync).toHaveBeenCalledWith({
|
||||
port: handoff.port,
|
||||
env,
|
||||
logger,
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(lifetime.aborted).toBe(true);
|
||||
expect(stopEgress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('leaves ordinary remote config unchanged and does not monitor', async () => {
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
stopEgress.mockClear();
|
||||
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(null);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
await run();
|
||||
const { remoteConfig } = jest.mocked(uploadRemoteSessionConfigAsync).mock.calls[0][0];
|
||||
expect(remoteConfig).toHaveProperty(controllerField);
|
||||
expect(Object.keys(remoteConfig).some(key => key.startsWith('egress'))).toBe(false);
|
||||
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
|
||||
expect(stopEgress).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(['startup', 'preview', 'report', 'wait', 'teardown'])(
|
||||
'releases egress after %s fails',
|
||||
async phase => {
|
||||
const error = new Error(`${phase} failed`);
|
||||
if (phase === 'startup') {
|
||||
jest.mocked(startDeviceSessionHostAsync).mockRejectedValue(error);
|
||||
} else if (phase === 'preview') {
|
||||
openPreview.mockRejectedValue(error);
|
||||
} else if (phase === 'report') {
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValue(error);
|
||||
} else if (phase === 'wait') {
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValue(error);
|
||||
} else {
|
||||
stopPreview.mockRejectedValue(error);
|
||||
}
|
||||
await expect(run()).rejects.toThrow(error);
|
||||
expect(lifetime.aborted).toBe(true);
|
||||
expect(stopEgress).toHaveBeenCalledTimes(1);
|
||||
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(
|
||||
phase === 'startup' || phase === 'preview' || phase === 'report' ? 0 : 1
|
||||
);
|
||||
}
|
||||
);
|
||||
it('passes cancellation to the monitor and releases the lifetime', async () => {
|
||||
const abort = new AbortController();
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => abort.abort());
|
||||
await run(abort.signal);
|
||||
expect(monitorLocalEgressAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ signal: abort.signal })
|
||||
);
|
||||
expect(lifetime.aborted).toBe(true);
|
||||
expect(stopEgress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('does not report or start a monitor after cancellation', async () => {
|
||||
const abort = new AbortController();
|
||||
abort.abort();
|
||||
await expect(run(abort.signal)).rejects.toThrow();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
|
||||
expect(stopEgress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import assert from 'node:assert';
|
||||
import fg from 'fast-glob';
|
||||
import { vol } from 'memfs';
|
||||
import path from 'node:path';
|
||||
@@ -193,22 +194,53 @@ describe(resolveIosSigningOptionsAsync, () => {
|
||||
const signingOptions = await resolveIosSigningOptionsAsync({
|
||||
job,
|
||||
logger: mockLogger,
|
||||
tmpDir: '/tmp',
|
||||
});
|
||||
|
||||
expect(signingOptions).not.toBeNull();
|
||||
expect(signingOptions?.keychainPath).toEqual('/tmp/ios_keychain');
|
||||
expect(signingOptions?.signingIdentity).toEqual('Test App Certificate');
|
||||
expect(signingOptions?.provisioningProfile).toEqual({
|
||||
'com.example.testapp': '/tmp/ios_provisioning_profile',
|
||||
expect(signingOptions).toMatchObject({
|
||||
keychainPath: '/tmp/ios_keychain',
|
||||
signingIdentity: 'Test App Certificate',
|
||||
provisioningProfile: {
|
||||
'com.example.testapp': '/tmp/ios_provisioning_profile',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should resolve zsign signing options without touching the keychain', async () => {
|
||||
const job = createTestIosJob();
|
||||
const tmpDir = '/tmp';
|
||||
vol.mkdirSync(tmpDir, { recursive: true });
|
||||
const credentialsManagerSpy = jest.spyOn(IosCredentialsManager.prototype, 'prepare');
|
||||
|
||||
const signingOptions = await resolveIosSigningOptionsAsync({
|
||||
job,
|
||||
logger: mockLogger,
|
||||
backend: 'zsign',
|
||||
tmpDir,
|
||||
});
|
||||
|
||||
expect(credentialsManagerSpy).not.toHaveBeenCalled();
|
||||
expect(signingOptions).toMatchObject({
|
||||
backend: 'zsign',
|
||||
keyPassword: job.secrets?.buildCredentials?.['testapp'].distributionCertificate.password,
|
||||
});
|
||||
assert(signingOptions?.backend === 'zsign');
|
||||
expect(signingOptions.certificatePath).toMatch(/\/tmp\/dist-cert-.*\.p12/);
|
||||
expect(vol.existsSync(signingOptions.certificatePath)).toBe(true);
|
||||
assert(typeof signingOptions.provisioningProfile === 'object');
|
||||
const profilePath = signingOptions.provisioningProfile['testapp'];
|
||||
expect(profilePath).toMatch(/\/tmp\/profile-testapp-.*\.mobileprovision/);
|
||||
expect(vol.existsSync(profilePath)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return undefined if no build credentials are provided', async () => {
|
||||
// @ts-expect-error: createTestIosJob does not support buildCredentials as null
|
||||
const job = createTestIosJob({ buildCredentials: null });
|
||||
const signingOptions = await resolveIosSigningOptionsAsync({
|
||||
job,
|
||||
logger: mockLogger,
|
||||
tmpDir: '/tmp',
|
||||
});
|
||||
expect(signingOptions).toBeUndefined();
|
||||
});
|
||||
@@ -219,6 +251,7 @@ describe(resolveIosSigningOptionsAsync, () => {
|
||||
const signingOptions = await resolveIosSigningOptionsAsync({
|
||||
job,
|
||||
logger: mockLogger,
|
||||
tmpDir: '/tmp',
|
||||
});
|
||||
expect(signingOptions).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import fs from 'fs';
|
||||
import fetch from 'node-fetch';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { compressCacheAsync, uploadCacheAsync } from '../saveCache';
|
||||
import { decompressCacheAsync } from '../restoreCache';
|
||||
|
||||
jest.mock('node-fetch');
|
||||
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
function createLoggerMock(): bunyan {
|
||||
return { info: jest.fn(), warn: jest.fn(), error: jest.fn() } as unknown as bunyan;
|
||||
}
|
||||
|
||||
describe('cache compress/decompress round trip', () => {
|
||||
it('preserves multiple sibling directories archived together', async () => {
|
||||
const logger = createLoggerMock();
|
||||
|
||||
const cachesDir = path.join(os.tmpdir(), 'gradle-caches');
|
||||
const buildCacheDir = path.join(cachesDir, 'build-cache-1');
|
||||
const journalDir = path.join(cachesDir, 'journal-1');
|
||||
await fs.promises.mkdir(buildCacheDir, { recursive: true });
|
||||
await fs.promises.mkdir(journalDir, { recursive: true });
|
||||
await fs.promises.writeFile(path.join(buildCacheDir, 'entry'), 'cache entry');
|
||||
await fs.promises.writeFile(path.join(journalDir, 'file-access.properties'), 'inception=1');
|
||||
|
||||
const { archivePath } = await compressCacheAsync({
|
||||
paths: [buildCacheDir, journalDir],
|
||||
workingDirectory: cachesDir,
|
||||
verbose: false,
|
||||
logger,
|
||||
});
|
||||
|
||||
const restoreDir = path.join(os.tmpdir(), 'gradle-caches-restored');
|
||||
await fs.promises.mkdir(restoreDir, { recursive: true });
|
||||
await decompressCacheAsync({
|
||||
archivePath,
|
||||
workingDirectory: restoreDir,
|
||||
verbose: false,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(
|
||||
await fs.promises.readFile(path.join(restoreDir, 'build-cache-1', 'entry'), 'utf8')
|
||||
).toBe('cache entry');
|
||||
expect(
|
||||
await fs.promises.readFile(
|
||||
path.join(restoreDir, 'journal-1', 'file-access.properties'),
|
||||
'utf8'
|
||||
)
|
||||
).toBe('inception=1');
|
||||
});
|
||||
|
||||
it('preserves file modification times so journal-fallback entries can still age out', async () => {
|
||||
const logger = createLoggerMock();
|
||||
|
||||
const sourceDir = path.join(os.tmpdir(), 'mtime-source');
|
||||
await fs.promises.mkdir(sourceDir, { recursive: true });
|
||||
const filePath = path.join(sourceDir, 'entry');
|
||||
await fs.promises.writeFile(filePath, 'cache entry');
|
||||
|
||||
// A whole-second value in the past — tar stores mtimes at one-second resolution.
|
||||
const mtime = new Date('2026-08-01T00:00:00.000Z');
|
||||
await fs.promises.utimes(filePath, mtime, mtime);
|
||||
|
||||
const { archivePath } = await compressCacheAsync({
|
||||
paths: [sourceDir],
|
||||
workingDirectory: sourceDir,
|
||||
verbose: false,
|
||||
logger,
|
||||
});
|
||||
|
||||
const restoreDir = path.join(os.tmpdir(), 'mtime-restored');
|
||||
await fs.promises.mkdir(restoreDir, { recursive: true });
|
||||
await decompressCacheAsync({
|
||||
archivePath,
|
||||
workingDirectory: restoreDir,
|
||||
verbose: false,
|
||||
logger,
|
||||
});
|
||||
|
||||
const restoredStat = await fs.promises.stat(path.join(restoreDir, 'entry'));
|
||||
expect(Math.floor(restoredStat.mtimeMs / 1000)).toBe(Math.floor(mtime.getTime() / 1000));
|
||||
});
|
||||
});
|
||||
|
||||
describe(uploadCacheAsync, () => {
|
||||
afterEach(() => {
|
||||
jest.mocked(fetch).mockReset();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['normal upload', undefined, false],
|
||||
['forced upload', true, true],
|
||||
] as const)('sends force for %s', async (_name, force, expectedForce) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'upload-cache-test-'));
|
||||
const archivePath = path.join(tempDir, 'cache.tar.gz');
|
||||
await fs.promises.writeFile(archivePath, 'cache archive');
|
||||
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
headers: {},
|
||||
url: 'https://storage.expo.test/cache',
|
||||
},
|
||||
}),
|
||||
{ status: 200 }
|
||||
)
|
||||
)
|
||||
.mockImplementationOnce(async (_url, request) => {
|
||||
const body = request!.body as NodeJS.ReadableStream & AsyncIterable<Buffer>;
|
||||
for await (const _chunk of body) {
|
||||
// Consume the archive stream as a real upload would.
|
||||
}
|
||||
return new Response(undefined, { status: 200 });
|
||||
});
|
||||
|
||||
try {
|
||||
await uploadCacheAsync({
|
||||
logger: createLoggerMock(),
|
||||
jobId: 'build-id',
|
||||
expoApiServerURL: 'https://api.expo.test',
|
||||
robotAccessToken: 'robot-token',
|
||||
paths: ['/cache/path'],
|
||||
key: 'cache-key',
|
||||
archivePath,
|
||||
size: 13,
|
||||
platform: Platform.ANDROID,
|
||||
force,
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
const [uploadSessionUrl, uploadSessionRequest] = jest.mocked(fetch).mock.calls[0];
|
||||
expect(uploadSessionUrl.toString()).toBe(
|
||||
'https://api.expo.test/v2/turtle-builds/caches/upload-sessions'
|
||||
);
|
||||
expect(JSON.parse(uploadSessionRequest!.body as string)).toMatchObject({
|
||||
buildId: 'build-id',
|
||||
force: expectedForce,
|
||||
key: 'cache-key',
|
||||
size: 13,
|
||||
});
|
||||
expect(jest.mocked(fetch).mock.calls[1][0].toString()).toBe(
|
||||
'https://storage.expo.test/cache'
|
||||
);
|
||||
} finally {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type BuildStepContext, type BuildStepEnv } from '@expo/steps';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { getDeviceRunSessionIdOrThrow } from '../../utils/remoteDeviceRunSession';
|
||||
import { uploadServeSimLogsFileAsync } from '../../utils/serveSimLogsArtifacts';
|
||||
import { ServeSimLogsRecorder } from '../../utils/serveSimLogsRecorder';
|
||||
import { createCollectServeSimLogsBuildFunction } from '../collectServeSimLogs';
|
||||
import { createStartServeSimLogsBuildFunction } from '../startServeSimLogs';
|
||||
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../utils/serveSimLogsRecorder');
|
||||
jest.mock('../../utils/serveSimLogsArtifacts');
|
||||
jest.mock('../../utils/remoteDeviceRunSession');
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const step = { logger } as BuildStepContext;
|
||||
const args = { inputs: {}, outputs: {}, env: {} as BuildStepEnv };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(getDeviceRunSessionIdOrThrow).mockReturnValue('session-id');
|
||||
jest.mocked(ServeSimLogsRecorder.finishAsync).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('starts collection and tolerates startup failures', async () => {
|
||||
jest.mocked(ServeSimLogsRecorder.startAsync).mockRejectedValueOnce(new Error('disk failure'));
|
||||
await expect(createStartServeSimLogsBuildFunction().fn?.(step, args)).resolves.toBeUndefined();
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Could not start serve-sim simulator logs',
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads each device with its own bounded signal', async () => {
|
||||
jest.mocked(ServeSimLogsRecorder.finishAsync).mockResolvedValue([
|
||||
{ udid: 'A', filePath: '/tmp/A.ndjson' },
|
||||
{ udid: 'B', filePath: '/tmp/B.ndjson' },
|
||||
]);
|
||||
await createCollectServeSimLogsBuildFunction(ctx).fn?.(step, args);
|
||||
expect(uploadServeSimLogsFileAsync).toHaveBeenCalledTimes(2);
|
||||
expect(uploadServeSimLogsFileAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
deviceRunSessionId: 'session-id',
|
||||
udid: 'A',
|
||||
filePath: '/tmp/A.ndjson',
|
||||
signal: expect.any(AbortSignal),
|
||||
})
|
||||
);
|
||||
expect(jest.mocked(uploadServeSimLogsFileAsync).mock.calls[0][1].signal).not.toBe(
|
||||
jest.mocked(uploadServeSimLogsFileAsync).mock.calls[1][1].signal
|
||||
);
|
||||
});
|
||||
|
||||
it('skips empty collection and warns on finalization failure', async () => {
|
||||
await createCollectServeSimLogsBuildFunction(ctx).fn?.(step, args);
|
||||
expect(uploadServeSimLogsFileAsync).not.toHaveBeenCalled();
|
||||
jest.mocked(ServeSimLogsRecorder.finishAsync).mockRejectedValueOnce(new Error('failure'));
|
||||
await expect(
|
||||
createCollectServeSimLogsBuildFunction(ctx).fn?.(step, args)
|
||||
).resolves.toBeUndefined();
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Could not finalize serve-sim simulator logs',
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
+427
@@ -0,0 +1,427 @@
|
||||
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { pollAgentDeviceArtifactsForUploadAsync } from '../../utils/agentDeviceArtifacts';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../../utils/agentDeviceEvents';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
waitForFileAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { type StartupTasks, createStartupTasks } from '../../utils/startupTasks';
|
||||
import {
|
||||
createStartAgentDeviceRemoteSessionBuildFunction,
|
||||
runAgentDeviceRemoteSessionAsync,
|
||||
} from '../startAgentDeviceRemoteSession';
|
||||
|
||||
// The daemon entry path and the state directory are resolved from the home directory when
|
||||
// the module loads, so point it at a temp home we can populate.
|
||||
jest.mock('node:os', () => {
|
||||
const actual = jest.requireActual('node:os');
|
||||
const actualPath = jest.requireActual('node:path');
|
||||
return {
|
||||
...actual,
|
||||
homedir: () => actualPath.join(actual.tmpdir(), 'eas-agent-device-orchestration-home'),
|
||||
};
|
||||
});
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../utils/agentDeviceArtifacts', () => ({
|
||||
pollAgentDeviceArtifactsForUploadAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/agentDeviceEvents', () => ({
|
||||
startAgentDeviceEventCollectionAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
getDeviceRunSessionIdOrThrow: jest.fn(),
|
||||
getNgrokAuthtokenOrThrow: jest.fn(),
|
||||
getNgrokTunnelDomainOrThrow: jest.fn(),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
spawnDetached: jest.fn(),
|
||||
startNgrokTunnelAsync: jest.fn(),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
waitForFileAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const TEST_HOME = path.join(os.tmpdir(), 'eas-agent-device-orchestration-home');
|
||||
const DAEMON_ENTRY_PATH = path.join(
|
||||
TEST_HOME,
|
||||
'.bun/install/global/node_modules/agent-device/dist/src/internal/daemon.js'
|
||||
);
|
||||
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const mockPreviewStopAsync = jest.fn();
|
||||
const mockTunnelStopAsync = jest.fn();
|
||||
const mockDaemonStopAsync = jest.fn();
|
||||
const mockEventCollectionStopAsync = jest.fn();
|
||||
|
||||
async function runAsync(
|
||||
logger: { info: jest.Mock; warn: jest.Mock },
|
||||
runtimePlatform: BuildRuntimePlatform,
|
||||
launchInputs: Record<string, { value: unknown }> = {}
|
||||
): Promise<void> {
|
||||
const buildFunction = createStartAgentDeviceRemoteSessionBuildFunction(ctx);
|
||||
await buildFunction.fn!(
|
||||
{ logger, global: { runtimePlatform } } as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
max_duration_seconds: { value: undefined },
|
||||
...launchInputs,
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
}
|
||||
|
||||
describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () => {
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
jest.mocked(spawn).mockResolvedValue(undefined as never);
|
||||
jest.mocked(pollAgentDeviceArtifactsForUploadAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(startAgentDeviceEventCollectionAsync).mockResolvedValue({
|
||||
stopAsync: mockEventCollectionStopAsync,
|
||||
getLastEventObservedAt: () => undefined,
|
||||
});
|
||||
jest.mocked(getDeviceRunSessionIdOrThrow).mockReturnValue('device-run-session-id');
|
||||
jest.mocked(getNgrokTunnelDomainOrThrow).mockReturnValue('tunnel.example.com');
|
||||
jest.mocked(getNgrokAuthtokenOrThrow).mockReturnValue('ngrok-token');
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 4242,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: mockDaemonStopAsync,
|
||||
});
|
||||
jest.mocked(waitForFileAsync).mockResolvedValue({ port: 5678, token: 'daemon-token' });
|
||||
jest.mocked(startNgrokTunnelAsync).mockResolvedValue({
|
||||
url: 'https://agent-device-abc.tunnel.example.com',
|
||||
subdomainId: 'agent-device-abc',
|
||||
stopAsync: mockTunnelStopAsync,
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: jest.fn().mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.tunnel.example.com',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
finishAsync: mockPreviewStopAsync,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
|
||||
await fs.promises.mkdir(path.dirname(DAEMON_ENTRY_PATH), { recursive: true });
|
||||
await fs.promises.writeFile(DAEMON_ENTRY_PATH, '');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(TEST_HOME, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reports the preview URL and tears every resource down', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
|
||||
await runAsync(logger, BuildRuntimePlatform.LINUX);
|
||||
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
|
||||
);
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: expect.objectContaining({
|
||||
agentDeviceRemoteSessionUrl: 'https://agent-device-abc.tunnel.example.com',
|
||||
agentDeviceRemoteSessionToken: 'daemon-token',
|
||||
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
previewApiUrl: 'https://web-preview.tunnel.example.com',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockEventCollectionStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('hands the launch inputs to serve-sim and announces them on an iOS session', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
|
||||
await runAsync(logger, BuildRuntimePlatform.DARWIN, {
|
||||
launch_app_identifier: { value: 'host.exp.Exponent' },
|
||||
launch_args: { value: ['-EXDevMenuIsOnboardingFinished', '1'] },
|
||||
open_url: { value: 'exp://127.0.0.1:8081' },
|
||||
});
|
||||
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
|
||||
openUrl: 'exp://127.0.0.1:8081',
|
||||
})
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'serve-sim will launch host.exp.Exponent with arguments ' +
|
||||
'["-EXDevMenuIsOnboardingFinished","1"], then open exp://127.0.0.1:8081.'
|
||||
);
|
||||
});
|
||||
|
||||
it('hands network capture to serve-sim on an iOS session', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
|
||||
await runAsync(logger, BuildRuntimePlatform.DARWIN, {
|
||||
network_capture: { value: true },
|
||||
network_capture_fields: { value: ['header'] },
|
||||
});
|
||||
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ networkCapture: true, networkCaptureFields: ['header'] })
|
||||
);
|
||||
});
|
||||
|
||||
it('fails before starting the daemon when network capture is asked for on Android', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
|
||||
await expect(
|
||||
runAsync(logger, BuildRuntimePlatform.LINUX, { network_capture: { value: true } })
|
||||
).rejects.toThrow('records traffic through serve-sim on an iOS simulator');
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails before starting the daemon when a launch is asked for on Android', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
|
||||
await expect(
|
||||
runAsync(logger, BuildRuntimePlatform.LINUX, {
|
||||
launch_app_identifier: { value: 'host.exp.Exponent' },
|
||||
})
|
||||
).rejects.toThrow('runs on linux');
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts the session host without waiting for the agent-device daemon', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const sessionHost = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
|
||||
ctx,
|
||||
{} as never
|
||||
);
|
||||
let markHostStarted!: () => void;
|
||||
const hostStarted = new Promise<void>(resolve => {
|
||||
markHostStarted = resolve;
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementation(async () => {
|
||||
markHostStarted();
|
||||
return sessionHost;
|
||||
});
|
||||
// The daemon credentials only appear after the session host started. A sequential
|
||||
// startup would wait here forever.
|
||||
jest.mocked(waitForFileAsync).mockImplementation(async () => {
|
||||
await hostStarted;
|
||||
return { port: 5678, token: 'daemon-token' };
|
||||
});
|
||||
|
||||
await runAsync(logger, BuildRuntimePlatform.DARWIN);
|
||||
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: expect.objectContaining({
|
||||
agentDeviceRemoteSessionUrl: 'https://agent-device-abc.tunnel.example.com',
|
||||
agentDeviceRemoteSessionToken: 'daemon-token',
|
||||
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('stops the daemon and its tunnel when the session host fails to start', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
jest
|
||||
.mocked(startDeviceSessionHostAsync)
|
||||
.mockRejectedValue(new Error('serve-sim did not start'));
|
||||
|
||||
await expect(runAsync(logger, BuildRuntimePlatform.DARWIN)).rejects.toThrow(
|
||||
'serve-sim did not start'
|
||||
);
|
||||
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
// The failure aborts the daemon task, which then may not start its daemon or tunnel.
|
||||
// Whatever started is stopped.
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(jest.mocked(spawnDetached).mock.calls.length);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(
|
||||
jest.mocked(startNgrokTunnelAsync).mock.calls.length
|
||||
);
|
||||
});
|
||||
|
||||
it('stops the session host and the daemon when the daemon credentials never appear', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
jest.mocked(waitForFileAsync).mockRejectedValue(new Error('no daemon credentials'));
|
||||
|
||||
await expect(runAsync(logger, BuildRuntimePlatform.DARWIN)).rejects.toThrow(
|
||||
'no daemon credentials'
|
||||
);
|
||||
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('runAgentDeviceRemoteSessionAsync with a device that is still starting', () => {
|
||||
function deferred(): {
|
||||
promise: Promise<void>;
|
||||
resolve: () => void;
|
||||
reject: (e: Error) => void;
|
||||
} {
|
||||
let resolve!: () => void;
|
||||
let reject!: (e: Error) => void;
|
||||
const promise = new Promise<void>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
promise.catch(() => {});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
type Device = { booted: Promise<unknown>; ready: Promise<unknown> };
|
||||
function startSession(device: Device | ((tasks: StartupTasks) => Device)) {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
child: jest.fn().mockReturnThis(),
|
||||
} as never;
|
||||
const tasks = createStartupTasks(logger);
|
||||
return runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env: {},
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
sessionEnv: {
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
ngrokTunnelDomain: 'tunnel.example.com',
|
||||
ngrokAuthtoken: 'ngrok-token',
|
||||
},
|
||||
packageVersion: undefined,
|
||||
maxIdleTimeMinutes: undefined,
|
||||
maxDurationSeconds: undefined,
|
||||
launch: {},
|
||||
capture: { networkCapture: false, networkCaptureFields: [] },
|
||||
tasks,
|
||||
device: typeof device === 'function' ? device(tasks) : device,
|
||||
});
|
||||
}
|
||||
|
||||
async function flushAsync(): Promise<void> {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
it('starts the daemon during the boot and the session host after it', async () => {
|
||||
const booted = deferred();
|
||||
const session = startSession({ booted: booted.promise, ready: booted.promise });
|
||||
await flushAsync();
|
||||
|
||||
expect(spawnDetached).toHaveBeenCalledTimes(1);
|
||||
expect(startNgrokTunnelAsync).toHaveBeenCalledTimes(1);
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
|
||||
booted.resolve();
|
||||
await session;
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports the session as ready only after the app is launched', async () => {
|
||||
const ready = deferred();
|
||||
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
|
||||
await flushAsync();
|
||||
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
|
||||
ready.resolve();
|
||||
await session;
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops waiting for the boot when the daemon fails', async () => {
|
||||
jest.mocked(waitForFileAsync).mockRejectedValue(new Error('no daemon credentials'));
|
||||
const neverBooted = new Promise<void>(() => {});
|
||||
|
||||
// In the combined step, `ready` stops waiting for the boot on an abort, like this.
|
||||
await expect(
|
||||
startSession(tasks => ({ booted: neverBooted, ready: tasks.untilAborted(neverBooted) }))
|
||||
).rejects.toThrow('no daemon credentials');
|
||||
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops the session host when the app fails while the daemon install hangs', async () => {
|
||||
jest.mocked(spawn).mockImplementation(((
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: { signal?: AbortSignal }
|
||||
) =>
|
||||
command === 'bun' && args[0] === 'add'
|
||||
? new Promise((_resolve, reject) => {
|
||||
options!.signal!.addEventListener('abort', () => reject(options!.signal!.reason));
|
||||
})
|
||||
: Promise.resolve(undefined)) as never);
|
||||
const ready = deferred();
|
||||
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
|
||||
await flushAsync();
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
|
||||
|
||||
ready.reject(new Error('simctl install failed'));
|
||||
|
||||
await expect(session).rejects.toThrow('simctl install failed');
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops everything it started when the app install fails', async () => {
|
||||
const ready = deferred();
|
||||
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
|
||||
await flushAsync();
|
||||
ready.reject(new Error('simctl install failed'));
|
||||
|
||||
await expect(session).rejects.toThrow('simctl install failed');
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('declares the launch inputs', () => {
|
||||
const buildFunction = createStartAgentDeviceRemoteSessionBuildFunction(ctx);
|
||||
const globalCtx = createGlobalContextMock();
|
||||
|
||||
expect(
|
||||
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
|
||||
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
|
||||
});
|
||||
});
|
||||
+229
-2
@@ -1,9 +1,38 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { stopAgentDeviceEventCollectionSafelyAsync } from '../startAgentDeviceRemoteSession';
|
||||
import { spawnDetached } from '../../utils/remoteDeviceRunSession';
|
||||
import {
|
||||
startAgentDeviceDaemonAsync,
|
||||
stopAgentDeviceEventCollectionSafelyAsync,
|
||||
} from '../startAgentDeviceRemoteSession';
|
||||
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
spawnDetached: jest.fn(),
|
||||
}));
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
|
||||
async function writeDaemonEntry(cwd: string): Promise<string> {
|
||||
const daemonPath = path.join(
|
||||
cwd,
|
||||
'node_modules',
|
||||
'agent-device',
|
||||
'dist',
|
||||
'src',
|
||||
'internal',
|
||||
'daemon.js'
|
||||
);
|
||||
await fs.promises.mkdir(path.dirname(daemonPath), { recursive: true });
|
||||
await fs.promises.writeFile(daemonPath, '');
|
||||
return daemonPath;
|
||||
}
|
||||
|
||||
describe(stopAgentDeviceEventCollectionSafelyAsync, () => {
|
||||
beforeEach(() => {
|
||||
@@ -12,7 +41,6 @@ describe(stopAgentDeviceEventCollectionSafelyAsync, () => {
|
||||
|
||||
it('reports an unexpected stop failure without rejecting', async () => {
|
||||
const error = new Error('stop failed');
|
||||
const logger = { warn: jest.fn() } as unknown as bunyan;
|
||||
|
||||
await expect(
|
||||
stopAgentDeviceEventCollectionSafelyAsync({
|
||||
@@ -37,3 +65,202 @@ describe(stopAgentDeviceEventCollectionSafelyAsync, () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(startAgentDeviceDaemonAsync, () => {
|
||||
const stopAsync = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
stopAsync.mockResolvedValue(undefined);
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 1234,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync,
|
||||
});
|
||||
jest.mocked(spawn).mockImplementation((async (
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: { cwd?: string }
|
||||
) => {
|
||||
if (command === 'bun' && args[0] === '--version') {
|
||||
return { stdout: '1.3.11\n' };
|
||||
}
|
||||
if (typeof options?.cwd === 'string' && command !== 'git') {
|
||||
await writeDaemonEntry(options.cwd);
|
||||
}
|
||||
return { stdout: '' };
|
||||
}) as never);
|
||||
});
|
||||
|
||||
it('installs with bun add by default and launches the published daemon', async () => {
|
||||
const handle = await startAgentDeviceDaemonAsync({
|
||||
packageVersion: '1.2.3',
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'bun',
|
||||
['add', '[email protected]'],
|
||||
expect.objectContaining({ cwd: expect.stringContaining('eas-agent-device-') })
|
||||
);
|
||||
const addCwd = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
|
||||
expect(spawnDetached).toHaveBeenCalledWith({
|
||||
command: 'node',
|
||||
args: [path.join(addCwd, 'node_modules/agent-device/dist/src/internal/daemon.js')],
|
||||
env: expect.objectContaining({
|
||||
AGENT_DEVICE_DAEMON_SERVER_MODE: 'http',
|
||||
AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0',
|
||||
AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS: '0',
|
||||
AGENT_DEVICE_SESSION_IDLE_TIMEOUT_MS: '0',
|
||||
}),
|
||||
});
|
||||
|
||||
await handle.stopAsync();
|
||||
await expect(fs.promises.access(addCwd)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('installs with npm when EAS_OVERRIDE_PACKAGE_MANAGER is npm', async () => {
|
||||
await startAgentDeviceDaemonAsync({
|
||||
packageVersion: undefined,
|
||||
env: { EAS_OVERRIDE_PACKAGE_MANAGER: 'npm' },
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'npm',
|
||||
['install', '--no-audit', 'agent-device@latest'],
|
||||
expect.objectContaining({ cwd: expect.stringContaining('eas-agent-device-') })
|
||||
);
|
||||
});
|
||||
|
||||
it('kills the install and does not fall back to git when aborted', async () => {
|
||||
jest.mocked(spawn).mockImplementation(
|
||||
((_command: string, _args: string[], options?: { signal?: AbortSignal }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
options!.signal!.addEventListener('abort', () => reject(options!.signal!.reason));
|
||||
})) as never
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const failure = new Error('app install failed');
|
||||
|
||||
const daemon = startAgentDeviceDaemonAsync({
|
||||
packageVersion: '1.2.3',
|
||||
env: {},
|
||||
logger,
|
||||
signal: controller.signal,
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
controller.abort(failure);
|
||||
|
||||
await expect(daemon).rejects.toBe(failure);
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'bun',
|
||||
['add', '[email protected]'],
|
||||
expect.objectContaining({ signal: controller.signal })
|
||||
);
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to git clone when the published daemon is missing', async () => {
|
||||
jest.mocked(spawn).mockImplementation((async (command: string, args: string[]) => {
|
||||
if (command === 'bun' && args[0] === '--version') {
|
||||
return { stdout: '1.3.11\n' };
|
||||
}
|
||||
return { stdout: '' };
|
||||
}) as never);
|
||||
|
||||
await startAgentDeviceDaemonAsync({
|
||||
packageVersion: '1.2.3',
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'git',
|
||||
[
|
||||
'clone',
|
||||
'--depth',
|
||||
'1',
|
||||
'--branch',
|
||||
'v1.2.3',
|
||||
'https://github.com/callstack/agent-device.git',
|
||||
'/tmp/agent-device-src',
|
||||
],
|
||||
expect.objectContaining({ env: {}, logger })
|
||||
);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'bun',
|
||||
['install', '--production'],
|
||||
expect.objectContaining({ cwd: '/tmp/agent-device-src' })
|
||||
);
|
||||
expect(spawnDetached).toHaveBeenCalledWith({
|
||||
command: 'bun',
|
||||
args: ['run', 'src/daemon.ts'],
|
||||
cwd: '/tmp/agent-device-src',
|
||||
env: expect.objectContaining({
|
||||
AGENT_DEVICE_DAEMON_SERVER_MODE: 'http',
|
||||
AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0',
|
||||
AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS: '0',
|
||||
AGENT_DEVICE_SESSION_IDLE_TIMEOUT_MS: '0',
|
||||
}),
|
||||
});
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Failed to start agent-device daemon from the configured package manager; falling back to git clone',
|
||||
expect.any(Error),
|
||||
expect.objectContaining({
|
||||
extras: expect.objectContaining({
|
||||
packageSpec: '[email protected]',
|
||||
packageManager: 'bun',
|
||||
bunVersion: '1.3.11',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('clones latest and reports unknown bun when diagnostics fail', async () => {
|
||||
jest.mocked(spawn).mockImplementation((async (command: string, args: string[]) => {
|
||||
if (command === 'bun' && args[0] === '--version') {
|
||||
throw new Error('bun missing');
|
||||
}
|
||||
return { stdout: '' };
|
||||
}) as never);
|
||||
|
||||
await startAgentDeviceDaemonAsync({
|
||||
packageVersion: undefined,
|
||||
env: { EAS_OVERRIDE_PACKAGE_MANAGER: 'npm' },
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'git',
|
||||
[
|
||||
'clone',
|
||||
'--depth',
|
||||
'1',
|
||||
'https://github.com/callstack/agent-device.git',
|
||||
'/tmp/agent-device-src',
|
||||
],
|
||||
expect.objectContaining({ logger })
|
||||
);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'npm',
|
||||
['install', '--no-audit', '--omit=dev'],
|
||||
expect.objectContaining({ cwd: '/tmp/agent-device-src' })
|
||||
);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Failed to start agent-device daemon from the configured package manager; falling back to git clone',
|
||||
expect.any(Error),
|
||||
expect.objectContaining({
|
||||
extras: expect.objectContaining({
|
||||
packageVersion: 'latest',
|
||||
packageManager: 'npm',
|
||||
bunVersion: 'unknown',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,400 @@
|
||||
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession';
|
||||
import { downloadBuildAsync } from '../downloadBuild';
|
||||
import { installBuildAsync } from '../installBuild';
|
||||
import { launchApplicationAsync } from '../launchApplication';
|
||||
import {
|
||||
getAgentDeviceRemoteSessionEnvOrThrow,
|
||||
runAgentDeviceRemoteSessionAsync,
|
||||
} from '../startAgentDeviceRemoteSession';
|
||||
import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceSession';
|
||||
import { startAndroidEmulatorAsync } from '../startAndroidEmulator';
|
||||
import { bootIosSimulatorAsync } from '../startIosSimulator';
|
||||
|
||||
jest.mock('../../utils/localEgressSession', () => ({
|
||||
withLocalEgressSession: (fn: unknown) => fn,
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../startIosSimulator', () => ({ bootIosSimulatorAsync: jest.fn() }));
|
||||
jest.mock('../startAndroidEmulator', () => ({ startAndroidEmulatorAsync: jest.fn() }));
|
||||
jest.mock('../downloadBuild', () => ({ downloadBuildAsync: jest.fn() }));
|
||||
jest.mock('../installBuild', () => ({ installBuildAsync: jest.fn() }));
|
||||
jest.mock('../launchApplication', () => ({
|
||||
...jest.requireActual('../launchApplication'),
|
||||
launchApplicationAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../startAgentDeviceRemoteSession', () => ({
|
||||
getAgentDeviceRemoteSessionEnvOrThrow: jest.fn(),
|
||||
runAgentDeviceRemoteSessionAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const graphqlClient = { query: jest.fn() };
|
||||
const ctx = { graphqlClient } as unknown as CustomBuildContext;
|
||||
const sessionEnv = {
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
ngrokTunnelDomain: 'tunnel.example.com',
|
||||
ngrokAuthtoken: 'ngrok-token',
|
||||
};
|
||||
|
||||
type Device = { booted: Promise<unknown>; ready: Promise<unknown> };
|
||||
|
||||
function deferred<T = void>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (err: Error) => void;
|
||||
} {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (err: Error) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
promise.catch(() => {});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function flushAsync(): Promise<void> {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
function runStep(
|
||||
runtimePlatform: BuildRuntimePlatform,
|
||||
inputValues: Record<string, unknown> = {}
|
||||
): Promise<void> {
|
||||
const buildFunction = createStartAgentDeviceSessionBuildFunction(ctx);
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const inputs = Object.fromEntries(
|
||||
[
|
||||
'device_identifier',
|
||||
'system_image_package',
|
||||
'lcd_width',
|
||||
'lcd_height',
|
||||
'lcd_density',
|
||||
'build_id',
|
||||
'application_archive_url',
|
||||
'launch_args',
|
||||
'open_url',
|
||||
'network_capture',
|
||||
'network_capture_fields',
|
||||
'package_version',
|
||||
'max_idle_time_minutes',
|
||||
'max_duration_seconds',
|
||||
].map(id => [id, { value: inputValues[id] }])
|
||||
);
|
||||
return buildFunction.fn!(
|
||||
{
|
||||
logger,
|
||||
global: {
|
||||
runtimePlatform,
|
||||
staticContext: { job: { secrets: { robotAccessToken: 'robot-token' } } },
|
||||
},
|
||||
} as unknown as BuildStepContext,
|
||||
{ inputs, outputs: {}, env: {}, signal: undefined } as never
|
||||
) as Promise<void>;
|
||||
}
|
||||
|
||||
/** The `device` promises handed to the shared agent-device session code. */
|
||||
function sessionDevice(): Device {
|
||||
return jest.mocked(runAgentDeviceRemoteSessionAsync).mock.calls[0][1].device;
|
||||
}
|
||||
|
||||
describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(getAgentDeviceRemoteSessionEnvOrThrow).mockReturnValue(sessionEnv);
|
||||
jest.mocked(runAgentDeviceRemoteSessionAsync).mockImplementation(async (_ctx, { device }) => {
|
||||
await device.ready;
|
||||
});
|
||||
jest.mocked(bootIosSimulatorAsync).mockResolvedValue({
|
||||
deviceIdentifier: 'iPhone 17' as never,
|
||||
udid: 'udid' as never,
|
||||
displayName: 'iPhone 17',
|
||||
});
|
||||
jest.mocked(startAndroidEmulatorAsync).mockResolvedValue({
|
||||
serialId: 'emulator-5554' as never,
|
||||
emulatorPromise: Promise.resolve(),
|
||||
shouldAdjustAnimationScale: true,
|
||||
});
|
||||
jest.mocked(downloadBuildAsync).mockResolvedValue({ artifactPath: '/tmp/App.app' });
|
||||
jest
|
||||
.mocked(installBuildAsync)
|
||||
.mockResolvedValue({ applicationIdentifier: 'dev.example.app', activityName: '.Main' });
|
||||
jest.mocked(launchApplicationAsync).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('downloads the build and starts the session while the iOS Simulator boots', async () => {
|
||||
const boot = deferred<Awaited<ReturnType<typeof bootIosSimulatorAsync>>>();
|
||||
jest.mocked(bootIosSimulatorAsync).mockReturnValue(boot.promise);
|
||||
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, {
|
||||
build_id: 'build-id',
|
||||
launch_args: ['-flag'],
|
||||
open_url: 'exp://example.test',
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
expect(
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
|
||||
).toBeLessThan(jest.mocked(bootIosSimulatorAsync).mock.invocationCallOrder[0]);
|
||||
expect(downloadBuildAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
buildId: 'build-id',
|
||||
extensions: ['app'],
|
||||
graphqlClient,
|
||||
robotAccessToken: 'robot-token',
|
||||
})
|
||||
);
|
||||
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledTimes(1);
|
||||
// The install needs the booted Simulator.
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
|
||||
boot.resolve({
|
||||
deviceIdentifier: 'iPhone 17' as never,
|
||||
udid: 'udid' as never,
|
||||
displayName: '',
|
||||
});
|
||||
await step;
|
||||
|
||||
expect(installBuildAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ artifactPath: '/tmp/App.app' })
|
||||
);
|
||||
expect(launchApplicationAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
applicationIdentifier: 'dev.example.app',
|
||||
launchArgs: ['-flag'],
|
||||
openUrl: 'exp://example.test',
|
||||
})
|
||||
);
|
||||
expect(jest.mocked(installBuildAsync).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
jest.mocked(launchApplicationAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('waits for the download when the Simulator boots first', async () => {
|
||||
const download = deferred<{ artifactPath: string }>();
|
||||
jest.mocked(downloadBuildAsync).mockReturnValue(download.promise);
|
||||
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, {
|
||||
application_archive_url: 'https://example.test/app.tar.gz',
|
||||
});
|
||||
await flushAsync();
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
|
||||
download.resolve({ artifactPath: '/tmp/App.app' });
|
||||
await step;
|
||||
expect(installBuildAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('makes the session ready when the device boots if there is no app', async () => {
|
||||
await runStep(BuildRuntimePlatform.DARWIN);
|
||||
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
await expect(sessionDevice().ready).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('boots the Android Emulator with the device inputs', async () => {
|
||||
await runStep(BuildRuntimePlatform.LINUX, {
|
||||
device_identifier: 'pixel_7',
|
||||
system_image_package: 'system-images;android-35-ext15;google_apis_playstore;x86_64',
|
||||
lcd_width: 720,
|
||||
lcd_height: 1600,
|
||||
lcd_density: 300,
|
||||
build_id: 'build-id',
|
||||
});
|
||||
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
expect(startAndroidEmulatorAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
deviceIdentifier: 'pixel_7',
|
||||
systemImagePackage: 'system-images;android-35-ext15;google_apis_playstore;x86_64',
|
||||
lcdWidth: 720,
|
||||
lcdHeight: 1600,
|
||||
lcdDensity: 300,
|
||||
})
|
||||
);
|
||||
expect(downloadBuildAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ extensions: ['apk'] })
|
||||
);
|
||||
expect(launchApplicationAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ applicationIdentifier: 'dev.example.app', activityName: '.Main' })
|
||||
);
|
||||
});
|
||||
|
||||
it('fails the session when the download fails, without an unhandled rejection', async () => {
|
||||
const boot = deferred<Awaited<ReturnType<typeof bootIosSimulatorAsync>>>();
|
||||
jest.mocked(bootIosSimulatorAsync).mockReturnValue(boot.promise);
|
||||
jest.mocked(downloadBuildAsync).mockRejectedValue(new Error('download failed'));
|
||||
|
||||
const stepFailure = expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })
|
||||
).rejects.toThrow('download failed');
|
||||
// The download fails while the boot still runs. Nothing inside the step may leave
|
||||
// that rejection unhandled; Jest fails the test if it does.
|
||||
await flushAsync();
|
||||
boot.resolve({
|
||||
deviceIdentifier: 'iPhone 17' as never,
|
||||
udid: 'udid' as never,
|
||||
displayName: '',
|
||||
});
|
||||
|
||||
await stepFailure;
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/** A download that runs until its abort signal fires, like a stalled one. */
|
||||
function mockStalledDownload(): { aborted: () => boolean; settled: () => boolean } {
|
||||
let aborted = false;
|
||||
let settled = false;
|
||||
jest.mocked(downloadBuildAsync).mockImplementation(
|
||||
({ signal }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
signal!.addEventListener('abort', () => {
|
||||
aborted = true;
|
||||
// Settles a bit later, like a request that winds down.
|
||||
setImmediate(() => {
|
||||
settled = true;
|
||||
reject(new Error('The user aborted a request.'));
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
return { aborted: () => aborted, settled: () => settled };
|
||||
}
|
||||
|
||||
/** Runs the session like the real one: fails with the daemon, then waits for every part. */
|
||||
function mockSessionWithFailingDaemon(beforeFailure?: () => Promise<void>): void {
|
||||
jest
|
||||
.mocked(runAgentDeviceRemoteSessionAsync)
|
||||
.mockImplementation(async (_ctx, { tasks, device }) => {
|
||||
await beforeFailure?.();
|
||||
const daemon = tasks.run('agent-device daemon', async () => {
|
||||
throw new Error('daemon failed');
|
||||
});
|
||||
try {
|
||||
await Promise.all([daemon, device.ready]);
|
||||
} finally {
|
||||
await Promise.allSettled([daemon, device.ready]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it('stops the download and waits for it when the boot fails', async () => {
|
||||
const download = mockStalledDownload();
|
||||
jest.mocked(bootIosSimulatorAsync).mockRejectedValue(new Error('boot failed'));
|
||||
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'boot failed'
|
||||
);
|
||||
|
||||
expect(download.aborted()).toBe(true);
|
||||
// The step returned only after the download stopped.
|
||||
expect(download.settled()).toBe(true);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops a stalled download and does not install when the daemon fails', async () => {
|
||||
const download = mockStalledDownload();
|
||||
mockSessionWithFailingDaemon();
|
||||
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'daemon failed'
|
||||
);
|
||||
|
||||
expect(download.aborted()).toBe(true);
|
||||
expect(download.settled()).toBe(true);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not launch the app when startup fails during the install', async () => {
|
||||
const install = deferred<Awaited<ReturnType<typeof installBuildAsync>>>();
|
||||
const installStarted = deferred();
|
||||
jest.mocked(installBuildAsync).mockImplementation(() => {
|
||||
installStarted.resolve();
|
||||
return install.promise;
|
||||
});
|
||||
mockSessionWithFailingDaemon(async () => {
|
||||
await installStarted.promise;
|
||||
// The install finishes only after the daemon failed.
|
||||
setImmediate(() => install.resolve({ applicationIdentifier: 'dev.example.app' }));
|
||||
});
|
||||
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'daemon failed'
|
||||
);
|
||||
|
||||
expect(installBuildAsync).toHaveBeenCalledTimes(1);
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects conflicting application inputs before it boots anything', async () => {
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, {
|
||||
build_id: 'build-id',
|
||||
application_archive_url: 'https://example.test/app.tar.gz',
|
||||
})
|
||||
).rejects.toThrow('Pass only one of build_id or application_archive_url.');
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, { open_url: 'exp://example.test' })
|
||||
).rejects.toThrow('launch_args and open_url need an application');
|
||||
|
||||
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('declares the step inputs', () => {
|
||||
const buildFunction = createStartAgentDeviceSessionBuildFunction(ctx);
|
||||
const globalCtx = createGlobalContextMock();
|
||||
|
||||
expect(
|
||||
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
|
||||
).toEqual([
|
||||
'device_identifier',
|
||||
'system_image_package',
|
||||
'lcd_width',
|
||||
'lcd_height',
|
||||
'lcd_density',
|
||||
'build_id',
|
||||
'application_archive_url',
|
||||
'launch_args',
|
||||
'open_url',
|
||||
'network_capture',
|
||||
'network_capture_fields',
|
||||
'package_version',
|
||||
'max_idle_time_minutes',
|
||||
'max_duration_seconds',
|
||||
]);
|
||||
});
|
||||
|
||||
it('passes network capture to the session', async () => {
|
||||
await runStep(BuildRuntimePlatform.DARWIN, {
|
||||
network_capture: true,
|
||||
network_capture_fields: ['header', 'response-body'],
|
||||
});
|
||||
|
||||
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
capture: { networkCapture: true, networkCaptureFields: ['header', 'response-body'] },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects network capture on Android before it boots anything', async () => {
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.LINUX, { network_capture: true, system_image_package: 'x' })
|
||||
).rejects.toThrow('records traffic through serve-sim on an iOS simulator');
|
||||
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../../../utils/AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../../../utils/retry';
|
||||
import { resolveAndroidEmulatorLocalEgressAsync } from '../../utils/androidLocalEgress';
|
||||
import { createStartAndroidEmulatorBuildFunction } from '../startAndroidEmulator';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
@@ -18,6 +20,10 @@ jest.mock('../../../utils/retry', () => ({
|
||||
retryAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../utils/androidLocalEgress', () => ({
|
||||
resolveAndroidEmulatorLocalEgressAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
|
||||
AndroidEmulatorUtils: {
|
||||
defaultSystemImagePackage: 'system-images;android-30;default;x86_64',
|
||||
@@ -34,6 +40,8 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedRetryAsync = jest.mocked(retryAsync);
|
||||
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
|
||||
const mockedResolveLocalEgress = jest.mocked(resolveAndroidEmulatorLocalEgressAsync);
|
||||
const mockedMkdtemp = jest.spyOn(fs.promises, 'mkdtemp');
|
||||
function createStep(callInputs?: Record<string, unknown>, envOverrides?: NodeJS.ProcessEnv) {
|
||||
const logger = createMockLogger();
|
||||
const fn = createStartAndroidEmulatorBuildFunction();
|
||||
@@ -54,6 +62,7 @@ function createStartResult(serialId: string) {
|
||||
|
||||
describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
mockedMkdtemp.mockResolvedValue('/tmp/logcat-directory');
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
mockedAndroidUtils.getAvailableDevicesAsync.mockResolvedValue([]);
|
||||
mockedAndroidUtils.createAsync.mockResolvedValue(undefined);
|
||||
@@ -62,6 +71,7 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
mockedAndroidUtils.waitForReadyAsync.mockResolvedValue(undefined);
|
||||
mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync.mockResolvedValue(undefined);
|
||||
mockedAndroidUtils.deleteAsync.mockResolvedValue(undefined);
|
||||
mockedResolveLocalEgress.mockResolvedValue(null);
|
||||
|
||||
mockedRetryAsync.mockImplementation(async (fn, { retryOptions }) => {
|
||||
let lastErr: unknown;
|
||||
@@ -76,6 +86,111 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
mockedMkdtemp.mockRestore();
|
||||
});
|
||||
|
||||
describe('local egress', () => {
|
||||
function createLocalEgress() {
|
||||
return {
|
||||
launchGate: {
|
||||
emulatorArgs: ['-http-proxy', '127.0.0.1:8898', '-no-metrics'],
|
||||
wrapperCommand: 'bash',
|
||||
wrapperArgs: ['-c', 'gate', 'eas-egress-gate'],
|
||||
admitAsync: jest.fn(),
|
||||
},
|
||||
networkReadyTarget: { host: '192.0.2.1', port: 443 },
|
||||
configureBootedEmulatorAsync: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
it('starts the emulator exactly as before without a local egress session', async () => {
|
||||
await createStep().executeAsync();
|
||||
|
||||
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith({
|
||||
deviceName: 'EasAndroidDevice01',
|
||||
env: expect.any(Object),
|
||||
logcatDirectory: '/tmp/logcat-directory',
|
||||
});
|
||||
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenCalledWith({
|
||||
env: expect.any(Object),
|
||||
serialId: 'emulator-default',
|
||||
timeoutMs: 60_000,
|
||||
logger: expect.anything(),
|
||||
});
|
||||
expect(
|
||||
mockedSpawn.mock.calls.filter(
|
||||
([command, args]) =>
|
||||
['sudo', 'nft', 'systemd-run'].includes(command) ||
|
||||
(command === 'adb' && (args.includes('reverse') || args.includes('http_proxy')))
|
||||
)
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('fences, gates and configures the emulator', async () => {
|
||||
const localEgress = createLocalEgress();
|
||||
mockedResolveLocalEgress.mockResolvedValue(localEgress);
|
||||
mockedAndroidUtils.startAsync.mockResolvedValueOnce(createStartResult('emulator-base'));
|
||||
|
||||
await createStep().executeAsync();
|
||||
|
||||
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ launchGate: localEgress.launchGate })
|
||||
);
|
||||
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ networkReadyTarget: { host: '192.0.2.1', port: 443 } })
|
||||
);
|
||||
expect(localEgress.configureBootedEmulatorAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ serialId: 'emulator-base' })
|
||||
);
|
||||
});
|
||||
|
||||
it('retries a boot whose local egress configuration fails', async () => {
|
||||
const localEgress = createLocalEgress();
|
||||
localEgress.configureBootedEmulatorAsync
|
||||
.mockRejectedValueOnce(new Error('netsimd outside the fence'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
mockedResolveLocalEgress.mockResolvedValue(localEgress);
|
||||
mockedAndroidUtils.startAsync
|
||||
.mockResolvedValueOnce(createStartResult('emulator-1111'))
|
||||
.mockResolvedValueOnce(createStartResult('emulator-2222'));
|
||||
|
||||
await createStep().executeAsync();
|
||||
|
||||
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ serialId: 'emulator-1111' })
|
||||
);
|
||||
expect(localEgress.configureBootedEmulatorAsync).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ serialId: 'emulator-2222' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('passes profile and LCD inputs to emulator creation', async () => {
|
||||
const systemImagePackage = 'system-images;android-35;default;x86_64';
|
||||
await createStep({
|
||||
device_identifier: 'medium_phone',
|
||||
system_image_package: systemImagePackage,
|
||||
lcd_width: 720,
|
||||
lcd_height: 1600,
|
||||
lcd_density: 262,
|
||||
}).executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('sdkmanager', [systemImagePackage], expect.anything());
|
||||
expect(mockedAndroidUtils.createAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
deviceIdentifier: 'medium_phone',
|
||||
systemImagePackage,
|
||||
lcdWidth: 720,
|
||||
lcdHeight: 1600,
|
||||
lcdDensity: 262,
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.createAsync.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockedAndroidUtils.startAsync.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('retries base emulator startup with increasing readiness timeouts', async () => {
|
||||
mockedAndroidUtils.startAsync
|
||||
.mockResolvedValueOnce(createStartResult('emulator-1111'))
|
||||
@@ -106,6 +221,20 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
serialId: 'emulator-2222',
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.startAsync).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
deviceName: 'EasAndroidDevice01',
|
||||
logcatDirectory: '/tmp/logcat-directory',
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.startAsync).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
deviceName: 'EasAndroidDevice01',
|
||||
logcatDirectory: '/tmp/logcat-directory',
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-1111',
|
||||
@@ -174,6 +303,24 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
serialId: 'emulator-clone-2-attempt-1',
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
deviceName: 'EasAndroidDevice01',
|
||||
logcatDirectory: '/tmp/logcat-directory',
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
deviceName: 'eas-simulator-1',
|
||||
logcatDirectory: '/tmp/logcat-directory',
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
deviceName: 'eas-simulator-2',
|
||||
logcatDirectory: '/tmp/logcat-directory',
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -200,6 +347,24 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledTimes(3);
|
||||
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('outputs and uses a temporary logcat directory', async () => {
|
||||
const step = createStep();
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
logcatDirectory: '/tmp/logcat-directory',
|
||||
})
|
||||
);
|
||||
expect(step.getOutputValueByName('logcat_directory')).toBe('/tmp/logcat-directory');
|
||||
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-default',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('skips animation scale adjustments when opt out env var is disabled', async () => {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession';
|
||||
|
||||
import {
|
||||
createStartAppiumRemoteSessionBuildFunction,
|
||||
installAppiumAsync,
|
||||
resolveAppium3VersionSpec,
|
||||
resolveAppiumDeviceAsync,
|
||||
} from '../startAppiumRemoteSession';
|
||||
|
||||
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
|
||||
AndroidEmulatorUtils: { getAttachedDevicesAsync: jest.fn() },
|
||||
}));
|
||||
jest.mock('../../../utils/IosSimulatorUtils', () => ({
|
||||
IosSimulatorUtils: { getAvailableDevicesAsync: jest.fn() },
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as never;
|
||||
|
||||
describe(resolveAppium3VersionSpec, () => {
|
||||
it('uses the worker-supported Appium 3 version by default', () => {
|
||||
expect(resolveAppium3VersionSpec(undefined)).toBe('^3');
|
||||
});
|
||||
|
||||
it.each(['3', '^3', '3.x', '3.5.0', '>=3 <4'])('accepts Appium 3 version %s', version => {
|
||||
expect(resolveAppium3VersionSpec(version)).toBe(version);
|
||||
});
|
||||
|
||||
it.each(['2', '2.19.0', 'latest', '4.0.0', '>=3'])('rejects version %s', version => {
|
||||
expect(() => resolveAppium3VersionSpec(version)).toThrow(
|
||||
`Appium 3 is required for EAS Simulator sessions. Received package version "${version}".`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveAppiumDeviceAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns XCUITest capabilities for the booted iOS simulator', async () => {
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([{ udid: 'ios-simulator-id' } as never]);
|
||||
|
||||
await expect(
|
||||
resolveAppiumDeviceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: {},
|
||||
logger,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
platformName: 'iOS',
|
||||
automationName: 'XCUITest',
|
||||
driverName: 'xcuitest',
|
||||
udid: 'ios-simulator-id',
|
||||
});
|
||||
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledWith({ env: {}, logger });
|
||||
expect(IosSimulatorUtils.getAvailableDevicesAsync).toHaveBeenCalledWith({
|
||||
env: {},
|
||||
filter: 'booted',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns UiAutomator2 capabilities for the booted Android emulator', async () => {
|
||||
jest
|
||||
.mocked(AndroidEmulatorUtils.getAttachedDevicesAsync)
|
||||
.mockResolvedValue([{ serialId: 'emulator-5554', state: 'device' } as never]);
|
||||
|
||||
await expect(
|
||||
resolveAppiumDeviceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
platformName: 'Android',
|
||||
automationName: 'UiAutomator2',
|
||||
driverName: 'uiautomator2',
|
||||
udid: 'emulator-5554',
|
||||
});
|
||||
expect(AndroidEmulatorUtils.getAttachedDevicesAsync).toHaveBeenCalledWith({ env: {} });
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe(installAppiumAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => {
|
||||
if (args.includes('--json')) {
|
||||
return { stdout: JSON.stringify({}) };
|
||||
}
|
||||
return { stdout: '' };
|
||||
}) as never);
|
||||
});
|
||||
|
||||
it('installs Appium with npm by default', async () => {
|
||||
const result = await installAppiumAsync({
|
||||
versionSpec: '^3',
|
||||
driverName: 'xcuitest',
|
||||
env: { EXISTING: 'value' },
|
||||
logger,
|
||||
});
|
||||
|
||||
try {
|
||||
expect(spawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'npm',
|
||||
['install', '--no-audit', 'appium@^3'],
|
||||
expect.objectContaining({
|
||||
cwd: result.appiumHome,
|
||||
env: expect.objectContaining({ APPIUM_HOME: result.appiumHome, EXISTING: 'value' }),
|
||||
})
|
||||
);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
result.appiumBinPath,
|
||||
['driver', 'install', 'xcuitest'],
|
||||
expect.objectContaining({ env: result.appiumEnv, logger })
|
||||
);
|
||||
} finally {
|
||||
await fs.promises.rm(result.appiumHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('installs Appium with bun add when EAS_OVERRIDE_PACKAGE_MANAGER is bun', async () => {
|
||||
const result = await installAppiumAsync({
|
||||
versionSpec: '3.5.0',
|
||||
driverName: 'uiautomator2',
|
||||
env: { EAS_OVERRIDE_PACKAGE_MANAGER: 'bun' },
|
||||
logger,
|
||||
});
|
||||
|
||||
try {
|
||||
expect(spawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'bun',
|
||||
['add', '[email protected]'],
|
||||
expect.objectContaining({ cwd: result.appiumHome })
|
||||
);
|
||||
} finally {
|
||||
await fs.promises.rm(result.appiumHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('createStartAppiumRemoteSessionBuildFunction', () => {
|
||||
it('declares the launch inputs so serve-sim can launch the app', () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const buildFunction = createStartAppiumRemoteSessionBuildFunction(ctx);
|
||||
const globalCtx = createGlobalContextMock();
|
||||
|
||||
expect(
|
||||
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
|
||||
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
|
||||
});
|
||||
});
|
||||
+248
-8
@@ -4,11 +4,14 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import { isProcessDescendantOfAsync } from '../../../utils/processes';
|
||||
import { pollArgentArtifactsForUploadAsync } from '../../utils/argentArtifacts';
|
||||
import { startArgentEventCollectionAsync } from '../../utils/argentEvents';
|
||||
import {
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
@@ -39,15 +42,18 @@ jest.mock('../../utils/argentEvents', () => ({
|
||||
...jest.requireActual('../../utils/argentEvents'),
|
||||
startArgentEventCollectionAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
ensureFfmpegInstalledAsync: jest.fn(),
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
ensureFfmpegInstalledOnceAsync: jest.fn(),
|
||||
finishRemoteSessionAsync: jest.requireActual('../../utils/remoteDeviceRunSession')
|
||||
.finishRemoteSessionAsync,
|
||||
getDeviceRunSessionIdOrThrow: jest.fn(),
|
||||
getNgrokAuthtokenOrThrow: jest.fn(),
|
||||
getNgrokTunnelDomainOrThrow: jest.fn(),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
spawnDetached: jest.fn(),
|
||||
startNgrokTunnelAsync: jest.fn(),
|
||||
startServeSimWithTunnelAsync: jest.fn(),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
}));
|
||||
@@ -58,6 +64,7 @@ const EXPECTED_EVENT_LOG_PATH = path.join(ARGENT_STATE_DIR, 'tool-server-events.
|
||||
|
||||
const mockStopAsync = jest.fn();
|
||||
const mockTunnelStopAsync = jest.fn();
|
||||
const mockPreviewStopAsync = jest.fn();
|
||||
|
||||
describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
beforeEach(async () => {
|
||||
@@ -68,7 +75,11 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
jest.mocked(pollArgentArtifactsForUploadAsync).mockResolvedValue(undefined);
|
||||
mockStopAsync.mockResolvedValue(undefined);
|
||||
mockTunnelStopAsync.mockResolvedValue(undefined);
|
||||
jest.mocked(startArgentEventCollectionAsync).mockResolvedValue({ stopAsync: mockStopAsync });
|
||||
mockPreviewStopAsync.mockResolvedValue(undefined);
|
||||
jest.mocked(startArgentEventCollectionAsync).mockResolvedValue({
|
||||
stopAsync: mockStopAsync,
|
||||
getLastEventObservedAt: () => undefined,
|
||||
});
|
||||
|
||||
jest.mocked(getDeviceRunSessionIdOrThrow).mockReturnValue('device-run-session-id');
|
||||
jest.mocked(getNgrokTunnelDomainOrThrow).mockReturnValue('tunnel.example.com');
|
||||
@@ -77,12 +88,22 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 4242,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: jest.fn(),
|
||||
});
|
||||
jest.mocked(startNgrokTunnelAsync).mockResolvedValue({
|
||||
url: 'https://argent-abc.tunnel.example.com',
|
||||
subdomainId: 'argent-abc',
|
||||
stopAsync: mockTunnelStopAsync,
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: jest.fn().mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.tunnel.example.com',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
finishAsync: mockPreviewStopAsync,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
|
||||
@@ -98,6 +119,75 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
await fs.promises.rm(TEST_HOME, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reports an early exit with output before opening tunnels or publishing readiness', async () => {
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 4242,
|
||||
getOutput: () => 'could not bind server port',
|
||||
getExitError: () => new Error('process exited with code 1'),
|
||||
stopAsync: jest.fn(),
|
||||
});
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
await expect(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
).rejects.toThrow(
|
||||
'Argent exited before becoming ready: process exited with code 1\nArgent tool-server output:\ncould not bind server port'
|
||||
);
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
expect(startArgentEventCollectionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['preview', 'config', 'wait'])(
|
||||
'finishes the host and tools after %s fails',
|
||||
async phase => {
|
||||
const error = new Error(`${phase} failed`);
|
||||
if (phase === 'preview') {
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
|
||||
openPreviewAsync: jest.fn().mockRejectedValue(error),
|
||||
finishAsync: mockPreviewStopAsync,
|
||||
});
|
||||
} else if (phase === 'config') {
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValueOnce(error);
|
||||
} else {
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValueOnce(error);
|
||||
}
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
await expect(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
).rejects.toBe(error);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(jest.mocked(spawnDetached).mock.results[0].value.stopAsync).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('enables the event log flag, shares one path, and starts/stops the collector', async () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
|
||||
@@ -108,33 +198,50 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: { package_version: { value: undefined } },
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: { EXISTING: 'value' },
|
||||
} as never
|
||||
);
|
||||
|
||||
// (1) The event log flag is enabled, before the tool-server is launched.
|
||||
// (1) FFmpeg setup starts in the background before Argent setup.
|
||||
expect(ensureFfmpegInstalledOnceAsync).toHaveBeenCalledWith({
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: { EXISTING: 'value' },
|
||||
logger: expect.anything(),
|
||||
});
|
||||
expect(jest.mocked(ensureFfmpegInstalledOnceAsync).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
jest.mocked(spawn).mock.invocationCallOrder[0]
|
||||
);
|
||||
|
||||
// (2) The event log flag is enabled, before the tool-server is launched.
|
||||
const spawnCalls = jest.mocked(spawn).mock.calls;
|
||||
const enableEventLogIndex = spawnCalls.findIndex(
|
||||
([command, args]) =>
|
||||
command === 'bunx' && Array.isArray(args) && args.includes('tool-server-event-log')
|
||||
command === 'bun' &&
|
||||
Array.isArray(args) &&
|
||||
args[0] === 'x' &&
|
||||
args.includes('tool-server-event-log')
|
||||
);
|
||||
expect(enableEventLogIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(jest.mocked(spawn).mock.invocationCallOrder[enableEventLogIndex]).toBeLessThan(
|
||||
jest.mocked(spawnDetached).mock.invocationCallOrder[0]
|
||||
);
|
||||
|
||||
// (2) The tool-server and the collector are pinned to the exact same event log path.
|
||||
// (3) The tool-server and the collector are pinned to the exact same event log path.
|
||||
const serverEnv = jest.mocked(spawnDetached).mock.calls[0][0].env;
|
||||
expect(serverEnv.ARGENT_EVENT_LOG).toBe(EXPECTED_EVENT_LOG_PATH);
|
||||
expect(serverEnv.ARGENT_EMULATOR_NO_WINDOW).toBe('1');
|
||||
expect(serverEnv.EXISTING).toBe('value');
|
||||
expect(jest.mocked(startArgentEventCollectionAsync).mock.calls[0][0]).toMatchObject({
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
eventLogPath: EXPECTED_EVENT_LOG_PATH,
|
||||
});
|
||||
|
||||
// (3) Collection starts (before we wait for the session to stop) and (4) is torn down
|
||||
// (4) Collection starts (before we wait for the session to stop) and (5) is torn down
|
||||
// afterwards, exactly once.
|
||||
expect(startArgentEventCollectionAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockStopAsync).toHaveBeenCalledTimes(1);
|
||||
@@ -145,5 +252,138 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
|
||||
);
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: expect.objectContaining({
|
||||
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
previewApiUrl: 'https://web-preview.tunnel.example.com',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('hands the launch inputs to serve-sim and announces them on an iOS session', async () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
|
||||
|
||||
await buildFunction.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
max_duration_seconds: { value: undefined },
|
||||
launch_app_identifier: { value: 'host.exp.Exponent' },
|
||||
launch_args: { value: ['-EXDevMenuIsOnboardingFinished', '1'] },
|
||||
open_url: { value: 'exp://127.0.0.1:8081' },
|
||||
},
|
||||
outputs: {},
|
||||
env: { EXISTING: 'value' },
|
||||
} as never
|
||||
);
|
||||
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
|
||||
openUrl: 'exp://127.0.0.1:8081',
|
||||
})
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'serve-sim will launch host.exp.Exponent with arguments ' +
|
||||
'["-EXDevMenuIsOnboardingFinished","1"], then open exp://127.0.0.1:8081.'
|
||||
);
|
||||
});
|
||||
|
||||
it('fails before starting anything when a launch is asked for on Android', async () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
|
||||
|
||||
await expect(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
max_duration_seconds: { value: undefined },
|
||||
launch_app_identifier: { value: 'host.exp.Exponent' },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
).rejects.toThrow('runs on linux');
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('declares the launch inputs', () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
|
||||
const globalCtx = createGlobalContextMock();
|
||||
|
||||
expect(
|
||||
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
|
||||
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
|
||||
});
|
||||
|
||||
it('stops automation without waiting for recording upload', async () => {
|
||||
let release!: () => void;
|
||||
let stopped!: () => void;
|
||||
const pendingFinish = new Promise<void>(resolve => {
|
||||
release = resolve;
|
||||
});
|
||||
const toolStopped = new Promise<void>(resolve => {
|
||||
stopped = resolve;
|
||||
});
|
||||
mockPreviewStopAsync.mockReturnValueOnce(pendingFinish);
|
||||
const stopServer = jest.fn(async () => {
|
||||
stopped();
|
||||
});
|
||||
jest.mocked(spawnDetached).mockReturnValueOnce({
|
||||
pid: 4242,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: stopServer,
|
||||
});
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
const running = buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
try {
|
||||
await toolStopped;
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
release();
|
||||
await running;
|
||||
}
|
||||
});
|
||||
});
|
||||
+54
-1
@@ -74,7 +74,7 @@ describe(warnIfArgentPackageVersionCannotBeVerified, () => {
|
||||
).not.toThrow();
|
||||
expect(warn).toHaveBeenCalledTimes(2);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Continuing and letting bunx resolve it.')
|
||||
expect.stringContaining('Continuing and letting the package manager resolve it.')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -94,6 +94,59 @@ describe(waitForArgentToolServerStateAsync, () => {
|
||||
return JSON.stringify({ port, pid, token });
|
||||
}
|
||||
|
||||
it('reports process exit instead of waiting for the startup timeout', async () => {
|
||||
const getExitError = jest.fn(() => new Error('server exited with code 1'));
|
||||
await expect(
|
||||
waitForArgentToolServerStateAsync({
|
||||
stateDir,
|
||||
ancestorPid: process.pid,
|
||||
timeoutMs: 100,
|
||||
pollIntervalMs: 1,
|
||||
getExitError,
|
||||
})
|
||||
).rejects.toThrow('server exited with code 1');
|
||||
expect(getExitError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('detects an exit after polling has begun', async () => {
|
||||
const getExitError = jest
|
||||
.fn<Error | undefined, []>()
|
||||
.mockReturnValueOnce(undefined)
|
||||
.mockReturnValueOnce(undefined)
|
||||
.mockReturnValue(new Error('server stopped'));
|
||||
await expect(
|
||||
waitForArgentToolServerStateAsync({
|
||||
stateDir,
|
||||
ancestorPid: process.pid,
|
||||
timeoutMs: 100,
|
||||
pollIntervalMs: 1,
|
||||
getExitError,
|
||||
})
|
||||
).rejects.toThrow('server stopped');
|
||||
expect(getExitError).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('rejects an exit observed while reading a matching state file', async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(stateDir, 'tool-server.json'),
|
||||
stateJson({ port: 4321, pid: process.pid })
|
||||
);
|
||||
const getExitError = jest
|
||||
.fn<Error | undefined, []>()
|
||||
.mockReturnValueOnce(undefined)
|
||||
.mockReturnValue(new Error('server exited during startup'));
|
||||
await expect(
|
||||
waitForArgentToolServerStateAsync({
|
||||
stateDir,
|
||||
ancestorPid: process.ppid,
|
||||
timeoutMs: 100,
|
||||
pollIntervalMs: 1,
|
||||
getExitError,
|
||||
})
|
||||
).rejects.toThrow('server exited during startup');
|
||||
expect(getExitError).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each(['tool-server.json', 'tool-server-012345abcdef.json'])(
|
||||
'reads a matching process from %s',
|
||||
async fileName => {
|
||||
|
||||
@@ -3,6 +3,12 @@ import spawn from '@expo/turtle-spawn';
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { configureSimulatorProxyEnvironmentAsync } from '../../utils/localEgress';
|
||||
import {
|
||||
installLocalEgressGuardAsync,
|
||||
resolveLocalEgressBootEnvironmentAsync,
|
||||
verifyLocalEgressGuardAsync,
|
||||
} from '../../utils/localEgressGuard';
|
||||
import { createStartIosSimulatorBuildFunction } from '../startIosSimulator';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
@@ -16,14 +22,36 @@ jest.mock('../../../utils/IosSimulatorUtils', () => ({
|
||||
getDeviceAsync: jest.fn(),
|
||||
cloneAsync: jest.fn(),
|
||||
enableAccessibilitySettingsAsync: jest.fn(),
|
||||
resolveUdidAsync: jest.fn(),
|
||||
bootAsync: jest.fn(),
|
||||
startAsync: jest.fn(),
|
||||
waitForReadyAsync: jest.fn(),
|
||||
disableApsdAsync: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../utils/localEgress', () => ({
|
||||
configureSimulatorProxyEnvironmentAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/localEgressGuard', () => ({
|
||||
installLocalEgressGuardAsync: jest.fn(),
|
||||
resolveLocalEgressBootEnvironmentAsync: jest.fn(),
|
||||
verifyLocalEgressGuardAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedUtils = jest.mocked(IosSimulatorUtils);
|
||||
const mockedConfigureProxyEnvironment = jest.mocked(configureSimulatorProxyEnvironmentAsync);
|
||||
const mockedInstallGuard = jest.mocked(installLocalEgressGuardAsync);
|
||||
const mockedVerifyGuard = jest.mocked(verifyLocalEgressGuardAsync);
|
||||
const mockedResolveBootEnvironment = jest.mocked(resolveLocalEgressBootEnvironmentAsync);
|
||||
|
||||
// Names resolve to udids the way the step expects; udids pass through.
|
||||
const UDIDS: Record<string, string> = {
|
||||
'iPhone 15': 'base',
|
||||
'eas-simulator-1': 'clone-1',
|
||||
'eas-simulator-2': 'clone-2',
|
||||
};
|
||||
|
||||
function createStep(callInputs?: Record<string, unknown>) {
|
||||
const logger = createMockLogger();
|
||||
@@ -41,27 +69,41 @@ describe(createStartIosSimulatorBuildFunction, () => {
|
||||
mockedUtils.getDeviceAsync.mockResolvedValue(null);
|
||||
mockedUtils.cloneAsync.mockResolvedValue(undefined);
|
||||
mockedUtils.enableAccessibilitySettingsAsync.mockResolvedValue(undefined);
|
||||
mockedUtils.startAsync.mockResolvedValue({ udid: 'test-udid' as any });
|
||||
mockedUtils.resolveUdidAsync.mockImplementation(
|
||||
async ({ deviceIdentifier }) => (UDIDS[deviceIdentifier] ?? deviceIdentifier) as any
|
||||
);
|
||||
mockedUtils.bootAsync.mockResolvedValue(undefined);
|
||||
mockedUtils.startAsync.mockImplementation(async ({ deviceIdentifier }) => ({
|
||||
udid: deviceIdentifier as any,
|
||||
}));
|
||||
mockedUtils.waitForReadyAsync.mockResolvedValue(undefined);
|
||||
mockedUtils.disableApsdAsync.mockResolvedValue(undefined);
|
||||
mockedConfigureProxyEnvironment.mockResolvedValue(false);
|
||||
mockedInstallGuard.mockResolvedValue(false);
|
||||
mockedVerifyGuard.mockResolvedValue(undefined);
|
||||
mockedResolveBootEnvironment.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it('does not enable accessibility settings by default', async () => {
|
||||
await createStep({ device_identifier: 'iPhone 15' }).executeAsync();
|
||||
|
||||
expect(mockedUtils.enableAccessibilitySettingsAsync).not.toHaveBeenCalled();
|
||||
expect(mockedUtils.startAsync).toHaveBeenCalledWith({
|
||||
expect(mockedUtils.resolveUdidAsync).toHaveBeenCalledWith({
|
||||
deviceIdentifier: 'iPhone 15',
|
||||
env: expect.any(Object),
|
||||
});
|
||||
expect(mockedUtils.bootAsync).toHaveBeenCalledWith({
|
||||
deviceIdentifier: 'base',
|
||||
env: expect.any(Object),
|
||||
launchdEnvironment: {},
|
||||
});
|
||||
expect(mockedUtils.startAsync).toHaveBeenCalledWith({
|
||||
deviceIdentifier: 'base',
|
||||
env: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
it('enables accessibility settings before starting the main device and every clone when requested', async () => {
|
||||
mockedUtils.startAsync
|
||||
.mockResolvedValueOnce({ udid: 'base' as any })
|
||||
.mockResolvedValueOnce({ udid: 'clone-1' as any })
|
||||
.mockResolvedValueOnce({ udid: 'clone-2' as any });
|
||||
|
||||
await createStep({
|
||||
device_identifier: 'iPhone 15',
|
||||
count: 2,
|
||||
@@ -89,11 +131,6 @@ describe(createStartIosSimulatorBuildFunction, () => {
|
||||
});
|
||||
|
||||
it('disables apsd on the main device and every clone', async () => {
|
||||
mockedUtils.startAsync
|
||||
.mockResolvedValueOnce({ udid: 'base' as any })
|
||||
.mockResolvedValueOnce({ udid: 'clone-1' as any })
|
||||
.mockResolvedValueOnce({ udid: 'clone-2' as any });
|
||||
|
||||
await createStep({ device_identifier: 'iPhone 15', count: 2 }).executeAsync();
|
||||
|
||||
expect(mockedUtils.disableApsdAsync).toHaveBeenCalledWith({
|
||||
@@ -110,6 +147,84 @@ describe(createStartIosSimulatorBuildFunction, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('installs the proxy environment and guard between boot and boot completion, then verifies the guard', async () => {
|
||||
mockedInstallGuard.mockResolvedValue(true);
|
||||
|
||||
await createStep({ device_identifier: 'iPhone 15', count: 2 }).executeAsync();
|
||||
|
||||
const udids = ['base', 'clone-1', 'clone-2'];
|
||||
expect(
|
||||
mockedUtils.bootAsync.mock.calls.map(([{ deviceIdentifier }]) => deviceIdentifier)
|
||||
).toEqual(udids);
|
||||
expect(mockedConfigureProxyEnvironment.mock.calls.map(([{ udid }]) => udid)).toEqual(udids);
|
||||
expect(mockedInstallGuard.mock.calls.map(([{ udid }]) => udid)).toEqual(udids);
|
||||
expect(mockedVerifyGuard.mock.calls.map(([{ udid }]) => udid)).toEqual(udids);
|
||||
for (const [callIndex] of udids.entries()) {
|
||||
const bootOrder = mockedUtils.bootAsync.mock.invocationCallOrder[callIndex];
|
||||
const configureOrder = mockedConfigureProxyEnvironment.mock.invocationCallOrder[callIndex];
|
||||
const guardOrder = mockedInstallGuard.mock.invocationCallOrder[callIndex];
|
||||
const bootCompleteOrder = mockedUtils.startAsync.mock.invocationCallOrder[callIndex];
|
||||
const verifyOrder = mockedVerifyGuard.mock.invocationCallOrder[callIndex];
|
||||
const readyOrder = mockedUtils.waitForReadyAsync.mock.invocationCallOrder[callIndex];
|
||||
// launchd is up when boot returns and has spawned nothing yet: that is the
|
||||
// only moment at which its environment reaches every process of the boot.
|
||||
expect(guardOrder).toBeGreaterThan(bootOrder);
|
||||
expect(configureOrder).toBeGreaterThan(guardOrder);
|
||||
expect(configureOrder).toBeLessThan(bootCompleteOrder);
|
||||
// The self-check needs a completed boot and runs before anything else.
|
||||
expect(verifyOrder).toBeGreaterThan(bootCompleteOrder);
|
||||
expect(verifyOrder).toBeLessThan(readyOrder);
|
||||
}
|
||||
});
|
||||
|
||||
it('boots with the local egress environment so the first processes inherit it', async () => {
|
||||
mockedResolveBootEnvironment.mockResolvedValue({
|
||||
DYLD_INSERT_LIBRARIES: '/w/bin/egress-guard.dylib',
|
||||
https_proxy: 'http://127.0.0.1:8899',
|
||||
});
|
||||
|
||||
await createStep({ device_identifier: 'iPhone 15', count: 2 }).executeAsync();
|
||||
|
||||
for (const [{ deviceIdentifier, launchdEnvironment }] of mockedUtils.bootAsync.mock.calls) {
|
||||
expect(['base', 'clone-1', 'clone-2']).toContain(deviceIdentifier);
|
||||
expect(launchdEnvironment).toEqual({
|
||||
DYLD_INSERT_LIBRARIES: '/w/bin/egress-guard.dylib',
|
||||
https_proxy: 'http://127.0.0.1:8899',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('boots with an empty launchd environment when no local egress session is active', async () => {
|
||||
await createStep({ device_identifier: 'iPhone 15' }).executeAsync();
|
||||
|
||||
expect(mockedUtils.bootAsync).toHaveBeenCalledWith({
|
||||
deviceIdentifier: 'base',
|
||||
env: expect.any(Object),
|
||||
launchdEnvironment: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('skips the guard verification when no local egress session is active', async () => {
|
||||
mockedInstallGuard.mockResolvedValue(false);
|
||||
|
||||
await createStep({ device_identifier: 'iPhone 15' }).executeAsync();
|
||||
|
||||
expect(mockedVerifyGuard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails the step when the guard cannot be installed or verified', async () => {
|
||||
mockedInstallGuard.mockRejectedValueOnce(new Error('guard library missing'));
|
||||
await expect(createStep({ device_identifier: 'iPhone 15' }).executeAsync()).rejects.toThrow(
|
||||
'guard library missing'
|
||||
);
|
||||
|
||||
mockedInstallGuard.mockResolvedValue(true);
|
||||
mockedVerifyGuard.mockRejectedValueOnce(new Error('self-check failed'));
|
||||
await expect(createStep({ device_identifier: 'iPhone 15' }).executeAsync()).rejects.toThrow(
|
||||
'self-check failed'
|
||||
);
|
||||
});
|
||||
|
||||
it('continues when disabling apsd fails', async () => {
|
||||
mockedUtils.disableApsdAsync.mockRejectedValue(new Error('apsd disable failed'));
|
||||
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { startAndroidLocalEgressAsync } from '../../utils/androidLocalEgress';
|
||||
import {
|
||||
LOCAL_EGRESS_HANDOFF_PATH,
|
||||
configureSystemProxyAsync,
|
||||
downloadChiselAsync,
|
||||
startChiselServerAsync,
|
||||
stopLocalEgressResourcesAsync,
|
||||
writeLocalEgressHandoffAsync,
|
||||
} from '../../utils/localEgress';
|
||||
import { findAvailablePortAsync, startNgrokTunnelAsync } from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartLocalEgressBuildFunction } from '../startLocalEgress';
|
||||
|
||||
jest.mock('../../utils/localEgress', () => ({
|
||||
...jest.requireActual('../../utils/localEgress'),
|
||||
configureSystemProxyAsync: jest.fn(),
|
||||
downloadChiselAsync: jest.fn(),
|
||||
startChiselServerAsync: jest.fn(),
|
||||
writeLocalEgressHandoffAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/androidLocalEgress', () => ({
|
||||
startAndroidLocalEgressAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/localEgressGuard', () => ({
|
||||
stopLocalEgressGuardRelaysAsync: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
findAvailablePortAsync: jest.fn(),
|
||||
startNgrokTunnelAsync: jest.fn(),
|
||||
getNgrokAuthtokenOrThrow: () => 'ngrok-token',
|
||||
getNgrokTunnelDomainOrThrow: () => 'example.com',
|
||||
}));
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const server = {
|
||||
pid: 12345,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: jest.fn(),
|
||||
};
|
||||
const tunnel = {
|
||||
url: 'https://egress.example.com',
|
||||
subdomainId: 'egress-id',
|
||||
stopAsync: jest.fn(),
|
||||
};
|
||||
|
||||
async function start(signal?: AbortSignal): Promise<void> {
|
||||
await createStartLocalEgressBuildFunction().fn!({ logger } as BuildStepContext, {
|
||||
env: {},
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
async function startOn(runtimePlatform: BuildRuntimePlatform): Promise<void> {
|
||||
await createStartLocalEgressBuildFunction().fn!(
|
||||
{ logger, global: { runtimePlatform } } as unknown as BuildStepContext,
|
||||
{
|
||||
env: {},
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
} as any
|
||||
);
|
||||
}
|
||||
|
||||
describe('local egress platforms', () => {
|
||||
const androidStop = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(fs.promises, 'mkdtemp').mockResolvedValue('/tmp/egress-acquisition-test');
|
||||
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
|
||||
jest.spyOn(fs.promises, 'rm').mockResolvedValue(undefined);
|
||||
server.stopAsync.mockResolvedValue(undefined);
|
||||
tunnel.stopAsync.mockResolvedValue(undefined);
|
||||
androidStop.mockResolvedValue(undefined);
|
||||
jest.mocked(findAvailablePortAsync).mockResolvedValue(52001);
|
||||
jest.mocked(downloadChiselAsync).mockResolvedValue('/tmp/egress-acquisition-test/chisel');
|
||||
jest.mocked(startChiselServerAsync).mockResolvedValue({ process: server, fingerprint: 'key=' });
|
||||
jest.mocked(startNgrokTunnelAsync).mockResolvedValue(tunnel);
|
||||
jest.mocked(configureSystemProxyAsync).mockResolvedValue({ service: 'Ethernet' });
|
||||
jest.mocked(writeLocalEgressHandoffAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(startAndroidLocalEgressAsync).mockResolvedValue({ stopAsync: androidStop });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('sets the macOS system proxy and writes the same handoff as before', async () => {
|
||||
await startOn(BuildRuntimePlatform.DARWIN);
|
||||
expect(configureSystemProxyAsync).toHaveBeenCalled();
|
||||
expect(startAndroidLocalEgressAsync).not.toHaveBeenCalled();
|
||||
expect(writeLocalEgressHandoffAsync).toHaveBeenCalledWith({
|
||||
url: tunnel.url,
|
||||
token: expect.any(String),
|
||||
fingerprint: 'key=',
|
||||
port: 8899,
|
||||
});
|
||||
});
|
||||
|
||||
it('starts the Android relay and fence on Linux instead of the system proxy', async () => {
|
||||
await startOn(BuildRuntimePlatform.LINUX);
|
||||
expect(configureSystemProxyAsync).not.toHaveBeenCalled();
|
||||
expect(startAndroidLocalEgressAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
chiselPid: server.pid,
|
||||
proxyPort: 8899,
|
||||
controlPort: 52001,
|
||||
workDir: '/tmp/egress-acquisition-test',
|
||||
})
|
||||
);
|
||||
expect(writeLocalEgressHandoffAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ platform: 'android' })
|
||||
);
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
expect(androidStop).toHaveBeenCalledTimes(1);
|
||||
expect(server.stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('releases the tunnel when the Android fence cannot start', async () => {
|
||||
jest.mocked(startAndroidLocalEgressAsync).mockRejectedValue(new Error('nft failed'));
|
||||
await expect(startOn(BuildRuntimePlatform.LINUX)).rejects.toThrow('nft failed');
|
||||
expect(server.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(tunnel.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(writeLocalEgressHandoffAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('local egress acquisition', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(fs.promises, 'mkdtemp').mockResolvedValue('/tmp/egress-acquisition-test');
|
||||
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
|
||||
jest.spyOn(fs.promises, 'rm').mockResolvedValue(undefined);
|
||||
server.stopAsync.mockResolvedValue(undefined);
|
||||
tunnel.stopAsync.mockResolvedValue(undefined);
|
||||
jest.mocked(findAvailablePortAsync).mockResolvedValue(52001);
|
||||
jest.mocked(downloadChiselAsync).mockResolvedValue('/tmp/egress-acquisition-test/chisel');
|
||||
jest.mocked(startChiselServerAsync).mockResolvedValue({ process: server, fingerprint: 'key=' });
|
||||
jest.mocked(startNgrokTunnelAsync).mockResolvedValue(tunnel);
|
||||
jest.mocked(configureSystemProxyAsync).mockResolvedValue({ service: 'Ethernet' });
|
||||
jest.mocked(writeLocalEgressHandoffAsync).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('stops the detached server and removes credentials when ngrok acquisition fails', async () => {
|
||||
jest.mocked(startNgrokTunnelAsync).mockRejectedValue(new Error('listener quota'));
|
||||
await expect(start()).rejects.toThrow('listener quota');
|
||||
expect(server.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(configureSystemProxyAsync).not.toHaveBeenCalled();
|
||||
expect(fs.promises.rm).toHaveBeenCalledWith('/tmp/egress-acquisition-test', {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('attempts every release even when one resource cannot stop', async () => {
|
||||
await start();
|
||||
tunnel.stopAsync.mockRejectedValue(new Error('ngrok close failed'));
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
expect(server.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(fs.promises.rm).toHaveBeenCalledWith(LOCAL_EGRESS_HANDOFF_PATH, { force: true });
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Could not stop a local egress resource.'
|
||||
);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'disposes late listeners without delaying cleanup (close fails: %s)',
|
||||
async closeFails => {
|
||||
let finishTunnel!: (value: typeof tunnel) => void;
|
||||
jest.mocked(startNgrokTunnelAsync).mockImplementation(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
finishTunnel = resolve;
|
||||
})
|
||||
);
|
||||
const started = start();
|
||||
const rejected = expect(started).rejects.toThrow();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(startNgrokTunnelAsync).toHaveBeenCalledTimes(1);
|
||||
const stopped = stopLocalEgressResourcesAsync(logger);
|
||||
await Promise.all([stopped, rejected]);
|
||||
expect(server.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(tunnel.stopAsync).not.toHaveBeenCalled();
|
||||
if (closeFails) {
|
||||
tunnel.stopAsync.mockRejectedValue(new Error('late close failed'));
|
||||
}
|
||||
finishTunnel(tunnel);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
if (closeFails) {
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Could not release a cancelled local egress acquisition.'
|
||||
);
|
||||
}
|
||||
expect(tunnel.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(configureSystemProxyAsync).not.toHaveBeenCalled();
|
||||
expect(writeLocalEgressHandoffAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['job', 'step'])('finishes %s cancellation when ngrok never settles', async source => {
|
||||
jest.mocked(startNgrokTunnelAsync).mockReturnValue(new Promise(() => {}));
|
||||
const controller = new AbortController();
|
||||
const started = start(controller.signal);
|
||||
const rejected = expect(started).rejects.toThrow();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(startNgrokTunnelAsync).toHaveBeenCalledTimes(1);
|
||||
if (source === 'step') {
|
||||
controller.abort(new Error('cancelled'));
|
||||
}
|
||||
const stopped = source === 'job' ? stopLocalEgressResourcesAsync(logger) : Promise.resolve();
|
||||
const outcome = await Promise.race([
|
||||
Promise.all([stopped, rejected]).then(() => 'stopped'),
|
||||
new Promise(resolve => setImmediate(() => resolve('still waiting'))),
|
||||
]);
|
||||
expect(outcome).toBe('stopped');
|
||||
expect(server.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(configureSystemProxyAsync).not.toHaveBeenCalled();
|
||||
expect(writeLocalEgressHandoffAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles ngrok rejection after finalization without an unhandled rejection', async () => {
|
||||
let rejectTunnel!: (error: Error) => void;
|
||||
jest.mocked(startNgrokTunnelAsync).mockReturnValue(
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectTunnel = reject;
|
||||
})
|
||||
);
|
||||
const started = start();
|
||||
const rejected = expect(started).rejects.toThrow();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
await Promise.all([stopLocalEgressResourcesAsync(logger), rejected]);
|
||||
rejectTunnel(new Error('late connection failure'));
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(server.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(configureSystemProxyAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not launch a server after a download finishes following cancellation', async () => {
|
||||
let finishDownload!: (value: string) => void;
|
||||
jest.mocked(downloadChiselAsync).mockImplementation(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
finishDownload = resolve;
|
||||
})
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const started = start(controller.signal);
|
||||
const rejected = expect(started).rejects.toThrow('cancelled');
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
controller.abort(new Error('cancelled'));
|
||||
await rejected;
|
||||
finishDownload('/tmp/chisel');
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(startChiselServerAsync).not.toHaveBeenCalled();
|
||||
expect(
|
||||
jest
|
||||
.mocked(fs.promises.rm)
|
||||
.mock.calls.filter(([target]) => target === '/tmp/egress-acquisition-test')
|
||||
).toHaveLength(2);
|
||||
expect(fs.promises.rm).toHaveBeenCalledWith('/tmp/egress-acquisition-test', {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { Client, fetchExchange } from '@urql/core';
|
||||
|
||||
import * as sandboxDaemon from '../../utils/sandboxDaemon';
|
||||
|
||||
import { createStartSandboxBuildFunction, markSandboxReadyAsync } from '../startSandbox';
|
||||
|
||||
describe('sandbox build functions', () => {
|
||||
it('aborts an active ready request and keeps its authentication headers', async () => {
|
||||
const controller = new AbortController();
|
||||
let requestStarted!: () => void;
|
||||
const started = new Promise<void>(resolve => {
|
||||
requestStarted = resolve;
|
||||
});
|
||||
const fetchMock = jest.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => {
|
||||
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer test-token');
|
||||
return await new Promise<Response>((_resolve, reject) => {
|
||||
init!.signal!.addEventListener('abort', () => reject(init!.signal!.reason), { once: true });
|
||||
requestStarted();
|
||||
});
|
||||
});
|
||||
const graphqlClient = new Client({
|
||||
url: 'https://api.expo.test/graphql',
|
||||
exchanges: [fetchExchange],
|
||||
fetchOptions: { headers: { Authorization: 'Bearer test-token' } },
|
||||
});
|
||||
try {
|
||||
const pending = markSandboxReadyAsync(
|
||||
{ graphqlClient } as any,
|
||||
'sandbox-id',
|
||||
controller.signal
|
||||
);
|
||||
const rejected = expect(pending).rejects.toMatchObject({ name: 'AbortError' });
|
||||
await started;
|
||||
controller.abort();
|
||||
await rejected;
|
||||
} finally {
|
||||
fetchMock.mockRestore();
|
||||
}
|
||||
});
|
||||
it('does not mark the sandbox ready if startup was canceled', async () => {
|
||||
const controller = new AbortController();
|
||||
const stopAsync = jest.fn(async () => {});
|
||||
const start = jest
|
||||
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
|
||||
.mockImplementation(async options => {
|
||||
expect(options.signal).toBe(controller.signal);
|
||||
controller.abort();
|
||||
return { ready: Promise.resolve(), stopAsync };
|
||||
});
|
||||
const mutation = jest.fn();
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
graphqlClient: { mutation },
|
||||
} as any);
|
||||
try {
|
||||
await expect(
|
||||
fn.fn!({ logger: {} } as any, {
|
||||
inputs: { sandbox_id: { value: 'sandbox-id' } },
|
||||
outputs: {},
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
|
||||
signal: controller.signal,
|
||||
})
|
||||
).rejects.toMatchObject({ name: 'AbortError' });
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
start.mockRestore();
|
||||
}
|
||||
});
|
||||
it('excludes the MCP token from the command environment', async () => {
|
||||
const env = {
|
||||
PREPARED_BY_EARLIER_STEP: 'value',
|
||||
EXPO_TOKEN: 'expo-token',
|
||||
__EAS_SANDBOX_MCP_TOKEN: 'token',
|
||||
};
|
||||
const startupError = new Error('stop after checking options');
|
||||
const start = jest
|
||||
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
|
||||
.mockRejectedValue(startupError);
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
} as any);
|
||||
try {
|
||||
await expect(
|
||||
fn.fn!({ logger: {} } as any, {
|
||||
inputs: { sandbox_id: { value: 'sandbox-id' } },
|
||||
outputs: {},
|
||||
env,
|
||||
})
|
||||
).rejects.toBe(startupError);
|
||||
expect(start.mock.calls[0][0]).toMatchObject({ credential: 'token' });
|
||||
expect(start.mock.calls[0][0].env).toEqual({
|
||||
PREPARED_BY_EARLIER_STEP: 'value',
|
||||
EXPO_TOKEN: 'expo-token',
|
||||
});
|
||||
expect(env.__EAS_SANDBOX_MCP_TOKEN).toBe('token');
|
||||
} finally {
|
||||
start.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('throws a system error when the sandbox token is missing', async () => {
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
job: {},
|
||||
env: {},
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
fn.fn!({} as any, {
|
||||
inputs: { sandbox_id: { value: 'sandbox-id' } },
|
||||
outputs: {},
|
||||
env: {},
|
||||
})
|
||||
).rejects.toBeInstanceOf(SystemError);
|
||||
});
|
||||
|
||||
it('throws a system error when the MCP server URL is missing', async () => {
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
job: {},
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'sandbox-token' },
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
fn.fn!({} as any, {
|
||||
inputs: { sandbox_id: { value: 'sandbox-id' } },
|
||||
outputs: {},
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'sandbox-token' },
|
||||
})
|
||||
).rejects.toThrow('MCP server URL is required to start the sandbox daemon.');
|
||||
});
|
||||
|
||||
it('marks the sandbox as ready', async () => {
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => ({ data: { sandbox: { markSandboxReady: { id: 'sandbox-id' } } } }),
|
||||
}));
|
||||
|
||||
await markSandboxReadyAsync({ graphqlClient: { mutation } } as any, 'sandbox-id');
|
||||
|
||||
expect(mutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ sandboxId: 'sandbox-id' },
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a system error when marking the sandbox as ready fails', async () => {
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => ({ error: new Error('request failed') }),
|
||||
}));
|
||||
|
||||
await expect(
|
||||
markSandboxReadyAsync({ graphqlClient: { mutation } } as any, 'sandbox-id')
|
||||
).rejects.toThrow('Failed to mark sandbox sandbox-id as ready.');
|
||||
});
|
||||
});
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, type BuildStepContext, type BuildStepEnv } from '@expo/steps';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
|
||||
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
getDeviceRunSessionIdOrThrow: jest.fn(),
|
||||
getNgrokTunnelDomainOrThrow: jest.fn(),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const env = {} as BuildStepEnv;
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const stopAsync = jest.fn();
|
||||
|
||||
async function runAsync(
|
||||
runtimePlatform: BuildRuntimePlatform,
|
||||
launchInputs: Record<string, { value: unknown }> = {}
|
||||
): Promise<void> {
|
||||
const buildFunction = createStartWebPreviewRemoteSessionBuildFunction(ctx);
|
||||
await buildFunction.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: '1.2.3' },
|
||||
max_duration_seconds: { value: 120 },
|
||||
...launchInputs,
|
||||
},
|
||||
outputs: {},
|
||||
env,
|
||||
} as never
|
||||
);
|
||||
}
|
||||
|
||||
describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(getDeviceRunSessionIdOrThrow).mockReturnValue('device-run-session-id');
|
||||
jest.mocked(getNgrokTunnelDomainOrThrow).mockReturnValue('tunnel.example.com');
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: jest.fn().mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.example.test',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
finishAsync: stopAsync,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
stopAsync.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('reports the session token when serve-sim minted one', async () => {
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: jest.fn().mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.example.test',
|
||||
previewToken: 'tok-1',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
finishAsync: stopAsync,
|
||||
});
|
||||
|
||||
await runAsync(BuildRuntimePlatform.DARWIN);
|
||||
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: {
|
||||
previewUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
previewApiUrl: 'https://web-preview.example.test',
|
||||
previewToken: 'tok-1',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['preview', 'config', 'wait'])('finishes the session after %s fails', async phase => {
|
||||
const error = new Error(`${phase} failed`);
|
||||
if (phase === 'preview') {
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
|
||||
openPreviewAsync: jest.fn().mockRejectedValue(error),
|
||||
finishAsync: stopAsync,
|
||||
});
|
||||
} else if (phase === 'config') {
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValueOnce(error);
|
||||
} else {
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValueOnce(error);
|
||||
}
|
||||
await expect(runAsync(BuildRuntimePlatform.LINUX)).rejects.toBe(error);
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[BuildRuntimePlatform.DARWIN, true],
|
||||
[BuildRuntimePlatform.LINUX, false],
|
||||
])('starts the web preview for %s', async (runtimePlatform, selectsXcode) => {
|
||||
await runAsync(runtimePlatform);
|
||||
|
||||
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledTimes(selectsXcode ? 1 : 0);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(ctx, {
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 60_000,
|
||||
packageVersion: '1.2.3',
|
||||
launchAppIdentifier: undefined,
|
||||
launchArgs: [],
|
||||
openUrl: undefined,
|
||||
networkCapture: false,
|
||||
networkCaptureFields: [],
|
||||
});
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
|
||||
ctx,
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
remoteConfig: {
|
||||
previewUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
previewApiUrl: 'https://web-preview.example.test',
|
||||
},
|
||||
logger,
|
||||
});
|
||||
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith({
|
||||
ctx,
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
logger,
|
||||
maxDurationSeconds: 120,
|
||||
signal: undefined,
|
||||
});
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('declares the launch inputs', () => {
|
||||
const buildFunction = createStartWebPreviewRemoteSessionBuildFunction(ctx);
|
||||
const globalCtx = createGlobalContextMock();
|
||||
|
||||
expect(
|
||||
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
|
||||
).toEqual([
|
||||
'launch_app_identifier',
|
||||
'launch_args',
|
||||
'open_url',
|
||||
'network_capture',
|
||||
'network_capture_fields',
|
||||
'package_version',
|
||||
'max_duration_seconds',
|
||||
]);
|
||||
});
|
||||
|
||||
it('hands the launch inputs to the session host and announces them', async () => {
|
||||
await runAsync(BuildRuntimePlatform.DARWIN, {
|
||||
launch_app_identifier: { value: 'host.exp.Exponent' },
|
||||
launch_args: { value: ['-EXDevMenuIsOnboardingFinished', '1'] },
|
||||
open_url: { value: 'exp://127.0.0.1:8081' },
|
||||
});
|
||||
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
|
||||
openUrl: 'exp://127.0.0.1:8081',
|
||||
})
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'serve-sim will launch host.exp.Exponent with arguments ' +
|
||||
'["-EXDevMenuIsOnboardingFinished","1"], then open exp://127.0.0.1:8081.'
|
||||
);
|
||||
});
|
||||
|
||||
it('hands network capture to the session host', async () => {
|
||||
await runAsync(BuildRuntimePlatform.DARWIN, {
|
||||
network_capture: { value: true },
|
||||
network_capture_fields: { value: ['header', 'response-body'] },
|
||||
});
|
||||
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
networkCapture: true,
|
||||
networkCaptureFields: ['header', 'response-body'],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('fails before starting anything when network capture is asked for on Android', async () => {
|
||||
await expect(
|
||||
runAsync(BuildRuntimePlatform.LINUX, {
|
||||
network_capture: { value: true },
|
||||
})
|
||||
).rejects.toThrow('this session runs on linux');
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails before starting anything when a launch is asked for on Android', async () => {
|
||||
await expect(
|
||||
runAsync(BuildRuntimePlatform.LINUX, {
|
||||
launch_app_identifier: { value: 'host.exp.Exponent' },
|
||||
})
|
||||
).rejects.toThrow('runs on linux');
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,533 @@
|
||||
import nock from 'nock';
|
||||
import { generateKeyPairSync } from 'node:crypto';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { AscApiClient } from '../../utils/ios/AscApiClient';
|
||||
import {
|
||||
createUpdateTestFlightMetadataBuildFunction,
|
||||
updateTestFlightMetadataAsync,
|
||||
} from '../updateTestFlightMetadata';
|
||||
|
||||
jest.unmock('node-fetch');
|
||||
|
||||
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
||||
const key = { keyId: 'TESTKEY', privateKey };
|
||||
const changelog = 'Test "quotes"\n$(not-a-command)';
|
||||
const options = {
|
||||
client: new AscApiClient({ key }),
|
||||
buildUploadId: 'upload',
|
||||
changelog,
|
||||
groups: [] as string[],
|
||||
logger: createMockLogger(),
|
||||
};
|
||||
const api = () => nock('https://api.appstoreconnect.apple.com');
|
||||
|
||||
function mockBuild(
|
||||
state = 'COMPLETE',
|
||||
buildId: string | null = 'build',
|
||||
primaryLocale: string | null = 'en-US'
|
||||
): void {
|
||||
api()
|
||||
.get('/v1/buildUploads/upload')
|
||||
.query(true)
|
||||
.reply(200, {
|
||||
data: {
|
||||
type: 'buildUploads',
|
||||
id: 'upload',
|
||||
attributes: { state: { state } },
|
||||
relationships: { build: { data: buildId ? { type: 'builds', id: buildId } : null } },
|
||||
},
|
||||
});
|
||||
if (state === 'COMPLETE' && buildId) {
|
||||
api()
|
||||
.get(`/v1/builds/${buildId}/app`)
|
||||
.query(true)
|
||||
.reply(200, {
|
||||
data: { id: 'app', attributes: primaryLocale ? { primaryLocale } : {} },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function mockAssignedGroups(ids: string[] = []): void {
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[builds]': 'build', limit: '200' })
|
||||
.reply(200, { data: ids.map(id => ({ id })) });
|
||||
}
|
||||
|
||||
beforeAll(() => nock.disableNetConnect());
|
||||
beforeEach(() => {
|
||||
options.logger = createMockLogger();
|
||||
options.client = new AscApiClient({ key });
|
||||
});
|
||||
afterAll(() => nock.enableNetConnect());
|
||||
afterEach(() => {
|
||||
try {
|
||||
expect(nock.pendingMocks()).toEqual([]);
|
||||
} finally {
|
||||
nock.cleanAll();
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts group arrays and preserves changelog input without shell parsing', async () => {
|
||||
const fn = createUpdateTestFlightMetadataBuildFunction();
|
||||
const spy = jest.spyOn(fn, 'fn').mockImplementation(async (_ctx, { inputs }) => {
|
||||
expect(inputs.groups.value).toEqual(['A, "B"']);
|
||||
expect(inputs.changelog.value).toBe(changelog);
|
||||
});
|
||||
const step = fn.createBuildStepFromFunctionCall(createGlobalContextMock(), {
|
||||
callInputs: {
|
||||
asc_api_key_path: '/tmp/key.json',
|
||||
build_upload_id: 'upload',
|
||||
groups: ['A, "B"'],
|
||||
changelog,
|
||||
},
|
||||
});
|
||||
await step.executeAsync();
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses empty metadata defaults', async () => {
|
||||
const fn = createUpdateTestFlightMetadataBuildFunction();
|
||||
const spy = jest.spyOn(fn, 'fn').mockImplementation(async (_ctx, { inputs }) => {
|
||||
expect(inputs.groups.value).toEqual([]);
|
||||
expect(inputs.changelog.value).toBe('');
|
||||
});
|
||||
const step = fn.createBuildStepFromFunctionCall(createGlobalContextMock(), {
|
||||
callInputs: { asc_api_key_path: '/tmp/key.json', build_upload_id: 'upload' },
|
||||
});
|
||||
await step.executeAsync();
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips automatic internal groups and groups that already contain the build', async () => {
|
||||
mockBuild();
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, {
|
||||
data: [
|
||||
{
|
||||
id: 'automatic',
|
||||
attributes: { name: 'Internal', isInternalGroup: true, hasAccessToAllBuilds: true },
|
||||
},
|
||||
{ id: 'assigned', attributes: { name: 'QA' } },
|
||||
{
|
||||
id: 'manual',
|
||||
attributes: { name: 'External', isInternalGroup: false, hasAccessToAllBuilds: true },
|
||||
},
|
||||
],
|
||||
});
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[builds]': 'build', limit: '200' })
|
||||
.reply(200, { data: [{ id: 'assigned', attributes: { name: 'QA' } }] });
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups', {
|
||||
data: [{ type: 'betaGroups', id: 'manual' }],
|
||||
})
|
||||
.reply(204);
|
||||
await updateTestFlightMetadataAsync({
|
||||
...options,
|
||||
changelog: '',
|
||||
groups: ['Internal', 'QA', 'External'],
|
||||
});
|
||||
expect(options.logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"Internal" (automatic): automatic access')
|
||||
);
|
||||
expect(options.logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"QA" (assigned): build already assigned')
|
||||
);
|
||||
expect(options.logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"External" (manual): assignment completed')
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['en-US', null])(
|
||||
'creates a localization when none exist (primary locale: %s)',
|
||||
async primaryLocale => {
|
||||
mockBuild('COMPLETE', 'build', primaryLocale);
|
||||
api().get('/v1/builds/build/betaBuildLocalizations').query(true).reply(200, { data: [] });
|
||||
api()
|
||||
.post('/v1/betaBuildLocalizations', {
|
||||
data: {
|
||||
type: 'betaBuildLocalizations',
|
||||
attributes: { locale: 'en-US', whatsNew: changelog },
|
||||
relationships: { build: { data: { type: 'builds', id: 'build' } } },
|
||||
},
|
||||
})
|
||||
.reply(201, { data: { id: 'locale' } });
|
||||
await updateTestFlightMetadataAsync(options);
|
||||
expect(options.logger.info).toHaveBeenCalledWith(
|
||||
`Updating changelog: ${JSON.stringify(changelog)}`
|
||||
);
|
||||
expect(options.logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Locale "en-US": localization created')
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('reads all localization pages and updates existing localizations without replacing them', async () => {
|
||||
mockBuild();
|
||||
api()
|
||||
.get('/v1/builds/build/betaBuildLocalizations')
|
||||
.query(true)
|
||||
.reply(200, {
|
||||
data: [{ id: 'en-US', attributes: { locale: 'en-US' } }],
|
||||
links: {
|
||||
next: 'https://api.appstoreconnect.apple.com/v1/builds/build/betaBuildLocalizations?limit=200&cursor=next',
|
||||
},
|
||||
});
|
||||
api()
|
||||
.get('/v1/builds/build/betaBuildLocalizations')
|
||||
.query({ limit: '200', cursor: 'next' })
|
||||
.reply(200, { data: [{ id: 'pl', attributes: { locale: 'pl' } }] });
|
||||
for (const id of ['en-US', 'pl']) {
|
||||
api()
|
||||
.patch(`/v1/betaBuildLocalizations/${id}`, {
|
||||
data: { type: 'betaBuildLocalizations', id, attributes: { whatsNew: changelog } },
|
||||
})
|
||||
.reply(200, { data: { id } });
|
||||
}
|
||||
await updateTestFlightMetadataAsync(options);
|
||||
for (const id of ['en-US', 'pl']) {
|
||||
expect(options.logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`Locale "${id}" (${id})`)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('updates by ID when Apple omits a localization locale and creates the primary locale', async () => {
|
||||
mockBuild();
|
||||
api()
|
||||
.get('/v1/builds/build/betaBuildLocalizations')
|
||||
.query(true)
|
||||
.reply(200, { data: [{ id: 'localization' }] });
|
||||
|
||||
api()
|
||||
.patch('/v1/betaBuildLocalizations/localization', {
|
||||
data: {
|
||||
type: 'betaBuildLocalizations',
|
||||
id: 'localization',
|
||||
attributes: { whatsNew: changelog },
|
||||
},
|
||||
})
|
||||
.reply(200, { data: { id: 'localization' } });
|
||||
api()
|
||||
.post('/v1/betaBuildLocalizations', {
|
||||
data: {
|
||||
type: 'betaBuildLocalizations',
|
||||
attributes: { locale: 'en-US', whatsNew: changelog },
|
||||
relationships: { build: { data: { type: 'builds', id: 'build' } } },
|
||||
},
|
||||
})
|
||||
.reply(201, { data: { id: 'primary' } });
|
||||
await updateTestFlightMetadataAsync(options);
|
||||
});
|
||||
|
||||
it('assigns requested groups when hasAccessToAllBuilds is null', async () => {
|
||||
mockBuild();
|
||||
mockAssignedGroups();
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, {
|
||||
data: [
|
||||
{
|
||||
id: 'internal',
|
||||
attributes: { name: 'Internal', isInternalGroup: true, hasAccessToAllBuilds: false },
|
||||
},
|
||||
{
|
||||
id: 'external',
|
||||
attributes: {
|
||||
name: 'test external',
|
||||
isInternalGroup: false,
|
||||
hasAccessToAllBuilds: null,
|
||||
publicLinkEnabled: false,
|
||||
publicLink: 'https://testflight.apple.com/join/abc',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'requested-external',
|
||||
attributes: { name: 'Beta', isInternalGroup: false, hasAccessToAllBuilds: null },
|
||||
},
|
||||
],
|
||||
});
|
||||
for (const id of ['internal', 'requested-external']) {
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups', {
|
||||
data: [{ type: 'betaGroups', id }],
|
||||
})
|
||||
.reply(204);
|
||||
}
|
||||
await expect(
|
||||
updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['Internal', 'Beta'] })
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('adds groups without changing changelog or submitting beta review', async () => {
|
||||
mockBuild();
|
||||
mockAssignedGroups();
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, { data: [{ id: 'group', attributes: { name: 'A, "B"' } }] });
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups', {
|
||||
data: [{ type: 'betaGroups', id: 'group' }],
|
||||
})
|
||||
.reply(204);
|
||||
await updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A, "B"', 'A, "B"'] });
|
||||
});
|
||||
|
||||
it('reports missing groups independently of the changelog task', async () => {
|
||||
mockBuild();
|
||||
api().get('/v1/builds/build/betaBuildLocalizations').query(true).reply(200, { data: [] });
|
||||
api()
|
||||
.post('/v1/betaBuildLocalizations')
|
||||
.reply(201, { data: { id: 'locale' } });
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, { data: [] });
|
||||
await expect(updateTestFlightMetadataAsync({ ...options, groups: ['missing'] })).rejects.toThrow(
|
||||
'The following TestFlight group was not found in App Store Connect: "missing".'
|
||||
);
|
||||
});
|
||||
|
||||
it('reads all pages and adds every group with a requested name', async () => {
|
||||
mockBuild();
|
||||
mockAssignedGroups();
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, {
|
||||
data: [{ id: 'first', attributes: { name: 'A' } }],
|
||||
links: {
|
||||
next: 'https://api.appstoreconnect.apple.com/v1/betaGroups?filter%5Bapp%5D=app&limit=200&cursor=page-2',
|
||||
},
|
||||
});
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200', cursor: 'page-2' })
|
||||
.reply(200, {
|
||||
data: [
|
||||
{ id: 'second', attributes: { name: 'A' } },
|
||||
{ id: 'other', attributes: { name: 'B' } },
|
||||
],
|
||||
});
|
||||
for (const id of ['first', 'second']) {
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups', {
|
||||
data: [{ type: 'betaGroups', id }],
|
||||
})
|
||||
.reply(204);
|
||||
}
|
||||
await updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A'] });
|
||||
});
|
||||
|
||||
it('assigns found groups before reporting missing requested names', async () => {
|
||||
mockBuild();
|
||||
mockAssignedGroups();
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, { data: [{ id: 'group', attributes: { name: 'A' } }] });
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups', {
|
||||
data: [{ type: 'betaGroups', id: 'group' }],
|
||||
})
|
||||
.reply(204);
|
||||
await expect(
|
||||
updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A', 'B', 'C'] })
|
||||
).rejects.toThrow(
|
||||
'The following TestFlight groups were not found in App Store Connect: "B", "C".'
|
||||
);
|
||||
});
|
||||
|
||||
it('attempts all localization changes and reports every failed update', async () => {
|
||||
mockBuild();
|
||||
api()
|
||||
.get('/v1/builds/build/betaBuildLocalizations')
|
||||
.query(true)
|
||||
.reply(200, {
|
||||
data: ['pl', 'fr', 'de'].map(locale => ({ id: locale, attributes: { locale } })),
|
||||
});
|
||||
for (const id of ['pl', 'fr', 'de']) {
|
||||
api()
|
||||
.patch(`/v1/betaBuildLocalizations/${id}`)
|
||||
.reply(
|
||||
id === 'de' ? 200 : 403,
|
||||
id === 'de' ? { data: { id } } : { errors: [{ code: `${id}_FAILED` }] }
|
||||
);
|
||||
}
|
||||
api()
|
||||
.post('/v1/betaBuildLocalizations', {
|
||||
data: {
|
||||
type: 'betaBuildLocalizations',
|
||||
attributes: { locale: 'en-US', whatsNew: changelog },
|
||||
relationships: { build: { data: { type: 'builds', id: 'build' } } },
|
||||
},
|
||||
})
|
||||
.reply(201, { data: { id: 'locale' } });
|
||||
await expect(updateTestFlightMetadataAsync(options)).rejects.toThrow(/pl_FAILED.*fr_FAILED/);
|
||||
expect(options.logger.error).toHaveBeenCalledWith(expect.stringContaining('"pl" (pl)'));
|
||||
expect(options.logger.error).toHaveBeenCalledWith(expect.stringContaining('"fr" (fr)'));
|
||||
});
|
||||
|
||||
it.each([1, 2])('explains automatic internal group rejection (%s errors)', async errorCount => {
|
||||
mockBuild();
|
||||
mockAssignedGroups();
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, { data: [{ id: 'group', attributes: { name: 'A' } }] });
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups')
|
||||
.reply(422, {
|
||||
errors: Array.from({ length: errorCount }, () => ({
|
||||
code: 'ENTITY_UNPROCESSABLE',
|
||||
title: 'Builds cannot be assigned to this internal group.',
|
||||
detail: 'Cannot add internal group to a build.',
|
||||
})),
|
||||
});
|
||||
await expect(
|
||||
updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A'] })
|
||||
).rejects.toThrow(
|
||||
"App Store Connect can't add this build to a requested internal TestFlight group."
|
||||
);
|
||||
});
|
||||
|
||||
it('stops after 20 group pages', async () => {
|
||||
mockBuild();
|
||||
const next =
|
||||
'https://api.appstoreconnect.apple.com/v1/betaGroups?filter%5Bapp%5D=app&limit=200&cursor=next';
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, { data: [], links: { next } });
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200', cursor: 'next' })
|
||||
.times(19)
|
||||
.reply(200, { data: [], links: { next } });
|
||||
await expect(
|
||||
updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A'] })
|
||||
).rejects.toThrow('We only support TestFlight group lists with up to 20 pages');
|
||||
});
|
||||
|
||||
it('tries group assignment if the changelog update fails', async () => {
|
||||
mockBuild();
|
||||
mockAssignedGroups();
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, { data: [{ id: 'group', attributes: { name: 'A' } }] });
|
||||
api().get('/v1/builds/build/betaBuildLocalizations').query(true).reply(200, { data: [] });
|
||||
api()
|
||||
.post('/v1/betaBuildLocalizations')
|
||||
.reply(403, { errors: [{ code: 'FORBIDDEN', detail: 'Not allowed' }] });
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups', {
|
||||
data: [{ type: 'betaGroups', id: 'group' }],
|
||||
})
|
||||
.reply(204);
|
||||
await expect(updateTestFlightMetadataAsync({ ...options, groups: ['A'] })).rejects.toThrow('403');
|
||||
});
|
||||
|
||||
it('reports both write failures', async () => {
|
||||
mockBuild();
|
||||
mockAssignedGroups();
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, { data: [{ id: 'group', attributes: { name: 'A' } }] });
|
||||
api().get('/v1/builds/build/betaBuildLocalizations').query(true).reply(200, { data: [] });
|
||||
api()
|
||||
.post('/v1/betaBuildLocalizations')
|
||||
.reply(403, { errors: [{ code: 'CHANGELOG_FAILED' }] });
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups')
|
||||
.reply(403, { errors: [{ code: 'GROUPS_FAILED' }] });
|
||||
await expect(updateTestFlightMetadataAsync({ ...options, groups: ['A'] })).rejects.toThrow(
|
||||
/CHANGELOG_FAILED.*GROUPS_FAILED/
|
||||
);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'attempts all groups and reports assignment errors (multiple: %s)',
|
||||
async multiple => {
|
||||
mockBuild();
|
||||
mockAssignedGroups();
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, { data: ['A', 'B', 'C'].map(id => ({ id, attributes: { name: id } })) });
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups', {
|
||||
data: [{ type: 'betaGroups', id: 'A' }],
|
||||
})
|
||||
.reply(
|
||||
multiple ? 403 : 204,
|
||||
multiple ? { errors: [{ code: 'FIRST_GROUP_FAILED' }] } : undefined
|
||||
);
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups', {
|
||||
data: [{ type: 'betaGroups', id: 'B' }],
|
||||
})
|
||||
.reply(422, { errors: [{ code: 'ENTITY_UNPROCESSABLE', title: 'Partial assignment' }] });
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups', {
|
||||
data: [{ type: 'betaGroups', id: 'C' }],
|
||||
})
|
||||
.reply(204);
|
||||
await expect(
|
||||
updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A', 'B', 'C'] })
|
||||
).rejects.toThrow(multiple ? /FIRST_GROUP_FAILED.*Partial assignment/ : 'Partial assignment');
|
||||
expect(options.logger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Group "B" (B): assignment failed.')
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('reads all membership pages before assigning groups', async () => {
|
||||
mockBuild();
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, { data: [{ id: 'group', attributes: { name: 'A' } }] });
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[builds]': 'build', limit: '200' })
|
||||
.reply(200, {
|
||||
data: [],
|
||||
links: {
|
||||
next: 'https://api.appstoreconnect.apple.com/v1/betaGroups?filter%5Bbuilds%5D=build&limit=200&cursor=next',
|
||||
},
|
||||
});
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[builds]': 'build', limit: '200', cursor: 'next' })
|
||||
.reply(200, { data: [{ id: 'group' }] });
|
||||
await updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A'] });
|
||||
});
|
||||
|
||||
it.each(['PROCESSING', 'FAILED'])('rejects a %s upload', async state => {
|
||||
mockBuild(state);
|
||||
await expect(updateTestFlightMetadataAsync(options)).rejects.toThrow('not ready');
|
||||
});
|
||||
|
||||
it('rejects a completed upload without its build relationship', async () => {
|
||||
mockBuild('COMPLETE', null);
|
||||
await expect(updateTestFlightMetadataAsync(options)).rejects.toThrow('not ready');
|
||||
});
|
||||
|
||||
it('propagates Apple write failures', async () => {
|
||||
mockBuild();
|
||||
api().get('/v1/builds/build/betaBuildLocalizations').query(true).reply(200, { data: [] });
|
||||
api()
|
||||
.post('/v1/betaBuildLocalizations')
|
||||
.reply(403, { errors: [{ code: 'FORBIDDEN', detail: 'Not allowed' }] });
|
||||
await expect(updateTestFlightMetadataAsync(options)).rejects.toThrow('403');
|
||||
});
|
||||
+22
-9
@@ -29,7 +29,20 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('uploads simulator device metadata', async () => {
|
||||
it.each([
|
||||
{
|
||||
platform: BuildRuntimePlatform.DARWIN,
|
||||
udid: SIMULATOR_UDID,
|
||||
name: 'iPhone 16',
|
||||
runtime: 'iOS 18.6',
|
||||
},
|
||||
{
|
||||
platform: BuildRuntimePlatform.LINUX,
|
||||
udid: 'emulator-5554',
|
||||
name: 'Pixel',
|
||||
runtime: 'Android 16',
|
||||
},
|
||||
])('uploads device metadata on $platform', async ({ platform, udid, name, runtime }) => {
|
||||
const recordingDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'ios-recording-test-'));
|
||||
const recordingPath = path.join(recordingDirectory, 'recording.mp4');
|
||||
await fs.writeFile(recordingPath, 'recording');
|
||||
@@ -45,7 +58,7 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
|
||||
try {
|
||||
const globalContext = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
runtimePlatform: platform,
|
||||
});
|
||||
const buildStep = createUploadDeviceRunSessionScreenRecordingsBuildFunction(
|
||||
{} as CustomBuildContext
|
||||
@@ -54,9 +67,9 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
callInputs: {
|
||||
recordings_json: [
|
||||
{
|
||||
udid: SIMULATOR_UDID,
|
||||
deviceName: 'iPhone 16',
|
||||
runtimeDisplayName: 'iOS 18.6',
|
||||
udid,
|
||||
deviceName: name,
|
||||
runtimeDisplayName: runtime,
|
||||
directory: recordingDirectory,
|
||||
},
|
||||
],
|
||||
@@ -68,13 +81,13 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
name: 'iPhone 16 screen recording (01234567-…, started at Jul 10, 2026, 10:00:00.000 UTC)',
|
||||
name: `${name} screen recording (${udid.slice(0, 8)}-…, started at Jul 10, 2026, 10:00:00.000 UTC)`,
|
||||
metadata: {
|
||||
__eas_type: 'screen-recording',
|
||||
__eas_screen_recording: '1',
|
||||
udid: SIMULATOR_UDID,
|
||||
deviceName: 'iPhone 16',
|
||||
runtimeDisplayName: 'iOS 18.6',
|
||||
udid,
|
||||
deviceName: name,
|
||||
runtimeDisplayName: runtime,
|
||||
firstFrameAt: '2026-07-10T10:00:00.000Z',
|
||||
width: 1179,
|
||||
height: 2556,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { getDeviceRunSessionIdOrThrow } from '../utils/remoteDeviceRunSession';
|
||||
import { uploadServeSimLogsFileAsync } from '../utils/serveSimLogsArtifacts';
|
||||
import { ServeSimLogsRecorder } from '../utils/serveSimLogsRecorder';
|
||||
|
||||
const UPLOAD_TIMEOUT_MS = 30_000;
|
||||
|
||||
export function createCollectServeSimLogsBuildFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'collect_serve_sim_logs',
|
||||
name: 'Collect serve-sim simulator logs',
|
||||
__metricsId: 'eas/collect_serve_sim_logs',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
|
||||
fn: async ({ logger }, { env }) => {
|
||||
try {
|
||||
const collected = await ServeSimLogsRecorder.finishAsync({ logger });
|
||||
if (collected.length === 0) {
|
||||
logger.info('No simulator logs collected; skipping upload.');
|
||||
return;
|
||||
}
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
for (const { udid, filePath } of collected) {
|
||||
// Each device gets its own budget so one stuck upload does not cancel the rest.
|
||||
await uploadServeSimLogsFileAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
udid,
|
||||
filePath,
|
||||
logger,
|
||||
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not finalize serve-sim simulator logs', error);
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
'Could not finalize simulator logs; the session result is unchanged.'
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -8,24 +8,33 @@ import {
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
import { Client } from '@urql/core';
|
||||
import contentDisposition from 'content-disposition';
|
||||
import { glob } from 'fast-glob';
|
||||
import { graphql } from 'gql.tada';
|
||||
import fetch from 'node-fetch';
|
||||
import assert from 'node:assert';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import stream from 'stream';
|
||||
import { promisify } from 'util';
|
||||
import { z } from 'zod';
|
||||
import bplistParser from 'bplist-parser';
|
||||
import plist from 'plist';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { decompressTarAsync, isFileTarGzAsync } from '../../utils/files';
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
|
||||
import { pluralize } from '../../utils/strings';
|
||||
|
||||
const streamPipeline = promisify(stream.pipeline);
|
||||
|
||||
type DownloadBuildSource =
|
||||
| { buildId: string; applicationArchiveUrl?: never }
|
||||
| { buildId?: never; applicationArchiveUrl: string };
|
||||
|
||||
const BUILD_BY_ID_QUERY = graphql(`
|
||||
query DownloadBuildByIdQuery($buildId: ID!) {
|
||||
builds {
|
||||
@@ -49,7 +58,12 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'build_id',
|
||||
required: true,
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'application_archive_url',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
@@ -65,20 +79,48 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
|
||||
required: true,
|
||||
}),
|
||||
],
|
||||
fn: async (stepsCtx, { inputs, outputs }) => {
|
||||
fn: async (stepsCtx, { inputs, outputs, signal }) => {
|
||||
const { logger } = stepsCtx;
|
||||
|
||||
const extensions = z.array(z.string()).parse(inputs.extensions.value);
|
||||
logger.info(`Expected extensions: [${extensions.join(', ')}]`);
|
||||
const buildId = z.string().uuid().parse(inputs.build_id.value);
|
||||
logger.info(`Downloading build ${buildId}...`);
|
||||
const buildId = inputs.build_id.value
|
||||
? z.string().uuid().parse(inputs.build_id.value)
|
||||
: undefined;
|
||||
const applicationArchiveUrl = inputs.application_archive_url.value
|
||||
? parseHttpApplicationArchiveUrl(inputs.application_archive_url.value)
|
||||
: undefined;
|
||||
|
||||
let source: DownloadBuildSource;
|
||||
if (buildId) {
|
||||
if (applicationArchiveUrl) {
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
|
||||
'Pass only one of build_id or application_archive_url.'
|
||||
);
|
||||
}
|
||||
source = { buildId };
|
||||
} else {
|
||||
if (!applicationArchiveUrl) {
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
|
||||
'Pass build_id or application_archive_url.'
|
||||
);
|
||||
}
|
||||
source = { applicationArchiveUrl };
|
||||
}
|
||||
|
||||
logger.info(
|
||||
buildId ? `Downloading build ${buildId}...` : `Downloading application archive...`
|
||||
);
|
||||
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger,
|
||||
buildId,
|
||||
...source,
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
robotAccessToken: stepsCtx.global.staticContext.job.secrets?.robotAccessToken ?? null,
|
||||
extensions,
|
||||
signal,
|
||||
});
|
||||
|
||||
outputs.artifact_path.set(artifactPath);
|
||||
@@ -89,11 +131,17 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
|
||||
async function fetchApplicationArchiveUrlAsync({
|
||||
buildId,
|
||||
graphqlClient,
|
||||
signal,
|
||||
}: {
|
||||
buildId: string;
|
||||
graphqlClient: Client;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<string> {
|
||||
const result = await graphqlClient.query(BUILD_BY_ID_QUERY, { buildId }).toPromise();
|
||||
signal?.throwIfAborted();
|
||||
const result = await graphqlClient
|
||||
.query(BUILD_BY_ID_QUERY, { buildId }, graphqlAbortContext(signal))
|
||||
.toPromise();
|
||||
signal?.throwIfAborted();
|
||||
|
||||
if (result.error) {
|
||||
const { error } = result;
|
||||
@@ -115,42 +163,58 @@ async function fetchApplicationArchiveUrlAsync({
|
||||
return applicationArchiveUrl;
|
||||
}
|
||||
|
||||
export async function downloadBuildAsync({
|
||||
logger,
|
||||
buildId,
|
||||
graphqlClient,
|
||||
robotAccessToken,
|
||||
extensions,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
buildId: string;
|
||||
graphqlClient: Client;
|
||||
robotAccessToken: string | null;
|
||||
extensions: string[];
|
||||
}): Promise<{ artifactPath: string }> {
|
||||
export async function downloadBuildAsync(
|
||||
params: DownloadBuildSource & {
|
||||
logger: bunyan;
|
||||
graphqlClient: Client;
|
||||
robotAccessToken: string | null;
|
||||
extensions: string[];
|
||||
/** Stops the build lookup, the request and the file write when aborted, and skips the rest. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
): Promise<{ artifactPath: string }> {
|
||||
const { logger, graphqlClient, robotAccessToken, extensions, signal } = params;
|
||||
|
||||
let downloadUrl: string;
|
||||
let headers: { Authorization: string } | undefined;
|
||||
if (params.applicationArchiveUrl) {
|
||||
if (params.buildId) {
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
|
||||
'Pass only one of buildId or applicationArchiveUrl.'
|
||||
);
|
||||
}
|
||||
downloadUrl = parseHttpApplicationArchiveUrl(params.applicationArchiveUrl);
|
||||
headers = undefined;
|
||||
} else if (params.buildId) {
|
||||
const buildId = z.string().uuid().parse(params.buildId);
|
||||
downloadUrl = await fetchApplicationArchiveUrlAsync({ buildId, graphqlClient, signal });
|
||||
headers = robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined;
|
||||
} else {
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
|
||||
'Pass buildId or applicationArchiveUrl.'
|
||||
);
|
||||
}
|
||||
|
||||
const downloadDestinationDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'download_build-downloaded-')
|
||||
);
|
||||
|
||||
const downloadUrl = await fetchApplicationArchiveUrlAsync({ buildId, graphqlClient });
|
||||
|
||||
const response = await retryOnDNSFailure(fetch)(downloadUrl, {
|
||||
headers: robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined,
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
const response = await retryOnDNSFailure(fetch)(downloadUrl, { headers, signal });
|
||||
|
||||
if (!response.ok) {
|
||||
const textResult = await asyncResult(response.text());
|
||||
throw new Error(`Unexpected response from server (${response.status}): ${textResult.value}`);
|
||||
}
|
||||
|
||||
// URL may contain percent-encoded characters, e.g. my%20file.apk
|
||||
// this replaces all non-alphanumeric characters (excluding dot) with underscore
|
||||
const archiveFilename = path
|
||||
.basename(new URL(response.url).pathname)
|
||||
.replace(/([^a-z0-9.-]+)/gi, '_');
|
||||
const archiveFilename = resolveArchiveFilename({ response, extensions });
|
||||
const archivePath = path.join(downloadDestinationDirectory, archiveFilename);
|
||||
|
||||
// On abort, node-fetch emits an error on the body stream, which fails the pipeline.
|
||||
await streamPipeline(response.body, fs.createWriteStream(archivePath));
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const { size } = await fs.promises.stat(archivePath);
|
||||
|
||||
@@ -166,10 +230,15 @@ export async function downloadBuildAsync({
|
||||
const extractionDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'download_build-extracted-')
|
||||
);
|
||||
// Not interrupted on abort: tar cannot stop its in-flight file writes, so an abort during
|
||||
// the extraction lets it finish (local, bounded work) and is reported right after it.
|
||||
// This way no file is written after this function settles.
|
||||
signal?.throwIfAborted();
|
||||
await decompressTarAsync({
|
||||
archivePath,
|
||||
destinationDirectory: extractionDirectory,
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const matchingFiles = await glob(`**/*.(${extensions.join('|')})`, {
|
||||
absolute: true,
|
||||
@@ -177,6 +246,18 @@ export async function downloadBuildAsync({
|
||||
onlyFiles: false,
|
||||
onlyDirectories: false,
|
||||
});
|
||||
let matchingFilesRoot = extractionDirectory;
|
||||
|
||||
if (
|
||||
matchingFiles.length === 0 &&
|
||||
extensions.includes('app') &&
|
||||
(await isIosAppBundleAsync(extractionDirectory))
|
||||
) {
|
||||
const appBundlePath = `${extractionDirectory}.app`;
|
||||
await fs.promises.rename(extractionDirectory, appBundlePath);
|
||||
matchingFiles.push(appBundlePath);
|
||||
matchingFilesRoot = path.dirname(appBundlePath);
|
||||
}
|
||||
|
||||
if (matchingFiles.length === 0) {
|
||||
throw new UserError(
|
||||
@@ -189,8 +270,68 @@ export async function downloadBuildAsync({
|
||||
`Found ${matchingFiles.length} matching ${pluralize(
|
||||
matchingFiles.length,
|
||||
'entry'
|
||||
)}:\n${matchingFiles.map(f => `- ${path.relative(extractionDirectory, f)}`).join('\n')}`
|
||||
)}:\n${matchingFiles.map(f => `- ${path.relative(matchingFilesRoot, f)}`).join('\n')}`
|
||||
);
|
||||
|
||||
return { artifactPath: matchingFiles[0] };
|
||||
}
|
||||
|
||||
async function isIosAppBundleAsync(directory: string): Promise<boolean> {
|
||||
try {
|
||||
const infoPlist = await fs.promises.readFile(path.join(directory, 'Info.plist'));
|
||||
const isBinaryPlist = infoPlist.subarray(0, 8).toString('ascii') === 'bplist00';
|
||||
const parsedInfoPlist = (
|
||||
isBinaryPlist
|
||||
? bplistParser.parseBuffer(infoPlist)[0]
|
||||
: plist.parse(infoPlist.toString('utf8'))
|
||||
) as Record<string, unknown> | undefined;
|
||||
|
||||
return parsedInfoPlist?.CFBundlePackageType === 'APPL';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function parseHttpApplicationArchiveUrl(value: unknown): string {
|
||||
try {
|
||||
const applicationArchiveUrl = z.string().parse(value);
|
||||
const parsedUrl = new URL(applicationArchiveUrl);
|
||||
assert(parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:');
|
||||
return applicationArchiveUrl;
|
||||
} catch {
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_BUILD_INVALID_APPLICATION_ARCHIVE_URL',
|
||||
'application_archive_url must be a valid HTTP or HTTPS URL.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveArchiveFilename({
|
||||
response,
|
||||
extensions,
|
||||
}: {
|
||||
response: Awaited<ReturnType<typeof fetch>>;
|
||||
extensions: string[];
|
||||
}): string {
|
||||
const contentDispositionHeader = response.headers.get('content-disposition');
|
||||
let headerFilename: string | undefined;
|
||||
if (contentDispositionHeader) {
|
||||
try {
|
||||
headerFilename = contentDisposition.parse(contentDispositionHeader).parameters.filename;
|
||||
} catch {
|
||||
// Ignore malformed Content-Disposition headers and fall back to the response URL.
|
||||
}
|
||||
}
|
||||
|
||||
const urlFilename = path.basename(new URL(response.url).pathname);
|
||||
let archiveFilename = path.basename(headerFilename ?? urlFilename ?? '');
|
||||
if (!archiveFilename || archiveFilename === '.' || archiveFilename === '..') {
|
||||
archiveFilename = 'application';
|
||||
}
|
||||
if (!path.extname(archiveFilename) && extensions.length === 1) {
|
||||
archiveFilename = `${archiveFilename}.${extensions[0]}`;
|
||||
}
|
||||
|
||||
// URL and header filenames may contain percent-encoded or unsafe filesystem characters.
|
||||
return archiveFilename.replace(/([^a-z0-9.-]+)/gi, '_');
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
BuildStepEnv,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
export function createInstallBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'install_build',
|
||||
name: 'Install build',
|
||||
__metricsId: 'eas/install_build',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'artifact_path',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'application_identifier',
|
||||
required: true,
|
||||
}),
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'activity_name',
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
fn: async ({ global, logger }, { inputs, outputs, env }) => {
|
||||
const artifactPath = z.string().min(1).parse(inputs.artifact_path.value);
|
||||
const { applicationIdentifier, activityName } = await installBuildAsync({
|
||||
artifactPath,
|
||||
runtimePlatform: global.runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
outputs.application_identifier.set(applicationIdentifier);
|
||||
if (activityName) {
|
||||
outputs.activity_name.set(activityName);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function installBuildAsync({
|
||||
artifactPath,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
artifactPath: string;
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<{ applicationIdentifier: string; activityName?: string }> {
|
||||
const artifactStat = await fs.promises.stat(artifactPath).catch(err => {
|
||||
throw new UserError(
|
||||
'EAS_INSTALL_BUILD_INVALID_ARTIFACT',
|
||||
`Build artifact does not exist at ${artifactPath}.`,
|
||||
{ cause: err }
|
||||
);
|
||||
});
|
||||
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
if (path.extname(artifactPath) !== '.app' || !artifactStat.isDirectory()) {
|
||||
throw new UserError(
|
||||
'EAS_INSTALL_BUILD_INVALID_ARTIFACT',
|
||||
'iOS Simulator sessions require a .app build artifact.'
|
||||
);
|
||||
}
|
||||
|
||||
const infoPlistPath = path.join(artifactPath, 'Info.plist');
|
||||
const { stdout } = await spawn(
|
||||
'plutil',
|
||||
['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', infoPlistPath],
|
||||
{ stdio: 'pipe', env }
|
||||
);
|
||||
const applicationIdentifier = stdout.trim();
|
||||
if (!applicationIdentifier) {
|
||||
throw new UserError(
|
||||
'EAS_INSTALL_BUILD_MISSING_IDENTIFIER',
|
||||
`Could not read CFBundleIdentifier from ${infoPlistPath}.`
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`Installing ${artifactPath} on the iOS Simulator.`);
|
||||
await spawn('xcrun', ['simctl', 'install', 'booted', artifactPath], { env, logger });
|
||||
return { applicationIdentifier };
|
||||
}
|
||||
|
||||
if (path.extname(artifactPath) !== '.apk' || !artifactStat.isFile()) {
|
||||
throw new UserError(
|
||||
'EAS_INSTALL_BUILD_INVALID_ARTIFACT',
|
||||
'Android Emulator sessions require an .apk build artifact.'
|
||||
);
|
||||
}
|
||||
|
||||
const { stdout } = await spawn('aapt2', ['dump', 'badging', artifactPath], {
|
||||
stdio: 'pipe',
|
||||
env,
|
||||
});
|
||||
const applicationIdentifier = stdout.match(/package: name='([^']+)'/)?.[1];
|
||||
const activityName = stdout.match(/launchable-activity: name='([^']+)'/)?.[1];
|
||||
if (!applicationIdentifier) {
|
||||
throw new UserError(
|
||||
'EAS_INSTALL_BUILD_MISSING_IDENTIFIER',
|
||||
`Could not read an Android application identifier from ${artifactPath}.`
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`Installing ${artifactPath} on the Android Emulator.`);
|
||||
await spawn('adb', ['install', '-r', artifactPath], { env, logger });
|
||||
return { applicationIdentifier, activityName };
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import downloadFile from '@expo/downloader';
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import {
|
||||
@@ -14,8 +16,15 @@ import assert from 'assert';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import semver from 'semver';
|
||||
|
||||
import { MaestroBackend, resolveMaestroBackend } from './maestroBackend';
|
||||
import { Datadog } from '../../datadog';
|
||||
import { getXcodeVersionAsync } from '../../ios/xcode';
|
||||
import { IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
|
||||
import { getProxiedDownloadUrl } from '../../utils/download';
|
||||
|
||||
const MAESTRO_RUNNER_WDA_CACHE_DOWNLOAD_TIMEOUT_MS = 20_000;
|
||||
|
||||
export function createInstallMaestroBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
@@ -29,6 +38,11 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'backend',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
@@ -37,22 +51,28 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger, global }, { inputs, env, outputs }) => {
|
||||
const requestedMaestroVersion = inputs.maestro_version.value as string | undefined;
|
||||
const { value: currentMaestroVersion } = await asyncResult(getMaestroVersion({ env }));
|
||||
const backend = resolveMaestroBackend({
|
||||
input: inputs.backend.value,
|
||||
env,
|
||||
});
|
||||
const requestedVersion = inputs.maestro_version.value as string | undefined;
|
||||
const { value: currentMaestroVersion } = await asyncResult(
|
||||
getMaestroVersion({ env, backend })
|
||||
);
|
||||
|
||||
// When not running in EAS Build VM, do not modify local environment.
|
||||
if (env.EAS_BUILD_RUNNER !== 'eas-build') {
|
||||
const currentIsJavaInstalled = await isJavaInstalled({ env });
|
||||
const currentIsIdbInstalled = await isIdbInstalled({ env });
|
||||
const needsToInstallJava = backend === 'maestro' && !(await isJavaInstalled({ env }));
|
||||
const needsToInstallIdb = backend === 'maestro' && !(await isIdbInstalled({ env }));
|
||||
|
||||
if (!currentIsJavaInstalled) {
|
||||
if (needsToInstallJava) {
|
||||
logger.warn(
|
||||
'It seems Java is not installed. It is required to run Maestro. If the job fails, this may be the reason.'
|
||||
);
|
||||
logger.info('');
|
||||
}
|
||||
|
||||
if (!currentIsIdbInstalled) {
|
||||
if (needsToInstallIdb) {
|
||||
logger.warn(
|
||||
'It seems IDB is not installed. Maestro requires it to run flows on iOS Simulator. If the job fails, this may be the reason.'
|
||||
);
|
||||
@@ -61,27 +81,32 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
|
||||
|
||||
if (!currentMaestroVersion) {
|
||||
logger.warn(
|
||||
'It seems Maestro is not installed. Please install Maestro manually and rerun the job.'
|
||||
`It seems ${backend} is not installed. Please install it manually and rerun the job.`
|
||||
);
|
||||
logger.info('');
|
||||
}
|
||||
|
||||
// Guide is helpful in these two cases, it doesn't mention Java.
|
||||
if (!currentIsIdbInstalled || !currentMaestroVersion) {
|
||||
if (backend === 'maestro' && (needsToInstallIdb || !currentMaestroVersion)) {
|
||||
logger.warn(
|
||||
'For more info, check out Maestro installation guide: https://maestro.mobile.dev/getting-started/installing-maestro'
|
||||
);
|
||||
} else if (backend === 'maestro-runner' && !currentMaestroVersion) {
|
||||
logger.warn(
|
||||
'For more info, check out maestro-runner installation guide: https://github.com/devicelab-dev/maestro-runner#install'
|
||||
);
|
||||
}
|
||||
|
||||
if (currentMaestroVersion) {
|
||||
outputs.maestro_version.set(currentMaestroVersion);
|
||||
logger.info(`Maestro ${currentMaestroVersion} is ready.`);
|
||||
logger.info(`${backend} ${currentMaestroVersion} is ready.`);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await isJavaInstalled({ env }))) {
|
||||
const needsToInstallJava = backend === 'maestro' && !(await isJavaInstalled({ env }));
|
||||
if (needsToInstallJava) {
|
||||
if (global.runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
logger.info('Installing Java');
|
||||
await installJavaFromGcs({ logger, env });
|
||||
@@ -94,55 +119,303 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
|
||||
}
|
||||
|
||||
// IDB is only a requirement on macOS.
|
||||
if (
|
||||
const needsToInstallIdb =
|
||||
backend === 'maestro' &&
|
||||
global.runtimePlatform === BuildRuntimePlatform.DARWIN &&
|
||||
!(await isIdbInstalled({ env }))
|
||||
) {
|
||||
!(await isIdbInstalled({ env }));
|
||||
if (needsToInstallIdb) {
|
||||
logger.info('Installing IDB');
|
||||
await installIdbFromBrew({ logger, env });
|
||||
}
|
||||
|
||||
// Skip installing if the input sets a specific Maestro version to install
|
||||
// and it is already installed which happens when developing on a local computer.
|
||||
if (
|
||||
!currentMaestroVersion ||
|
||||
(requestedMaestroVersion && requestedMaestroVersion !== currentMaestroVersion)
|
||||
) {
|
||||
await installMaestro({
|
||||
version: requestedMaestroVersion,
|
||||
global,
|
||||
switch (backend) {
|
||||
case 'maestro':
|
||||
// Skip installing if the input sets a specific Maestro version to install
|
||||
// and it is already installed, either on a build image or a local computer.
|
||||
if (
|
||||
!currentMaestroVersion ||
|
||||
(requestedVersion && requestedVersion !== currentMaestroVersion)
|
||||
) {
|
||||
await installMaestro({ version: requestedVersion, global, logger, env });
|
||||
}
|
||||
break;
|
||||
case 'maestro-runner': {
|
||||
let maestroRunnerVersionToInstall = requestedVersion;
|
||||
const currentMaestroRunnerVersion = semver.coerce(currentMaestroVersion)?.version;
|
||||
const requestedMaestroRunnerVersion =
|
||||
requestedVersion && requestedVersion !== 'latest'
|
||||
? semver.valid(requestedVersion)
|
||||
: null;
|
||||
if (
|
||||
global.runtimePlatform === BuildRuntimePlatform.DARWIN &&
|
||||
((requestedMaestroRunnerVersion &&
|
||||
semver.gte(requestedMaestroRunnerVersion, '1.1.16')) ||
|
||||
requestedVersion === 'latest' ||
|
||||
(requestedVersion === undefined &&
|
||||
(!currentMaestroRunnerVersion || semver.gt(currentMaestroRunnerVersion, '1.1.15'))))
|
||||
) {
|
||||
// `getXcodeVersionAsync` returns the raw version (e.g. "16.4"), which
|
||||
// `semver.coerce` normalizes (e.g. "16.4.0") for comparison and display.
|
||||
const xcodeVersion = semver.coerce(await getXcodeVersionAsync({ env }));
|
||||
|
||||
// maestro-runner 1.1.16 added `arch` to its xcodebuild destination. Xcode versions
|
||||
// below 26 reject that option, so use the last compatible maestro-runner version.
|
||||
if (xcodeVersion && semver.lt(xcodeVersion, '26.0.0')) {
|
||||
if (requestedMaestroRunnerVersion) {
|
||||
throw new UserError(
|
||||
'ERR_MAESTRO_INVALID_INPUT',
|
||||
`maestro-runner ${requestedVersion} is not compatible with Xcode ${xcodeVersion}. Use maestro-runner 1.1.15 or an Xcode 26+ image.`
|
||||
);
|
||||
}
|
||||
maestroRunnerVersionToInstall = '1.1.15';
|
||||
logger.info(
|
||||
`Xcode ${xcodeVersion} requires maestro-runner ${maestroRunnerVersionToInstall}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!currentMaestroVersion ||
|
||||
(maestroRunnerVersionToInstall &&
|
||||
maestroRunnerVersionToInstall !== currentMaestroVersion)
|
||||
) {
|
||||
await installMaestroRunner({
|
||||
version: maestroRunnerVersionToInstall,
|
||||
global,
|
||||
logger,
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const maestroVersionResult = await asyncResult(getMaestroVersion({ env, backend }));
|
||||
if (!maestroVersionResult.ok) {
|
||||
logger.error(maestroVersionResult.reason, 'Failed to get Maestro version.');
|
||||
|
||||
throw new Error(`Failed to ensure ${backend} is installed.`);
|
||||
}
|
||||
|
||||
if (backend === 'maestro-runner' && global.runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await installMaestroRunnerWdaCache({
|
||||
logger,
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
const maestroVersionResult = await asyncResult(getMaestroVersion({ env }));
|
||||
if (!maestroVersionResult.ok) {
|
||||
logger.error(maestroVersionResult.reason, 'Failed to get Maestro version.');
|
||||
|
||||
throw new Error('Failed to ensure Maestro is installed.');
|
||||
}
|
||||
|
||||
logger.info(`Maestro ${maestroVersionResult.value} is ready.`);
|
||||
logger.info(`${backend} ${maestroVersionResult.value} is ready.`);
|
||||
outputs.maestro_version.set(maestroVersionResult.value);
|
||||
|
||||
Datadog.distribution('eas.maestro.install', 1, {
|
||||
maestro_version: maestroVersionResult.value,
|
||||
maestro_backend: backend,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function getMaestroVersion({ env }: { env: BuildStepEnv }): Promise<string> {
|
||||
const { stdout } = await spawn('maestro', ['--version'], { stdio: 'pipe', env });
|
||||
// `maestro --version` can print an analytics notice to stdout before the version,
|
||||
// e.g. "Anonymous analytics enabled. To opt out, set MAESTRO_CLI_NO_ANALYTICS...\n2.0.10".
|
||||
// Take the last version-looking token: the real version is printed after the notice, so
|
||||
// this stays correct even if the notice itself contains a version-like string. Keeps the
|
||||
// step output and the eas.maestro.install metric tag clean. Best-effort only: fall back to
|
||||
// the raw output if none is found, so we never fail the build over a version string.
|
||||
const versions = stdout.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/g);
|
||||
return versions?.at(-1) ?? stdout.trim();
|
||||
async function installMaestroRunnerWdaCache({
|
||||
logger,
|
||||
env,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
env: BuildStepEnv;
|
||||
}): Promise<void> {
|
||||
if (!env.HOME) {
|
||||
logger.warn(
|
||||
'Skipping the prebuilt WebDriverAgent cache because the $HOME environment variable is empty.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const maestroRunnerHome = path.join(env.HOME, '.maestro-runner');
|
||||
|
||||
try {
|
||||
const wdaVersion = await getMaestroRunnerWdaVersion({ maestroRunnerHome });
|
||||
if (!wdaVersion) {
|
||||
logger.info(
|
||||
'Skipping the prebuilt WebDriverAgent cache because the installed WDA version is unknown.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const xcodeVersion = await getXcodeVersionAsync({ env });
|
||||
const iosRuntimes = await IosSimulatorUtils.getAvailableRuntimesAsync({ env });
|
||||
const iosRuntimeIdentifierPrefix = 'com.apple.CoreSimulator.SimRuntime.iOS-';
|
||||
// maestro-runner derives its WDA cache key from the runtime identifier, not from the runtime
|
||||
// version. These values can differ for patched runtimes, for example the iOS-26-3 identifier
|
||||
// can report version 26.3.1.
|
||||
const iosRuntimeVersions = [
|
||||
...new Set(
|
||||
iosRuntimes.map(runtime =>
|
||||
runtime.identifier.slice(iosRuntimeIdentifierPrefix.length).replaceAll('-', '.')
|
||||
)
|
||||
),
|
||||
];
|
||||
if (iosRuntimeVersions.length === 0) {
|
||||
logger.info(
|
||||
'Skipping the prebuilt WebDriverAgent cache because no iOS runtime is available.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const tempDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'install_maestro_runner_wda_cache')
|
||||
);
|
||||
try {
|
||||
const archivePath = path.join(tempDirectory, 'wda-cache.tar.gz');
|
||||
const directArchiveUrl =
|
||||
`https://storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/` +
|
||||
`xcode-${encodeURIComponent(xcodeVersion)}-wda-${encodeURIComponent(wdaVersion)}.tar.gz`;
|
||||
const proxiedArchiveUrl = getProxiedDownloadUrl({
|
||||
directUrl: directArchiveUrl,
|
||||
proxyBaseUrl: env.EAS_BUILD_COCOAPODS_CACHE_URL,
|
||||
});
|
||||
|
||||
logger.info(`Downloading the prebuilt WebDriverAgent cache for Xcode ${xcodeVersion}`);
|
||||
if (proxiedArchiveUrl) {
|
||||
try {
|
||||
await downloadFile(proxiedArchiveUrl, archivePath, {
|
||||
retry: 3,
|
||||
timeout: MAESTRO_RUNNER_WDA_CACHE_DOWNLOAD_TIMEOUT_MS,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
{ err },
|
||||
'Failed to download the prebuilt WebDriverAgent cache via the proxy; falling back to the direct URL.'
|
||||
);
|
||||
await downloadFile(directArchiveUrl, archivePath, {
|
||||
retry: 3,
|
||||
timeout: MAESTRO_RUNNER_WDA_CACHE_DOWNLOAD_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await downloadFile(directArchiveUrl, archivePath, {
|
||||
retry: 3,
|
||||
timeout: MAESTRO_RUNNER_WDA_CACHE_DOWNLOAD_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
await fs.promises.mkdir(maestroRunnerHome, { recursive: true });
|
||||
await spawn('tar', ['-xzf', archivePath, '-C', maestroRunnerHome], { logger, env });
|
||||
|
||||
const genericProductsDirectory = path.join(
|
||||
maestroRunnerHome,
|
||||
'cache',
|
||||
'wda-builds',
|
||||
'generic',
|
||||
'DerivedData',
|
||||
'Build',
|
||||
'Products'
|
||||
);
|
||||
for (const runtimeVersion of iosRuntimeVersions) {
|
||||
const productsDirectory = path.join(
|
||||
maestroRunnerHome,
|
||||
'cache',
|
||||
'wda-builds',
|
||||
`sim-ios${runtimeVersion}-iphone`,
|
||||
'DerivedData',
|
||||
'Build',
|
||||
'Products'
|
||||
);
|
||||
await fs.promises.cp(genericProductsDirectory, productsDirectory, { recursive: true });
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Installed the prebuilt WebDriverAgent cache for iOS ${iosRuntimeVersions.join(', ')}.`
|
||||
);
|
||||
} finally {
|
||||
await fs.promises.rm(tempDirectory, { force: true, recursive: true });
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Failed to install the prebuilt WebDriverAgent cache. maestro-runner will build WebDriverAgent when it is needed.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function getMaestroRunnerWdaVersion({
|
||||
maestroRunnerHome,
|
||||
}: {
|
||||
maestroRunnerHome: string;
|
||||
}): Promise<string | null> {
|
||||
try {
|
||||
const packageJson = JSON.parse(
|
||||
await fs.promises.readFile(
|
||||
path.join(maestroRunnerHome, 'drivers', 'ios', 'WebDriverAgent', 'package.json'),
|
||||
'utf8'
|
||||
)
|
||||
);
|
||||
return typeof packageJson.version === 'string' ? packageJson.version : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getMaestroVersion({
|
||||
env,
|
||||
backend,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
backend: MaestroBackend;
|
||||
}): Promise<string> {
|
||||
switch (backend) {
|
||||
case 'maestro': {
|
||||
const { stdout } = await spawn('maestro', ['--version'], { stdio: 'pipe', env });
|
||||
// `maestro --version` can print an analytics notice to stdout before the version,
|
||||
// e.g. "Anonymous analytics enabled. To opt out, set MAESTRO_CLI_NO_ANALYTICS...\n2.0.10".
|
||||
// Take the last version-looking token: the real version is printed after the notice.
|
||||
const versions = stdout.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/g);
|
||||
return versions?.at(-1) ?? stdout.trim();
|
||||
}
|
||||
case 'maestro-runner': {
|
||||
const { stdout } = await spawn('maestro-runner', ['--version'], { stdio: 'pipe', env });
|
||||
// maestro-runner prints build information after its version. The Go runtime version in
|
||||
// that output is also semver-shaped, so read only the prefixed runner version.
|
||||
return /^maestro-runner\s+(\S+)/m.exec(stdout)?.[1] ?? stdout.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function installMaestroRunner({
|
||||
global,
|
||||
version,
|
||||
logger,
|
||||
env,
|
||||
}: {
|
||||
version?: string;
|
||||
logger: bunyan;
|
||||
global: BuildStepGlobalContext;
|
||||
env: BuildStepEnv;
|
||||
}): Promise<void> {
|
||||
logger.info('Fetching maestro-runner install script');
|
||||
const tempDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'install_maestro_runner'));
|
||||
try {
|
||||
const installMaestroRunnerScriptResponse = await fetch(
|
||||
'https://open.devicelab.dev/install/maestro-runner'
|
||||
);
|
||||
const installMaestroRunnerScript = await installMaestroRunnerScriptResponse.text();
|
||||
const scriptPath = path.join(tempDirectory, 'install_maestro_runner.sh');
|
||||
await fs.promises.writeFile(scriptPath, installMaestroRunnerScript, { mode: 0o777 });
|
||||
logger.info('Installing maestro-runner');
|
||||
assert(
|
||||
env.HOME,
|
||||
'Failed to infer directory to install maestro-runner in: $HOME environment variable is empty.'
|
||||
);
|
||||
await spawn(scriptPath, version && version !== 'latest' ? ['--version', version] : [], {
|
||||
logger,
|
||||
env,
|
||||
});
|
||||
const binDir = path.join(env.HOME, '.maestro-runner', 'bin');
|
||||
global.updateEnv({
|
||||
...global.env,
|
||||
PATH: `${binDir}:${global.env.PATH}`,
|
||||
});
|
||||
env.PATH = `${binDir}:${env.PATH}`;
|
||||
process.env.PATH = `${binDir}:${process.env.PATH}`;
|
||||
} finally {
|
||||
await fs.promises.rm(tempDirectory, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function installMaestro({
|
||||
@@ -186,10 +459,10 @@ async function installMaestro({
|
||||
const maestroBinDir = path.join(maestroDir, 'bin');
|
||||
global.updateEnv({
|
||||
...global.env,
|
||||
PATH: `${global.env.PATH}:${maestroBinDir}`,
|
||||
PATH: `${maestroBinDir}:${global.env.PATH}`,
|
||||
});
|
||||
env.PATH = `${env.PATH}:${maestroBinDir}`;
|
||||
process.env.PATH = `${process.env.PATH}:${maestroBinDir}`;
|
||||
env.PATH = `${maestroBinDir}:${env.PATH}`;
|
||||
process.env.PATH = `${maestroBinDir}:${process.env.PATH}`;
|
||||
} finally {
|
||||
await fs.promises.rm(tempDirectory, { force: true, recursive: true });
|
||||
}
|
||||
@@ -197,37 +470,64 @@ async function installMaestro({
|
||||
|
||||
async function isIdbInstalled({ env }: { env: BuildStepEnv }): Promise<boolean> {
|
||||
try {
|
||||
await spawn('idb', ['-h'], { ignoreStdio: true, env });
|
||||
await spawn('idb_companion', ['--version'], { ignoreStdio: true, env });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function installIdbFromBrew({
|
||||
export async function installIdbFromBrew({
|
||||
logger,
|
||||
env,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
env: BuildStepEnv;
|
||||
}): Promise<void> {
|
||||
// Unfortunately our Mac images sometimes have two Homebrew
|
||||
// installations. We should use the ARM64 one, located in /opt/homebrew.
|
||||
const brewPath = '/opt/homebrew/bin/brew';
|
||||
const localEnv = {
|
||||
...env,
|
||||
HOMEBREW_NO_AUTO_UPDATE: '1',
|
||||
HOMEBREW_NO_INSTALL_CLEANUP: '1',
|
||||
};
|
||||
try {
|
||||
// Unfortunately our Mac images sometimes have two Homebrew
|
||||
// installations. We should use the ARM64 one, located in /opt/homebrew.
|
||||
const brewPath = '/opt/homebrew/bin/brew';
|
||||
const localEnv = {
|
||||
...env,
|
||||
HOMEBREW_NO_AUTO_UPDATE: '1',
|
||||
HOMEBREW_NO_INSTALL_CLEANUP: '1',
|
||||
};
|
||||
|
||||
await spawn(brewPath, ['tap', 'facebook/fb'], {
|
||||
env: localEnv,
|
||||
logger,
|
||||
});
|
||||
await spawn(brewPath, ['install', 'idb-companion'], {
|
||||
env: localEnv,
|
||||
logger,
|
||||
});
|
||||
logger.info('Tapping facebook/fb...');
|
||||
await spawn(brewPath, ['tap', 'facebook/fb'], {
|
||||
env: localEnv,
|
||||
logger,
|
||||
});
|
||||
|
||||
const brewRepo = await spawn(brewPath, ['--repo', 'facebook/fb'], {
|
||||
env: localEnv,
|
||||
});
|
||||
const tapPath = brewRepo.stdout.trim();
|
||||
|
||||
// c0386793f59da10c619787f2aa18d938ef1d69c9 is hash for 1.1.8 release,
|
||||
// last known compatible version + post_install fix.
|
||||
const gitSha = 'c0386793f59da10c619787f2aa18d938ef1d69c9';
|
||||
logger.info('Checking out facebook/fb at [email protected]...');
|
||||
await spawn('git', ['fetch', 'origin', gitSha], {
|
||||
cwd: tapPath,
|
||||
logger,
|
||||
});
|
||||
await spawn('git', ['checkout', gitSha], {
|
||||
cwd: tapPath,
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info('Installing idb_companion v1.1.8...');
|
||||
await spawn(brewPath, ['install', 'facebook/fb/idb-companion'], {
|
||||
env: localEnv,
|
||||
logger,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new SystemError('Failed to install idb-companion required for Maestro to run.', {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function isJavaInstalled({ env }: { env: BuildStepEnv }): Promise<boolean> {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import { BuildFunction, BuildRuntimePlatform, BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { Sentry } from '../../sentry';
|
||||
|
||||
export function createInstallMitmproxyBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'install_mitmproxy',
|
||||
name: 'Install mitmproxy',
|
||||
__metricsId: 'eas/install_mitmproxy',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
|
||||
fn: async ({ logger }, { env }) => {
|
||||
if (await isMitmproxyAvailableAsync(env)) {
|
||||
logger.info('mitmproxy is already installed.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (env.EAS_BUILD_RUNNER !== 'eas-build') {
|
||||
logger.warn(
|
||||
'mitmproxy is not installed and network capture needs it. Install it with `brew install --cask mitmproxy` and rerun the job.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info('Installing mitmproxy with Homebrew.');
|
||||
await installMitmproxyWithHomebrewAsync({ env, logger });
|
||||
|
||||
if (!(await isMitmproxyAvailableAsync(env))) {
|
||||
throw new Error('`brew install --cask mitmproxy` succeeded but mitmdump is not on PATH.');
|
||||
}
|
||||
logger.info('Installed mitmproxy.');
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not install mitmproxy for the device session', error, {
|
||||
level: 'warning',
|
||||
});
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
'Could not install mitmproxy, so network capture will record nothing in this session. Re-run the session to retry the install, or run it without network capture.'
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function isMitmproxyAvailableAsync(env: BuildStepEnv): Promise<boolean> {
|
||||
return (await asyncResult(spawn('mitmdump', ['--version'], { env }))).ok;
|
||||
}
|
||||
|
||||
async function installMitmproxyWithHomebrewAsync({
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
await spawn('brew', ['install', '--cask', 'mitmproxy'], {
|
||||
env: { ...env, HOMEBREW_NO_AUTO_UPDATE: '1' },
|
||||
logger,
|
||||
});
|
||||
}
|
||||
Loaded 100 of 692 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user