mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
866febb1ba | ||
|
|
d98d26d962 | ||
|
|
d78adfeb0f | ||
|
|
54f0b5368c | ||
|
|
f30267f05f | ||
|
|
86fc9ebb80 | ||
|
|
94f473b1f3 | ||
|
|
a3a1d84ebd | ||
|
|
eab32ecb9f | ||
|
|
cdf4e811d5 | ||
|
|
d003bffa29 | ||
|
|
7d7ebb27f6 | ||
|
|
9276e22ff2 | ||
|
|
f41312cf38 | ||
|
|
f56c054be4 |
No files matched your search
@@ -1,10 +1,9 @@
|
||||
---
|
||||
description: Security and secrets. Injection, credential or secret leakage, unsafe shell/child-process use, missing validation at trust boundaries.
|
||||
alwaysRun: true
|
||||
# No model override: security runs on the config default (gpt-5.5, on the
|
||||
# subscription). To give it a stronger model, use the metered openai-api alias
|
||||
# from the mixed auth.providers setup (see the @expo/code-review-cli README) —
|
||||
# pro-tier models are excluded from the subscription.
|
||||
# Security is the highest-stakes agent, so it runs on the Opus tier (mirrors
|
||||
# the expo/code-review-cli repo's own roster).
|
||||
model: anthropic/claude-opus-5
|
||||
---
|
||||
|
||||
# Security & secrets
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
{
|
||||
// Default model for every pass — served by the ChatGPT/Codex subscription (see
|
||||
// auth below), so reviews have no marginal cost. Per-agent frontmatter can
|
||||
// override this. Override everything at runtime with REVIEWER_MODEL
|
||||
// (e.g. REVIEWER_MODEL=openai/gpt-5.4-mini for a cheaper local run).
|
||||
"model": "openai/gpt-5.5",
|
||||
// Default model for every pass. Anthropic models run through the Claude Code
|
||||
// CLI engine (`claude -p`), inferred per agent from the model id — see the
|
||||
// auth entry below for the credential. Per-agent frontmatter can override
|
||||
// this (security runs Opus). Override everything at runtime with
|
||||
// REVIEWER_MODEL (e.g. REVIEWER_MODEL=anthropic/claude-haiku-4-5 for a
|
||||
// cheaper local run).
|
||||
"model": "anthropic/claude-sonnet-5",
|
||||
|
||||
// Agents are every markdown file in agents/. shared.md (prepended to all) and
|
||||
// coordinator.md are reserved. Per-agent overrides go in each file's frontmatter.
|
||||
@@ -30,17 +32,16 @@
|
||||
"breakGlass": { "marker": "/skip-review" },
|
||||
"commentTag": "expo-ai-code-reviewer",
|
||||
|
||||
// ChatGPT/Codex subscription (OAuth): tokenEnv holds the ACCESS token from an
|
||||
// `opencode auth login` ChatGPT sign-in (`ecr setup-auth` extracts it) — a
|
||||
// plain bearer with no rotation involvement; re-mint before its ~10-day expiry
|
||||
// (doctor + the run preflight warn first). NEVER use the refresh token here:
|
||||
// it is single-use and dies on first rotation. All models above are on the
|
||||
// subscription's allowlist, so reviews draw no metered spend. (To add pro-tier
|
||||
// models later, see the mixed auth.providers setup in the @expo/code-review-cli
|
||||
// README.)
|
||||
// Anthropic models always run through the Claude Code CLI — the engine is
|
||||
// inferred per agent from the `anthropic/…` model id, so this entry only
|
||||
// supplies the credential. The tokenEnv holds the shared review credential
|
||||
// (an `sk-ant-oat…` token from `claude setup-token`, or an `sk-ant-api…`
|
||||
// Console key — the CLI reads either; the engine classifies by shape).
|
||||
// ECR_EXPECTED_TOKEN_ENV (repo variable / workflow fallback) must equal this
|
||||
// tokenEnv. Locally, an active `claude` login is enough.
|
||||
"auth": {
|
||||
"mode": "oauth",
|
||||
"provider": "openai",
|
||||
"tokenEnv": "CODEX_OAUTH_ACCESS_TOKEN"
|
||||
"providers": {
|
||||
"anthropic": { "tokenEnv": "CLAUDE_CODE_REVIEW_SHARED_API_TOKEN" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
# No model override: the coordinator runs on the config default (gpt-5.5, on the
|
||||
# subscription). It only consolidates text (no repo tools), so the default is
|
||||
# plenty; override here if consolidation quality ever needs a stronger model.
|
||||
# No model override: the coordinator runs on the config default
|
||||
# (claude-sonnet-5). It only consolidates text (no repo tools), so the default
|
||||
# is plenty; override here if consolidation quality ever needs a stronger model.
|
||||
---
|
||||
|
||||
# Coordinator — consolidation & decision
|
||||
|
||||
@@ -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
|
||||
@@ -19,7 +19,7 @@ permissions:
|
||||
|
||||
env:
|
||||
# Published reviewer run via npx (override with repo variable ECR_VERSION).
|
||||
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.6.0' }}
|
||||
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.10.0' }}
|
||||
|
||||
concurrency:
|
||||
group: ai-code-review-cmd-${{ github.event.issue.number }}
|
||||
@@ -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'
|
||||
@@ -116,13 +118,20 @@ jobs:
|
||||
curl -fsSL https://getunblocked.com/install.sh | bash
|
||||
echo "$HOME/.unblocked/bin" >> "$GITHUB_PATH"
|
||||
|
||||
# The anthropic/… models run through the Claude Code CLI (claude-code
|
||||
# engine). Pinned exactly — see expo-code-review.yml.
|
||||
- name: Install Claude Code CLI
|
||||
if: steps.cmd.outputs.run == 'true'
|
||||
run: npm install -g @anthropic-ai/[email protected]
|
||||
|
||||
- name: Run AI review
|
||||
if: steps.cmd.outputs.run == 'true'
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CODEX_OAUTH_ACCESS_TOKEN: ${{ secrets.CODEX_OAUTH_ACCESS_TOKEN }}
|
||||
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CODEX_OAUTH_ACCESS_TOKEN' }}
|
||||
# Shared Anthropic review credential — see expo-code-review.yml.
|
||||
CLAUDE_CODE_REVIEW_SHARED_API_TOKEN: ${{ secrets.CLAUDE_CODE_REVIEW_SHARED_API_TOKEN }}
|
||||
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CLAUDE_CODE_REVIEW_SHARED_API_TOKEN' }}
|
||||
# Unblocked CLI headless auth (PAT or team access token). Absent =>
|
||||
# the CLI stays unauthenticated; the review still runs.
|
||||
UNBLOCKED_API_TOKEN: ${{ secrets.UNBLOCKED_API_TOKEN }}
|
||||
@@ -130,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[@]}"
|
||||
@@ -18,7 +18,7 @@ permissions:
|
||||
|
||||
env:
|
||||
# Published reviewer run via npx (override with repo variable ECR_VERSION).
|
||||
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.6.0' }}
|
||||
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.10.0' }}
|
||||
|
||||
concurrency:
|
||||
group: ai-code-review-dismiss-${{ github.event.issue.number }}
|
||||
@@ -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'
|
||||
|
||||
@@ -13,9 +13,9 @@ permissions:
|
||||
env:
|
||||
# The reviewer is the published @expo/code-review-cli package, run via npx — the
|
||||
# repo only carries the `.expo-code-review/` config, not the engine source.
|
||||
# Override with a repo variable ECR_VERSION to pin/bump; defaults to 0.6.x
|
||||
# (0.6.0 = trusted base-commit configuration + PR-head runtime-config scrub).
|
||||
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.6.0' }}
|
||||
# Override with a repo variable ECR_VERSION to pin/bump; defaults to 0.10.x
|
||||
# (the claude-code engine the anthropic/… models below run on).
|
||||
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.10.0' }}
|
||||
|
||||
concurrency:
|
||||
group: ai-code-review-${{ github.event.pull_request.number }}
|
||||
@@ -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
|
||||
@@ -125,6 +127,14 @@ jobs:
|
||||
curl -fsSL https://getunblocked.com/install.sh | bash
|
||||
echo "$HOME/.unblocked/bin" >> "$GITHUB_PATH"
|
||||
|
||||
# The anthropic/… models in .expo-code-review/config.jsonc run through the
|
||||
# Claude Code CLI (the claude-code engine). Pinned exactly — an unpinned
|
||||
# CLI would silently drift on every CI run; bump it deliberately (mirrors
|
||||
# expo/code-review-cli's own workflow).
|
||||
- name: Install Claude Code CLI
|
||||
if: steps.resolve.outputs.run == 'true'
|
||||
run: npm install -g @anthropic-ai/[email protected]
|
||||
|
||||
# SECURITY: the TRUSTED BASE checkout above includes .expo-code-review/
|
||||
# config.jsonc, whose auth.tokenEnv names the env var the CLI forwards as the
|
||||
# model credential. `ecr verify-config` is the canonical guard (ships with the
|
||||
@@ -136,7 +146,7 @@ jobs:
|
||||
- name: Guard config tokenEnv (root + routing + all scopes)
|
||||
if: steps.resolve.outputs.run == 'true'
|
||||
env:
|
||||
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CODEX_OAUTH_ACCESS_TOKEN' }}
|
||||
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CLAUDE_CODE_REVIEW_SHARED_API_TOKEN' }}
|
||||
run: npx --yes -p "@expo/code-review-cli@$ECR_VERSION" ecr verify-config
|
||||
|
||||
- name: Run AI review
|
||||
@@ -148,14 +158,14 @@ jobs:
|
||||
env:
|
||||
# GitHub token scoped by the permissions block above (PR comments only).
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# ChatGPT/Codex subscription ACCESS token (auth.mode "oauth" in
|
||||
# .expo-code-review/config.jsonc reads this env var) — used as-is, no
|
||||
# rotation involvement; re-mint before its ~10-day expiry. All reviews
|
||||
# run on the subscription.
|
||||
CODEX_OAUTH_ACCESS_TOKEN: ${{ secrets.CODEX_OAUTH_ACCESS_TOKEN }}
|
||||
# Shared Anthropic review credential (auth.providers.anthropic in
|
||||
# .expo-code-review/config.jsonc reads this env var). The claude-code
|
||||
# engine forwards it to the Claude Code CLI as the only credential —
|
||||
# an `sk-ant-oat…` setup-token or an `sk-ant-api…` Console key both work.
|
||||
CLAUDE_CODE_REVIEW_SHARED_API_TOKEN: ${{ secrets.CLAUDE_CODE_REVIEW_SHARED_API_TOKEN }}
|
||||
# Layer-1 runtime auth lock (mirrors the guard step): the CLI refuses to
|
||||
# run when the tokenEnv the loaded config honors differs from this.
|
||||
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CODEX_OAUTH_ACCESS_TOKEN' }}
|
||||
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CLAUDE_CODE_REVIEW_SHARED_API_TOKEN' }}
|
||||
# Unblocked CLI headless auth (PAT or team access token). Absent =>
|
||||
# the CLI stays unauthenticated; the review still runs.
|
||||
UNBLOCKED_API_TOKEN: ${{ secrets.UNBLOCKED_API_TOKEN }}
|
||||
@@ -164,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
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
staging_only:
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
description: "Only move the latest-eas-build-staging tag, not the latest-eas-build tag"
|
||||
version:
|
||||
type: string
|
||||
required: true
|
||||
description: "Version number to move the tag to"
|
||||
dry_run:
|
||||
type: boolean
|
||||
required: true
|
||||
default: true
|
||||
description: "Run the workflow in dry run mode, without making any NPM changes"
|
||||
|
||||
name: Move NPM tags used by EAS builds and workflows
|
||||
|
||||
jobs:
|
||||
move-tag:
|
||||
name: Move NPM tags used by EAS builds and workflows
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.EXPO_BOT_PAT }}
|
||||
NPM_ACCESS_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
|
||||
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
|
||||
INPUT_STAGING_ONLY: ${{ github.event.inputs.staging_only }}
|
||||
steps:
|
||||
- uses: actions/setup-node@7c12f8017d5436eb855f1ed4399f037a36fbd9e8 # v2
|
||||
with:
|
||||
registry-url: "https://registry.npmjs.org/"
|
||||
scope: "expo"
|
||||
node-version: 22
|
||||
- name: Resolve version (fails if the version does not exist)
|
||||
run: |
|
||||
set -o pipefail
|
||||
npm pack --dry-run eas-cli@$INPUT_VERSION
|
||||
- name: Add latest-eas-build-staging tag
|
||||
run: |
|
||||
cmd="npm dist-tag add eas-cli@$INPUT_VERSION latest-eas-build-staging"
|
||||
if [[ "$INPUT_DRY_RUN" == "true" ]]; then
|
||||
echo "Running in dry run mode, skipping version check"
|
||||
echo "cmd = $cmd"
|
||||
exit 0
|
||||
fi
|
||||
echo //registry.npmjs.org/:_authToken=$NPM_ACCESS_TOKEN > ~/.npmrc
|
||||
$cmd
|
||||
- name: Add latest-eas-build tag
|
||||
run: |
|
||||
cmd="npm dist-tag add eas-cli@$INPUT_VERSION latest-eas-build"
|
||||
if [[ "$INPUT_DRY_RUN" == "true" ]]; then
|
||||
echo "Running in dry run mode, skipping version check"
|
||||
echo "cmd = $cmd"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$INPUT_STAGING_ONLY" == "true" ]]; then
|
||||
echo "Only moving the staging tag, skipping the production tag"
|
||||
exit 0
|
||||
fi
|
||||
echo //registry.npmjs.org/:_authToken=$NPM_ACCESS_TOKEN > ~/.npmrc
|
||||
$cmd
|
||||
@@ -0,0 +1,58 @@
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
type: string
|
||||
required: false
|
||||
description: "eas-cli version to set as PRODUCTION in cli-versions.json. Defaults to the current STAGING value."
|
||||
|
||||
name: Promote eas-cli to production
|
||||
|
||||
jobs:
|
||||
promote-production:
|
||||
name: Bump PRODUCTION in cli-versions.json
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
token: ${{ secrets.EXPO_BOT_PAT }}
|
||||
- name: Ensure main is checked out
|
||||
run: |
|
||||
git fetch
|
||||
git checkout main
|
||||
- name: Resolve version
|
||||
run: |
|
||||
version="$INPUT_VERSION"
|
||||
if [ -z "$version" ]; then
|
||||
version="$(jq -r .STAGING cli-versions.json)"
|
||||
fi
|
||||
echo "PRODUCTION_VERSION=$version" >> "$GITHUB_ENV"
|
||||
- name: Verify version is an exact, published eas-cli version
|
||||
run: |
|
||||
resolved="$(npm view "eas-cli@$PRODUCTION_VERSION" version 2>/dev/null || true)"
|
||||
if [ "$resolved" != "$PRODUCTION_VERSION" ]; then
|
||||
echo "'$PRODUCTION_VERSION' is not an exact published eas-cli version." >&2
|
||||
echo "npm resolved it to: '${resolved:-<nothing>}'." >&2
|
||||
echo "Provide an exact version such as 21.5.0, not a range or dist-tag." >&2
|
||||
exit 1
|
||||
fi
|
||||
- name: Bump PRODUCTION in cli-versions.json
|
||||
run: |
|
||||
jq --arg version "$PRODUCTION_VERSION" '.PRODUCTION = $version' \
|
||||
cli-versions.json > cli-versions.json.tmp
|
||||
mv cli-versions.json.tmp cli-versions.json
|
||||
- name: Commit and push
|
||||
run: |
|
||||
if git diff --quiet cli-versions.json; then
|
||||
echo "PRODUCTION is already $PRODUCTION_VERSION; nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
git config --global user.email "[email protected]"
|
||||
git config --global user.name "Expo CI"
|
||||
git add cli-versions.json
|
||||
git commit -m "bump PRODUCTION to $PRODUCTION_VERSION in cli-versions.json"
|
||||
git push
|
||||
@@ -37,20 +37,20 @@ jobs:
|
||||
run: yarn build
|
||||
- name: Publish packages to npm
|
||||
run: yarn run -T lerna publish from-package --yes --no-verify-access
|
||||
- name: Add latest-eas-build-staging tag
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
|
||||
- name: Verify eas-cli was published as latest on npm
|
||||
run: |
|
||||
VERSION=$(cat lerna.json | jq -r .version)
|
||||
echo //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN > ~/.npmrc
|
||||
npm dist-tag add eas-cli@$VERSION latest-eas-build-staging
|
||||
- name: Add latest-eas-build tag
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
|
||||
run: |
|
||||
VERSION=$(cat lerna.json | jq -r .version)
|
||||
echo //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN > ~/.npmrc
|
||||
npm dist-tag add eas-cli@$VERSION latest-eas-build
|
||||
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
|
||||
@@ -66,30 +66,35 @@ jobs:
|
||||
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: Update changelog
|
||||
working-directory: ./scripts
|
||||
run: yarn release-changelog $EAS_CLI_VERSION > /tmp/current-changelog
|
||||
run: yarn release-changelog "$EAS_CLI_VERSION" > /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
|
||||
run: |
|
||||
git fetch
|
||||
git checkout 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 CHANGELOG.md
|
||||
git commit --allow-empty -m "update CHANGELOG.md"
|
||||
git add cli-versions.json CHANGELOG.md
|
||||
git commit --allow-empty -m "update CHANGELOG.md and bump STAGING in cli-versions.json"
|
||||
git push
|
||||
- name: Prepare changelog for Slack
|
||||
run: |
|
||||
tail -n +3 /tmp/current-changelog > /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:
|
||||
|
||||
@@ -60,6 +60,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
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,83 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 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
|
||||
|
||||
## [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
|
||||
|
||||
- [reviewer] Migrate AI code reviews from GPT to Claude models on the claude-code engine (Sonnet 5 by default, Opus 5 for the security agent). ([#4148](https://github.com/expo/eas-cli/pull/4148) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [21.5.1](https://github.com/expo/eas-cli/releases/tag/v21.5.1) - 2026-08-03
|
||||
|
||||
## [21.5.0](https://github.com/expo/eas-cli/releases/tag/v21.5.0) - 2026-08-03
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `eas submit:list`, `eas submit:view`, `eas submit:retry`, `eas submit:cancel`, and `eas submit:status` commands; list and view include the runtime version and fingerprint of the submitted build, and status shows the live App Store version and TestFlight build states cross-referenced with EAS submissions (Google Play app status is not available through EAS yet). ([#4134](https://github.com/expo/eas-cli/pull/4134) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-build-job] Add optional `ssh` field on build/job payloads. ([#4083](https://github.com/expo/eas-cli/pull/4083) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-build-job] Add an `SSH_SESSION` build phase for upcoming worker SSH support. ([#4029](https://github.com/expo/eas-cli/pull/4029) by [@gwdp](https://github.com/gwdp))
|
||||
@@ -17,12 +94,15 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
- [build-tools] Pass a metrics CORS origin to serve-sim so the dashboard can read a session's live CPU/memory stream cross-origin. ([#3990](https://github.com/expo/eas-cli/pull/3990) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Collect each iOS session's serve-sim CPU/memory `/metrics` to a file and upload it as a device run session artifact at teardown. ([#3995](https://github.com/expo/eas-cli/pull/3995) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Load local composite function catalogs for hook steps, in both steps-based and native builds. ([#4063](https://github.com/expo/eas-cli/pull/4063) by [@sswrk](https://github.com/sswrk))
|
||||
- [eas-cli] Add `--name` filter to `eas simulator:list`. ([#4118](https://github.com/expo/eas-cli/pull/4118) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🐛 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))
|
||||
- [build-tools] Pin the default `agent-device` version for remote sessions instead of installing `latest`. ([#4142](https://github.com/expo/eas-cli/pull/4142) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
|
||||
@@ -3,6 +3,19 @@
|
||||
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).
|
||||
|
||||
## EAS Build CLI versions
|
||||
|
||||
`cli-versions.json` at the repository root sets which `eas-cli` version EAS Build installs. It has two fields:
|
||||
|
||||
- `STAGING`: the version used on the staging build servers.
|
||||
- `PRODUCTION`: the version used on the production build servers.
|
||||
|
||||
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`.
|
||||
|
||||
## Choosing the next version
|
||||
|
||||
As stated above, the next EAS CLI version is chosen automatically based on the existing changelog entries under the unreleased section in **CHANGELOG.md**.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"STAGING": "22.0.0",
|
||||
"PRODUCTION": "21.5.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "21.5.0",
|
||||
"version": "22.1.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"
|
||||
@@ -11,6 +11,7 @@
|
||||
"start-allow-unused": "lerna run watch-allow-unused --stream --parallel",
|
||||
"watch": "yarn start",
|
||||
"eas": "packages/eas-cli/bin/run",
|
||||
"actionlint": "actionlint",
|
||||
"lint": "oxlint --type-aware --import-plugin --tsconfig ./tsconfig.oxlint.json .",
|
||||
"fmt": "oxfmt .",
|
||||
"fmt:check": "oxfmt --check .",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "21.5.0",
|
||||
"version": "22.1.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -38,17 +38,17 @@
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "21.0.0",
|
||||
"@expo/eas-build-job": "21.5.0",
|
||||
"@expo/downloader": "22.0.0",
|
||||
"@expo/eas-build-job": "22.0.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "21.0.0",
|
||||
"@expo/logger": "22.0.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.5.0",
|
||||
"@expo/template-file": "21.0.2",
|
||||
"@expo/turtle-spawn": "21.0.0",
|
||||
"@expo/steps": "22.1.0",
|
||||
"@expo/template-file": "22.0.0",
|
||||
"@expo/turtle-spawn": "22.0.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
BuildStepGlobalContext,
|
||||
BuildWorkflow,
|
||||
StepsConfigParser,
|
||||
buildLocalCompositeFunctionCatalogAsync,
|
||||
createLocalCompositeFunctionLoader,
|
||||
errors,
|
||||
} from '@expo/steps';
|
||||
import assert from 'assert';
|
||||
@@ -17,10 +19,6 @@ import { Artifacts, BuildContext } from '../context';
|
||||
import { CustomBuildContext } from '../customBuildContext';
|
||||
import { Datadog } from '../datadog';
|
||||
import { findAndUploadXcodeBuildLogsAsync } from '../ios/xcodeBuildLogs';
|
||||
import {
|
||||
buildCompositeFunctionCatalogAsync,
|
||||
createCompositeFunctionLoader,
|
||||
} from '../steps/compositeFunctions';
|
||||
import { getEasFunctionGroups } from '../steps/easFunctionGroups';
|
||||
import { getEasFunctions } from '../steps/easFunctions';
|
||||
import { retryAsync } from '../utils/retry';
|
||||
@@ -72,11 +70,13 @@ export async function runCustomBuildAsync(ctx: BuildContext<BuildJob>): Promise<
|
||||
steps: ctx.job.steps,
|
||||
hooks: ctx.job.hooks,
|
||||
// Eager for job steps (always run), lazy loader for hooks (running anchors only).
|
||||
compositeFunctionCatalog: await buildCompositeFunctionCatalogAsync(projectRoot, {
|
||||
steps: ctx.job.steps,
|
||||
compositeFunctionCatalog: await buildLocalCompositeFunctionCatalogAsync(projectRoot, {
|
||||
rootSteps: ctx.job.steps,
|
||||
logger: ctx.logger,
|
||||
}),
|
||||
loadCompositeFunction: createLocalCompositeFunctionLoader(projectRoot, {
|
||||
logger: ctx.logger,
|
||||
}),
|
||||
loadCompositeFunction: createCompositeFunctionLoader(projectRoot, ctx.logger),
|
||||
})
|
||||
: new BuildConfigParser(globalContext, {
|
||||
externalFunctions: easFunctions,
|
||||
|
||||
@@ -29,6 +29,7 @@ import { 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
|
||||
@@ -168,20 +169,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 {
|
||||
|
||||
@@ -13,13 +13,13 @@ import {
|
||||
BuildStepGlobalContext,
|
||||
HookEntry,
|
||||
constructHookEntriesAsync,
|
||||
createLocalCompositeFunctionLoader,
|
||||
extendCompositeFunctionCatalogFromStepsAsync,
|
||||
validateHookStepsAsync,
|
||||
} from '@expo/steps';
|
||||
|
||||
import { BuildContext } from '../context';
|
||||
import { CustomBuildContext } from '../customBuildContext';
|
||||
import { createCompositeFunctionLoader } from '../steps/compositeFunctions';
|
||||
import { getEasFunctionGroups } from '../steps/easFunctionGroups';
|
||||
import { getEasFunctions } from '../steps/easFunctions';
|
||||
|
||||
@@ -99,9 +99,9 @@ export async function parseJobHooksAsync<TJob extends BuildJob>(
|
||||
const hookEntriesByKey: Partial<Record<HookKey, HookEntry[]>> = {};
|
||||
const orderedSteps: BuildStep[] = [];
|
||||
const compositeFunctionCatalog: CompositeFunctionCatalog = {};
|
||||
const loadCompositeFunction = createCompositeFunctionLoader(
|
||||
const loadCompositeFunction = createLocalCompositeFunctionLoader(
|
||||
ctx.getReactNativeProjectDirectory(),
|
||||
ctx.logger
|
||||
{ logger: ctx.logger }
|
||||
);
|
||||
for (const anchor of wrappedAnchors) {
|
||||
for (const side of ['before', 'after'] as const) {
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { BuildPhase, Generic } from '@expo/eas-build-job';
|
||||
import { Result, asyncResult } from '@expo/results';
|
||||
import { BuildStepGlobalContext, BuildWorkflow, StepsConfigParser, errors } from '@expo/steps';
|
||||
import {
|
||||
BuildStepGlobalContext,
|
||||
BuildWorkflow,
|
||||
StepsConfigParser,
|
||||
buildLocalCompositeFunctionCatalogAsync,
|
||||
createLocalCompositeFunctionLoader,
|
||||
errors,
|
||||
} from '@expo/steps';
|
||||
import fs from 'fs/promises';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import { prepareProjectSourcesAsync } from './common/projectSources';
|
||||
import { BuildContext } from './context';
|
||||
import { CustomBuildContext } from './customBuildContext';
|
||||
import {
|
||||
buildCompositeFunctionCatalogAsync,
|
||||
createCompositeFunctionLoader,
|
||||
} from './steps/compositeFunctions';
|
||||
import { getEasFunctionGroups } from './steps/easFunctionGroups';
|
||||
import { getEasFunctions } from './steps/easFunctions';
|
||||
import { uploadJobOutputsToWwwAsync } from './utils/outputs';
|
||||
@@ -51,8 +54,8 @@ export async function runGenericJobAsync(
|
||||
try {
|
||||
const projectRoot = ctx.getReactNativeProjectDirectory(customBuildCtx.projectSourceDirectory);
|
||||
// Eager for job steps (always run), lazy loader for hooks (running anchors only).
|
||||
const compositeFunctionCatalog = await buildCompositeFunctionCatalogAsync(projectRoot, {
|
||||
steps: ctx.job.steps,
|
||||
const compositeFunctionCatalog = await buildLocalCompositeFunctionCatalogAsync(projectRoot, {
|
||||
rootSteps: ctx.job.steps,
|
||||
logger: ctx.logger,
|
||||
});
|
||||
|
||||
@@ -62,7 +65,9 @@ export async function runGenericJobAsync(
|
||||
steps: ctx.job.steps,
|
||||
hooks: ctx.job.hooks,
|
||||
compositeFunctionCatalog,
|
||||
loadCompositeFunction: createCompositeFunctionLoader(projectRoot, ctx.logger),
|
||||
loadCompositeFunction: createLocalCompositeFunctionLoader(projectRoot, {
|
||||
logger: ctx.logger,
|
||||
}),
|
||||
});
|
||||
|
||||
return await parser.parseAsync();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,201 +0,0 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
buildCompositeFunctionCatalogAsync,
|
||||
createCompositeFunctionLoader,
|
||||
} from '../compositeFunctions';
|
||||
|
||||
async function makeProjectWithCompositeFunctionAsync(
|
||||
functionName: string,
|
||||
contents: string
|
||||
): Promise<string> {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-functions-test-'));
|
||||
const functionDir = path.join(projectRoot, '.eas', 'functions', functionName);
|
||||
await fs.mkdir(functionDir, { recursive: true });
|
||||
await fs.writeFile(path.join(functionDir, 'function.yml'), contents, 'utf-8');
|
||||
return projectRoot;
|
||||
}
|
||||
|
||||
const setupCompositeFunctionContents = [
|
||||
'name: Setup',
|
||||
'inputs:',
|
||||
' - name: greeting',
|
||||
' type: string',
|
||||
' default_value: hello',
|
||||
'outputs:',
|
||||
' version:',
|
||||
' value: ${{ steps.read.outputs.version }}',
|
||||
'runs:',
|
||||
' steps:',
|
||||
' - id: read',
|
||||
' run: set-output version "1.0.0"',
|
||||
].join('\n');
|
||||
|
||||
describe(buildCompositeFunctionCatalogAsync, () => {
|
||||
it('discovers, reads and validates referenced local composite functions keyed by normalized ref', async () => {
|
||||
const projectRoot = await makeProjectWithCompositeFunctionAsync(
|
||||
'setup',
|
||||
setupCompositeFunctionContents
|
||||
);
|
||||
|
||||
const catalog = await buildCompositeFunctionCatalogAsync(projectRoot, {
|
||||
steps: [{ uses: './.eas/functions/setup', id: 'setup' }],
|
||||
});
|
||||
|
||||
expect(Object.keys(catalog)).toEqual(['./.eas/functions/setup']);
|
||||
const action = catalog['./.eas/functions/setup'];
|
||||
expect(action.name).toBe('Setup');
|
||||
expect(action.runs.steps).toHaveLength(1);
|
||||
expect(action.outputs?.version.value).toBe('${{ steps.read.outputs.version }}');
|
||||
});
|
||||
|
||||
it('loads nested composite functions transitively referenced by other composite functions', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-functions-nested-'));
|
||||
const innerDir = path.join(projectRoot, '.eas', 'functions', 'inner');
|
||||
const outerDir = path.join(projectRoot, '.eas', 'functions', 'outer');
|
||||
await fs.mkdir(innerDir, { recursive: true });
|
||||
await fs.mkdir(outerDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(innerDir, 'function.yml'),
|
||||
['runs:', ' steps:', ' - run: echo inner'].join('\n'),
|
||||
'utf-8'
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(outerDir, 'function.yml'),
|
||||
['runs:', ' steps:', ' - uses: ./.eas/functions/inner'].join('\n'),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const catalog = await buildCompositeFunctionCatalogAsync(projectRoot, {
|
||||
steps: [{ uses: './.eas/functions/outer', id: 'outer' }],
|
||||
});
|
||||
|
||||
expect(Object.keys(catalog).sort()).toEqual([
|
||||
'./.eas/functions/inner',
|
||||
'./.eas/functions/outer',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty catalog when there are no referenced composite functions', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-functions-empty-'));
|
||||
const catalog = await buildCompositeFunctionCatalogAsync(projectRoot, {
|
||||
steps: [{ run: 'echo hi' }],
|
||||
});
|
||||
expect(catalog).toEqual({});
|
||||
});
|
||||
|
||||
it('ignores unreferenced malformed composite functions on disk', async () => {
|
||||
const projectRoot = await makeProjectWithCompositeFunctionAsync(
|
||||
'broken',
|
||||
['name: Broken', 'runs:', ' steps: []'].join('\n')
|
||||
);
|
||||
|
||||
const catalog = await buildCompositeFunctionCatalogAsync(projectRoot, {
|
||||
steps: [{ run: 'echo hi' }],
|
||||
});
|
||||
expect(catalog).toEqual({});
|
||||
});
|
||||
|
||||
it('resolves composite functions referenced by an arbitrary path (arbitrary path style)', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-functions-arbitrary-'));
|
||||
const functionDir = path.join(projectRoot, 'internal-functions', 'deploy');
|
||||
await fs.mkdir(functionDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(functionDir, 'function.yml'),
|
||||
['runs:', ' steps:', ' - run: echo deploy'].join('\n'),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const catalog = await buildCompositeFunctionCatalogAsync(projectRoot, {
|
||||
steps: [{ uses: './internal-functions/deploy', id: 'deploy' }],
|
||||
});
|
||||
|
||||
expect(Object.keys(catalog)).toEqual(['./internal-functions/deploy']);
|
||||
});
|
||||
|
||||
it('resolves composite functions defined with a function.yaml extension', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-functions-yaml-'));
|
||||
const functionDir = path.join(projectRoot, '.eas', 'functions', 'setup');
|
||||
await fs.mkdir(functionDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(functionDir, 'function.yaml'),
|
||||
['runs:', ' steps:', ' - run: echo hi'].join('\n'),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const catalog = await buildCompositeFunctionCatalogAsync(projectRoot, {
|
||||
steps: [{ uses: './.eas/functions/setup', id: 'setup' }],
|
||||
});
|
||||
|
||||
expect(Object.keys(catalog)).toEqual(['./.eas/functions/setup']);
|
||||
});
|
||||
|
||||
it('loads a shared composite function above the EAS project root', async () => {
|
||||
const sourceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-functions-monorepo-'));
|
||||
const projectRoot = path.join(sourceRoot, 'apps', 'my-app');
|
||||
const functionDir = path.join(sourceRoot, 'shared', 'functions', 'setup');
|
||||
await fs.mkdir(projectRoot, { recursive: true });
|
||||
await fs.mkdir(functionDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(functionDir, 'function.yml'),
|
||||
['runs:', ' steps:', ' - run: echo shared'].join('\n'),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const catalog = await buildCompositeFunctionCatalogAsync(projectRoot, {
|
||||
steps: [{ uses: '../../shared/functions/setup', id: 'setup' }],
|
||||
});
|
||||
|
||||
expect(Object.keys(catalog)).toEqual(['../../shared/functions/setup']);
|
||||
});
|
||||
|
||||
it('throws a clear error for a referenced composite function that does not exist', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-functions-missing-'));
|
||||
await expect(
|
||||
buildCompositeFunctionCatalogAsync(projectRoot, {
|
||||
steps: [{ uses: './.eas/functions/missing', id: 'missing' }],
|
||||
})
|
||||
).rejects.toThrow(
|
||||
/Local composite function "\.\/\.eas\/functions\/missing" was referenced by a step but no such composite function exists/
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a clear error for a malformed referenced composite function config', async () => {
|
||||
const projectRoot = await makeProjectWithCompositeFunctionAsync(
|
||||
'broken',
|
||||
['name: Broken', 'runs:', ' steps: []'].join('\n')
|
||||
);
|
||||
await expect(
|
||||
buildCompositeFunctionCatalogAsync(projectRoot, {
|
||||
steps: [{ uses: './.eas/functions/broken', id: 'broken' }],
|
||||
})
|
||||
).rejects.toThrow(/must declare at least one step under "runs.steps"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe(createCompositeFunctionLoader, () => {
|
||||
it('loads function.yml from disk for a normalized path', async () => {
|
||||
const projectRoot = await makeProjectWithCompositeFunctionAsync(
|
||||
'setup',
|
||||
setupCompositeFunctionContents
|
||||
);
|
||||
|
||||
const loader = createCompositeFunctionLoader(projectRoot);
|
||||
const config = await loader('./.eas/functions/setup');
|
||||
|
||||
expect(config.name).toBe('Setup');
|
||||
expect(config.runs.steps).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects for a path with no composite function on disk', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-functions-loader-'));
|
||||
|
||||
const loader = createCompositeFunctionLoader(projectRoot);
|
||||
|
||||
await expect(loader('./.eas/functions/missing')).rejects.toThrow(
|
||||
/no such composite function exists/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
/**
|
||||
* Loads local function.yml files from the project and builds the catalog consumed by
|
||||
* {@link StepsConfigParser}. Keeps filesystem I/O in build-tools; expansion logic lives in @expo/steps.
|
||||
*/
|
||||
import { bunyan } from '@expo/logger';
|
||||
import {
|
||||
CompositeFunctionCatalog,
|
||||
CompositeFunctionConfig,
|
||||
CompositeFunctionConfigZ,
|
||||
Step,
|
||||
} from '@expo/eas-build-job';
|
||||
import {
|
||||
buildCompositeFunctionCatalogFromStepsAsync,
|
||||
resolveLocalCompositeFunctionPath,
|
||||
} from '@expo/steps';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import YAML from 'yaml';
|
||||
import { ZodError, z } from 'zod';
|
||||
|
||||
async function loadLocalCompositeFunctionConfigAsync(
|
||||
projectRoot: string,
|
||||
compositeFunctionPath: string,
|
||||
{ logger }: { logger?: bunyan } = {}
|
||||
): Promise<CompositeFunctionConfig> {
|
||||
const resolvedPath = resolveLocalCompositeFunctionPath(projectRoot, compositeFunctionPath);
|
||||
|
||||
for (const ext of ['yml', 'yaml'] as const) {
|
||||
const absolutePath = path.join(resolvedPath, `function.${ext}`);
|
||||
let rawContents: string;
|
||||
try {
|
||||
rawContents = await fs.readFile(absolutePath, 'utf-8');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') {
|
||||
continue;
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to read local composite function "${compositeFunctionPath}" from ${absolutePath}`,
|
||||
{
|
||||
cause: err as Error,
|
||||
}
|
||||
);
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = YAML.parse(rawContents);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Failed to parse local composite function "${compositeFunctionPath}" YAML at ${absolutePath}`,
|
||||
{
|
||||
cause: err as Error,
|
||||
}
|
||||
);
|
||||
}
|
||||
let config: CompositeFunctionConfig;
|
||||
try {
|
||||
config = CompositeFunctionConfigZ.parse(parsed);
|
||||
} catch (err) {
|
||||
if (err instanceof ZodError) {
|
||||
throw new Error(
|
||||
`Invalid composite function "${compositeFunctionPath}": ${z.prettifyError(err)}`
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
logger?.debug(
|
||||
`Loaded local composite function "${compositeFunctionPath}" from ${path.relative(projectRoot, absolutePath)}`
|
||||
);
|
||||
return config;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Local composite function "${compositeFunctionPath}" was referenced by a step but no such composite function exists. A local composite function is resolved from a "function.yml" (or "function.yaml") file at the referenced path relative to the EAS project root (e.g. "uses: ${compositeFunctionPath}" resolves "${compositeFunctionPath}/function.yml"). The recommended convention is to keep composite functions under ".eas/functions/<name>".`
|
||||
);
|
||||
}
|
||||
|
||||
export function createCompositeFunctionLoader(
|
||||
projectRoot: string,
|
||||
logger?: bunyan
|
||||
): (compositeFunctionPath: string) => Promise<CompositeFunctionConfig> {
|
||||
return compositeFunctionPath =>
|
||||
loadLocalCompositeFunctionConfigAsync(projectRoot, compositeFunctionPath, { logger });
|
||||
}
|
||||
|
||||
export async function buildCompositeFunctionCatalogAsync(
|
||||
projectRoot: string,
|
||||
{ steps, logger }: { steps: readonly Step[]; logger?: bunyan }
|
||||
): Promise<CompositeFunctionCatalog> {
|
||||
return buildCompositeFunctionCatalogFromStepsAsync({
|
||||
rootSteps: steps,
|
||||
loadCompositeFunction: createCompositeFunctionLoader(projectRoot, logger),
|
||||
});
|
||||
}
|
||||
@@ -45,6 +45,7 @@ import { createSendSlackMessageFunction } from './functions/sendSlackMessage';
|
||||
import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/startAgentDeviceRemoteSession';
|
||||
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';
|
||||
@@ -94,6 +95,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
parseXcactivitylogFunction(),
|
||||
createStartAgentDeviceRemoteSessionBuildFunction(ctx),
|
||||
createStartArgentRemoteSessionBuildFunction(ctx),
|
||||
createStartAppiumRemoteSessionBuildFunction(ctx),
|
||||
createStartAndroidEmulatorBuildFunction(),
|
||||
createStartCuttlefishDeviceBuildFunction(),
|
||||
createStartIosSimulatorBuildFunction(),
|
||||
|
||||
+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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { Datadog } from '../../../datadog';
|
||||
@@ -38,6 +40,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 +73,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 +91,352 @@ 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,418 @@ 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('rejects sharding with maestro-runner', async () => {
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
backend: 'maestro-runner',
|
||||
shards: 2,
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(
|
||||
'maestro-runner does not support EAS Maestro test sharding'
|
||||
);
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a non-junit output_format with maestro-runner', async () => {
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
backend: 'maestro-runner',
|
||||
output_format: 'html',
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(
|
||||
'maestro-runner only supports the "junit" output_format'
|
||||
);
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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 +624,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())
|
||||
@@ -764,6 +1215,29 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('discovers maestro-runner flow results from the attempt report directory', async () => {
|
||||
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',
|
||||
output_format: 'junit',
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { compressCacheAsync } from '../saveCache';
|
||||
import { decompressCacheAsync } from '../restoreCache';
|
||||
|
||||
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));
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
@@ -34,6 +35,7 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedRetryAsync = jest.mocked(retryAsync);
|
||||
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
|
||||
const mockedMkdtemp = jest.spyOn(fs.promises, 'mkdtemp');
|
||||
function createStep(callInputs?: Record<string, unknown>, envOverrides?: NodeJS.ProcessEnv) {
|
||||
const logger = createMockLogger();
|
||||
const fn = createStartAndroidEmulatorBuildFunction();
|
||||
@@ -54,6 +56,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);
|
||||
@@ -76,6 +79,10 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
mockedMkdtemp.mockRestore();
|
||||
});
|
||||
|
||||
it('retries base emulator startup with increasing readiness timeouts', async () => {
|
||||
mockedAndroidUtils.startAsync
|
||||
.mockResolvedValueOnce(createStartResult('emulator-1111'))
|
||||
@@ -106,6 +113,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 +195,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 +239,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,86 @@
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
|
||||
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession';
|
||||
|
||||
import { resolveAppium3VersionSpec, resolveAppiumDeviceAsync } from '../startAppiumRemoteSession';
|
||||
|
||||
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
|
||||
AndroidEmulatorUtils: { getAttachedDevicesAsync: jest.fn() },
|
||||
}));
|
||||
jest.mock('../../../utils/IosSimulatorUtils', () => ({
|
||||
IosSimulatorUtils: { getAvailableDevicesAsync: jest.fn() },
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
selectXcodeDeveloperDirectoryAsync: 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();
|
||||
});
|
||||
});
|
||||
+8
-2
@@ -68,7 +68,10 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
jest.mocked(pollArgentArtifactsForUploadAsync).mockResolvedValue(undefined);
|
||||
mockStopAsync.mockResolvedValue(undefined);
|
||||
mockTunnelStopAsync.mockResolvedValue(undefined);
|
||||
jest.mocked(startArgentEventCollectionAsync).mockResolvedValue({ stopAsync: mockStopAsync });
|
||||
jest.mocked(startArgentEventCollectionAsync).mockResolvedValue({
|
||||
stopAsync: mockStopAsync,
|
||||
getLastEventObservedAt: () => undefined,
|
||||
});
|
||||
|
||||
jest.mocked(getDeviceRunSessionIdOrThrow).mockReturnValue('device-run-session-id');
|
||||
jest.mocked(getNgrokTunnelDomainOrThrow).mockReturnValue('tunnel.example.com');
|
||||
@@ -108,7 +111,10 @@ 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,3 +1,4 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import {
|
||||
@@ -14,7 +15,9 @@ 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';
|
||||
|
||||
export function createInstallMaestroBuildFunction(): BuildFunction {
|
||||
@@ -29,6 +32,11 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'backend',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
@@ -37,22 +45,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 +75,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 +113,173 @@ 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,
|
||||
logger,
|
||||
env,
|
||||
});
|
||||
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'))))
|
||||
) {
|
||||
const xcodeVersion = await getXcodeVersion({ 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 (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 }));
|
||||
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 Maestro is installed.');
|
||||
throw new Error(`Failed to ensure ${backend} 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 getXcodeVersion({ env }: { env: BuildStepEnv }): 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 xcodeVersion = semver.coerce(/^Xcode\s+(\S+)/m.exec(stdout)?.[1])?.version;
|
||||
if (!xcodeVersion) {
|
||||
throw new SystemError(`Failed to parse Xcode version from xcodebuild output: ${stdout.trim()}`);
|
||||
}
|
||||
return xcodeVersion;
|
||||
}
|
||||
|
||||
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 +323,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 +334,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,25 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { BuildStepEnv } from '@expo/steps';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const MaestroBackendSchema = z.enum(['maestro', 'maestro-runner']).default('maestro');
|
||||
|
||||
export type MaestroBackend = z.output<typeof MaestroBackendSchema>;
|
||||
|
||||
export function resolveMaestroBackend({
|
||||
input,
|
||||
env,
|
||||
}: {
|
||||
input: unknown;
|
||||
env: BuildStepEnv;
|
||||
}): MaestroBackend {
|
||||
const result = MaestroBackendSchema.safeParse(input || env.EAS_MAESTRO_BACKEND || undefined);
|
||||
if (!result.success) {
|
||||
throw new UserError(
|
||||
'ERR_MAESTRO_INVALID_INPUT',
|
||||
'backend and EAS_MAESTRO_BACKEND must be either "maestro" or "maestro-runner".',
|
||||
{ cause: result.error }
|
||||
);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { asyncResult } from '@expo/results';
|
||||
import { XMLBuilder, XMLParser, XMLValidator } from 'fast-xml-parser';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface MaestroFlowResult {
|
||||
name: string;
|
||||
@@ -34,10 +35,16 @@ const xmlParser = new XMLParser({
|
||||
isArray: name => ['testsuite', 'testcase', 'property'].includes(name),
|
||||
});
|
||||
|
||||
// A `file=` attribute counts as present only when it is a non-empty string.
|
||||
// Official Maestro writes the flow path as a `file=` testcase attribute. maestro-runner writes
|
||||
// the same value as a `<property name="file" value="..."/>` child.
|
||||
function fileAttrOf(tc: any): string | undefined {
|
||||
const f = tc?.['@_file'];
|
||||
return typeof f === 'string' && f.length > 0 ? f : undefined;
|
||||
if (typeof f === 'string' && f.length > 0) {
|
||||
return f;
|
||||
}
|
||||
const properties: { '@_name'?: unknown; '@_value'?: unknown }[] = tc?.properties?.property ?? [];
|
||||
const fileProperty = properties.find(property => property['@_name'] === 'file')?.['@_value'];
|
||||
return typeof fileProperty === 'string' && fileProperty.length > 0 ? fileProperty : undefined;
|
||||
}
|
||||
|
||||
function parseJUnitContent(content: string): JUnitTestCaseResult[] {
|
||||
@@ -62,26 +69,46 @@ function parseJUnitContent(content: string): JUnitTestCaseResult[] {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Standard JUnit marks skipped tests with a <skipped/> child (no failure/error). Exclude
|
||||
// them so they aren't miscounted as passed, matching the report.json path which drops
|
||||
// skipped flows.
|
||||
if (tc.skipped != null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const file = fileAttrOf(tc);
|
||||
|
||||
const timeStr = tc['@_time'];
|
||||
const timeSeconds = timeStr ? parseFloat(timeStr) : 0;
|
||||
const duration = Number.isFinite(timeSeconds) ? Math.round(timeSeconds * 1000) : 0;
|
||||
|
||||
const status: 'passed' | 'failed' = tc['@_status'] === 'SUCCESS' ? 'passed' : 'failed';
|
||||
// maestro-runner puts the real error in the `message` attribute and the command
|
||||
// label (e.g. `tapOn`) in the body; official Maestro only writes the body. Prefer
|
||||
// `@_message` and fall back to `#text` so both stay correct.
|
||||
const failureText =
|
||||
tc.failure != null
|
||||
? typeof tc.failure === 'string'
|
||||
? tc.failure
|
||||
: (tc.failure?.['#text'] ?? null)
|
||||
: (tc.failure?.['@_message'] ?? tc.failure?.['#text'] ?? null)
|
||||
: null;
|
||||
const errorText =
|
||||
tc.error != null
|
||||
? typeof tc.error === 'string'
|
||||
? tc.error
|
||||
: (tc.error?.['#text'] ?? null)
|
||||
: (tc.error?.['@_message'] ?? tc.error?.['#text'] ?? null)
|
||||
: null;
|
||||
const errorMessage: string | null = failureText ?? errorText ?? null;
|
||||
// Official Maestro uses status="SUCCESS". maestro-runner uses standard JUnit semantics:
|
||||
// a testcase passes when it has no failure or error child.
|
||||
const statusAttribute = tc['@_status'];
|
||||
const status: 'passed' | 'failed' =
|
||||
typeof statusAttribute === 'string'
|
||||
? statusAttribute === 'SUCCESS'
|
||||
? 'passed'
|
||||
: 'failed'
|
||||
: tc.failure == null && tc.error == null
|
||||
? 'passed'
|
||||
: 'failed';
|
||||
|
||||
const rawProperties: { '@_name': string; '@_value': string }[] =
|
||||
tc.properties?.property ?? [];
|
||||
@@ -176,6 +203,69 @@ async function fileExists(absPath: string): Promise<boolean> {
|
||||
return (await asyncResult(fs.stat(absPath))).ok;
|
||||
}
|
||||
|
||||
// maestro-runner writes report.json as the source of truth for a run. Use it for runner control
|
||||
// flow because sourceFile preserves the exact flow path, while older runner JUnit reports flatten
|
||||
// it to a basename. Keep only passed and failed flows so the result matches Maestro JUnit reports,
|
||||
// which do not include skipped flows.
|
||||
const MaestroRunnerRecordedFlowSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
sourceFile: z.string().min(1),
|
||||
status: z.enum(['passed', 'failed']),
|
||||
});
|
||||
const MaestroRunnerReportSchema = z.object({
|
||||
flows: z
|
||||
.array(
|
||||
z.discriminatedUnion('status', [
|
||||
MaestroRunnerRecordedFlowSchema,
|
||||
z.object({ status: z.literal('skipped') }),
|
||||
])
|
||||
)
|
||||
.transform(flows =>
|
||||
flows.filter(
|
||||
(flow): flow is z.infer<typeof MaestroRunnerRecordedFlowSchema> => flow.status !== 'skipped'
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
type MaestroRunnerReport = z.infer<typeof MaestroRunnerReportSchema>;
|
||||
|
||||
export async function parseFailedFlowsFromMaestroRunnerReport(args: {
|
||||
reportDirectory: string;
|
||||
workingDirectory: string;
|
||||
}): Promise<string[] | null> {
|
||||
const report = await parseMaestroRunnerReport(args.reportDirectory);
|
||||
if (report === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const failedPaths = [
|
||||
...new Set(report.flows.filter(flow => flow.status === 'failed').map(flow => flow.sourceFile)),
|
||||
];
|
||||
if (failedPaths.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const flowPath of failedPaths) {
|
||||
if (!(await fileExists(path.resolve(args.workingDirectory, flowPath)))) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return failedPaths;
|
||||
}
|
||||
|
||||
export async function parseMaestroRunnerReport(
|
||||
reportDirectory: string
|
||||
): Promise<MaestroRunnerReport | null> {
|
||||
let report: unknown;
|
||||
try {
|
||||
report = JSON.parse(await fs.readFile(path.join(reportDirectory, 'report.json'), 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const result = MaestroRunnerReportSchema.safeParse(report);
|
||||
return result.success ? result.data : null;
|
||||
}
|
||||
|
||||
// Group by `file=` so two same-named flows in different files stay separate.
|
||||
export async function parseMaestroResultsFromFileAttrs(
|
||||
junitDirectory: string
|
||||
|
||||
@@ -2,6 +2,9 @@ import { bunyan } from '@expo/logger';
|
||||
import { type Dirent } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Sentry } from '../../sentry';
|
||||
|
||||
export interface ParsedFailureScreenshot {
|
||||
flowName: string;
|
||||
@@ -133,6 +136,156 @@ export async function harvestFailureScreenshotsAsync(args: {
|
||||
return [...legacyShots, ...dedupeBundleShotsByFlowName(bundleShots)];
|
||||
}
|
||||
|
||||
const MaestroRunnerCommandSchema = z.object({
|
||||
status: z.string().optional(),
|
||||
artifacts: z
|
||||
.object({
|
||||
screenshotBefore: z.string().optional(),
|
||||
screenshotAfter: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
get subCommands() {
|
||||
return z.array(MaestroRunnerCommandSchema).optional();
|
||||
},
|
||||
});
|
||||
type MaestroRunnerCommand = z.infer<typeof MaestroRunnerCommandSchema>;
|
||||
|
||||
const MaestroRunnerFlowDetailSchema = z.object({
|
||||
commands: z.array(MaestroRunnerCommandSchema).optional(),
|
||||
});
|
||||
|
||||
const MaestroRunnerScreenshotReportSchema = z.object({
|
||||
flows: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
dataFile: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// maestro-runner records failure screenshot paths in its report JSON instead of using the
|
||||
// official Maestro debug-directory layout. Never throws, so screenshots cannot change the test
|
||||
// result.
|
||||
export async function harvestMaestroRunnerFailureScreenshotsAsync(args: {
|
||||
reportDirectory: string;
|
||||
capturedSinceMs: number;
|
||||
attemptIndex: number;
|
||||
logger: bunyan;
|
||||
}): Promise<HarvestedScreenshot[]> {
|
||||
let report: unknown;
|
||||
try {
|
||||
report = JSON.parse(await fs.readFile(path.join(args.reportDirectory, 'report.json'), 'utf8'));
|
||||
} catch (err: any) {
|
||||
args.logger.info(
|
||||
{ err },
|
||||
`Skipping maestro-runner screenshot harvest: cannot read ${args.reportDirectory}.`
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
const parsedReport = MaestroRunnerScreenshotReportSchema.safeParse(report);
|
||||
if (!parsedReport.success) {
|
||||
args.logger.warn(
|
||||
{ err: parsedReport.error },
|
||||
`Skipping maestro-runner screenshot harvest: unexpected report.json shape in ${args.reportDirectory}.`
|
||||
);
|
||||
Sentry.capture('maestro-runner report.json failed schema validation', parsedReport.error);
|
||||
return [];
|
||||
}
|
||||
const flows = parsedReport.data.flows ?? [];
|
||||
|
||||
const shots: HarvestedScreenshot[] = [];
|
||||
for (const { name, status, dataFile } of flows) {
|
||||
if (status !== 'failed' || !name || !dataFile) {
|
||||
continue;
|
||||
}
|
||||
const flowDataPath = resolvePathInsideDirectory(args.reportDirectory, dataFile);
|
||||
if (!flowDataPath) {
|
||||
args.logger.info(`Skipping maestro-runner flow data outside the report directory.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let screenshotPath: string | undefined;
|
||||
try {
|
||||
const detail = MaestroRunnerFlowDetailSchema.safeParse(
|
||||
JSON.parse(await fs.readFile(flowDataPath, 'utf8'))
|
||||
);
|
||||
if (!detail.success) {
|
||||
args.logger.warn(
|
||||
{ err: detail.error },
|
||||
`Skipping malformed maestro-runner flow data ${flowDataPath}.`
|
||||
);
|
||||
Sentry.capture('maestro-runner flow data failed schema validation', detail.error);
|
||||
continue;
|
||||
}
|
||||
screenshotPath = findFailedMaestroRunnerScreenshot(detail.data.commands ?? []);
|
||||
} catch (err: any) {
|
||||
args.logger.info({ err }, `Skipping unreadable maestro-runner flow data ${flowDataPath}.`);
|
||||
continue;
|
||||
}
|
||||
if (!screenshotPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileAbsPath = resolvePathInsideDirectory(args.reportDirectory, screenshotPath);
|
||||
if (!fileAbsPath) {
|
||||
args.logger.info(`Skipping maestro-runner screenshot outside the report directory.`);
|
||||
continue;
|
||||
}
|
||||
let capturedAtMs: number;
|
||||
try {
|
||||
capturedAtMs = Math.round((await fs.stat(fileAbsPath)).mtimeMs);
|
||||
} catch (err: any) {
|
||||
args.logger.info({ err }, `Skipping unreadable screenshot ${fileAbsPath}.`);
|
||||
continue;
|
||||
}
|
||||
if (capturedAtMs < args.capturedSinceMs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const flowName = normalizeFlowName(name);
|
||||
shots.push({
|
||||
fileAbsPath,
|
||||
displayName: `Failure Screenshot: ${flowName} (attempt ${args.attemptIndex + 1})`,
|
||||
metadata: {
|
||||
kind: 'maestro-test-screenshot',
|
||||
flowName,
|
||||
attemptIndex: args.attemptIndex,
|
||||
capturedAtMs,
|
||||
},
|
||||
});
|
||||
}
|
||||
return shots;
|
||||
}
|
||||
|
||||
function findFailedMaestroRunnerScreenshot(
|
||||
commands: readonly MaestroRunnerCommand[]
|
||||
): string | undefined {
|
||||
for (const command of commands) {
|
||||
if (command.status !== 'failed') {
|
||||
continue;
|
||||
}
|
||||
const nested = findFailedMaestroRunnerScreenshot(command.subCommands ?? []);
|
||||
const screenshot =
|
||||
nested ?? command.artifacts?.screenshotAfter ?? command.artifacts?.screenshotBefore;
|
||||
if (screenshot) {
|
||||
return screenshot;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolvePathInsideDirectory(directory: string, relativePath: string): string | null {
|
||||
const candidate = path.resolve(directory, relativePath);
|
||||
const relative = path.relative(directory, candidate);
|
||||
return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)
|
||||
? candidate
|
||||
: null;
|
||||
}
|
||||
|
||||
// Maestro >= 2.7.0: resolve a bundle dir to its owning failed flow and return that flow's failure
|
||||
// screenshot — the highest-numbered step in `screenshots/`. A required failure halts the flow, so
|
||||
// the failed step is normally the last (highest-numbered) captured step; earlier warned steps have
|
||||
|
||||
@@ -13,6 +13,7 @@ import os from 'os';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { MaestroBackend, resolveMaestroBackend } from './maestroBackend';
|
||||
import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
|
||||
import {
|
||||
copyLatestAttemptXml,
|
||||
@@ -21,12 +22,15 @@ import {
|
||||
parseFailedFlowNamesFromJUnitFile,
|
||||
parseFailedFlowsFromFileAttrs,
|
||||
parseFailedFlowsFromJUnit,
|
||||
parseFailedFlowsFromMaestroRunnerReport,
|
||||
parseJUnitTestCases,
|
||||
parseMaestroRunnerReport,
|
||||
} from './maestroResultParser';
|
||||
import {
|
||||
type HarvestedScreenshot,
|
||||
computePureFailureFlowNames,
|
||||
harvestFailureScreenshotsAsync,
|
||||
harvestMaestroRunnerFailureScreenshotsAsync,
|
||||
selectFailureScreenshots,
|
||||
} from './maestroScreenshots';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
@@ -35,6 +39,7 @@ import { sleepAsync } from '../../utils/retry';
|
||||
const FlowPathSchema = z.array(z.string().min(1)).min(1);
|
||||
const RetriesSchema = z.number().int().min(0).default(0);
|
||||
const ShardsSchema = z.number().int().min(1).optional();
|
||||
const AndroidConnectionModeSchema = z.enum(['adb', 'dadb']).default('adb');
|
||||
|
||||
function parseInput<S extends z.ZodTypeAny>(
|
||||
schema: S,
|
||||
@@ -61,35 +66,58 @@ function isFilesystemError(err: any): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// `outputPath: null` means "let maestro pick" (no --output flag). Junit and
|
||||
// other declared formats pass an explicit path so downstream upload steps
|
||||
// know where to find the result.
|
||||
function buildMaestroArgs(args: {
|
||||
flow_path: string[];
|
||||
outputPath: string | null;
|
||||
output_format: string | undefined;
|
||||
function buildMaestroArgs({
|
||||
backend,
|
||||
platform,
|
||||
flowPaths,
|
||||
output,
|
||||
outputFormat,
|
||||
shards,
|
||||
includeTags,
|
||||
excludeTags,
|
||||
}: {
|
||||
backend: MaestroBackend;
|
||||
platform: 'ios' | 'android';
|
||||
flowPaths: string[];
|
||||
output: string | null;
|
||||
outputFormat: string | undefined;
|
||||
shards: number | undefined;
|
||||
include_tags: string | undefined;
|
||||
exclude_tags: string | undefined;
|
||||
}): string[] {
|
||||
const out: string[] = ['test'];
|
||||
if (args.output_format) {
|
||||
out.push(`--format=${args.output_format.toUpperCase()}`);
|
||||
includeTags: string | undefined;
|
||||
excludeTags: string | undefined;
|
||||
}): { executable: MaestroBackend; args: string[] } {
|
||||
switch (backend) {
|
||||
case 'maestro': {
|
||||
const args = ['test'];
|
||||
if (outputFormat) {
|
||||
args.push(`--format=${outputFormat.toUpperCase()}`);
|
||||
}
|
||||
if (output) {
|
||||
args.push(`--output=${output}`);
|
||||
}
|
||||
if (shards !== undefined) {
|
||||
args.push(`--shard-split=${shards}`);
|
||||
}
|
||||
if (includeTags) {
|
||||
args.push(`--include-tags=${includeTags}`);
|
||||
}
|
||||
if (excludeTags) {
|
||||
args.push(`--exclude-tags=${excludeTags}`);
|
||||
}
|
||||
args.push(...flowPaths);
|
||||
return { executable: 'maestro', args };
|
||||
}
|
||||
case 'maestro-runner': {
|
||||
const args = [`--platform=${platform}`, 'test', `--output=${output}`, '--flatten'];
|
||||
if (includeTags) {
|
||||
args.push(`--include-tags=${includeTags}`);
|
||||
}
|
||||
if (excludeTags) {
|
||||
args.push(`--exclude-tags=${excludeTags}`);
|
||||
}
|
||||
args.push(...flowPaths);
|
||||
return { executable: 'maestro-runner', args };
|
||||
}
|
||||
}
|
||||
if (args.outputPath) {
|
||||
out.push(`--output=${args.outputPath}`);
|
||||
}
|
||||
if (args.shards !== undefined) {
|
||||
out.push(`--shard-split=${args.shards}`);
|
||||
}
|
||||
if (args.include_tags) {
|
||||
out.push(`--include-tags=${args.include_tags}`);
|
||||
}
|
||||
if (args.exclude_tags) {
|
||||
out.push(`--exclude-tags=${args.exclude_tags}`);
|
||||
}
|
||||
out.push(...args.flow_path);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
@@ -143,6 +171,16 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'android_connection_mode',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'backend',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({ id: 'junit_report_directory', required: true }),
|
||||
@@ -180,7 +218,7 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
// Public docs (EAS workflows pre-packaged-jobs) document
|
||||
// `${MAESTRO_TESTS_DIR}` for users to save screenshots/recordings into
|
||||
// the uploaded dir.
|
||||
const spawnEnv = { ...env, MAESTRO_TESTS_DIR: testsDirectory };
|
||||
const spawnEnv: NodeJS.ProcessEnv = { ...env, MAESTRO_TESTS_DIR: testsDirectory };
|
||||
|
||||
// Outputs are published BEFORE any throw below so downstream
|
||||
// `if: always()` upload steps still see populated values when this
|
||||
@@ -191,6 +229,14 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
outputs.final_report_path.set(finalReportPath);
|
||||
}
|
||||
|
||||
// Resolved after the output assignments above: an invalid backend input or
|
||||
// EAS_MAESTRO_BACKEND throws, and downstream `if: always()` upload steps still
|
||||
// need the outputs interpolated.
|
||||
const backend = resolveMaestroBackend({
|
||||
input: inputs.backend.value,
|
||||
env,
|
||||
});
|
||||
|
||||
const flowPaths = parseInput(
|
||||
FlowPathSchema,
|
||||
inputs.flow_path.value,
|
||||
@@ -206,6 +252,25 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
inputs.shards.value,
|
||||
'shards must be a positive integer.'
|
||||
);
|
||||
const androidConnectionMode = parseInput(
|
||||
AndroidConnectionModeSchema,
|
||||
inputs.android_connection_mode.value ||
|
||||
env.EAS_MAESTRO_ANDROID_CONNECTION_MODE ||
|
||||
undefined,
|
||||
'android_connection_mode and EAS_MAESTRO_ANDROID_CONNECTION_MODE must be either "adb" or "dadb".'
|
||||
);
|
||||
if (backend === 'maestro-runner' && shards !== undefined && shards > 1) {
|
||||
throw new UserError(
|
||||
'ERR_MAESTRO_INVALID_INPUT',
|
||||
'maestro-runner does not support EAS Maestro test sharding. Remove shards or set it to 1.'
|
||||
);
|
||||
}
|
||||
if (backend === 'maestro-runner' && outputFormat !== undefined && outputFormat !== 'junit') {
|
||||
throw new UserError(
|
||||
'ERR_MAESTRO_INVALID_INPUT',
|
||||
`maestro-runner only supports the "junit" output_format, but received "${outputFormat}".`
|
||||
);
|
||||
}
|
||||
const retryFailedOnly = inputs.retry_failed_only.value as boolean;
|
||||
|
||||
try {
|
||||
@@ -214,7 +279,7 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
throw new SystemError('Failed to create JUnit report directory', { cause: err });
|
||||
}
|
||||
|
||||
// Legacy-only (Maestro < 2.6.0 reports carry no `file=` attribute): the
|
||||
// Official Maestro legacy-only (Maestro < 2.6.0 reports carry no `file=` attribute): the
|
||||
// flow scan is built lazily in the retry branch below and memoized so
|
||||
// retries share one scan. Never runs when the report has `file=`.
|
||||
let nameToPathPromise: Promise<Map<string, string> | null> | undefined;
|
||||
@@ -224,38 +289,96 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
// numeric err.status → maestro exited non-zero → retry.
|
||||
// else (signal-only, OOM kill, unknown) → infra → SystemError, never
|
||||
// downgraded to "tests failed".
|
||||
// Retry-failed-only (junit mode): after a failed attempt, subset to the failing
|
||||
// flows. The failed-flow parsers return null when the JUnit cannot be
|
||||
// trusted; we then fall through to dumb retry (re-run everything).
|
||||
// Retry-failed-only: after a failed attempt, subset to the failing flows. The
|
||||
// failed-flow parsers return null when a report cannot be trusted; we then fall
|
||||
// through to dumb retry (re-run everything).
|
||||
let flowsToRun: string[] = flowPaths;
|
||||
let lastAttemptExitCode: number | null = null;
|
||||
const harvested: HarvestedScreenshot[] = [];
|
||||
const reportDirectories = backend === 'maestro' ? [junitReportDirectory] : [];
|
||||
|
||||
const totalAttempts = retries + 1;
|
||||
if (
|
||||
backend === 'maestro-runner' &&
|
||||
platform === 'android' &&
|
||||
androidConnectionMode === 'dadb'
|
||||
) {
|
||||
logger.info('maestro-runner does not support DADB. Using the default ADB connection.');
|
||||
}
|
||||
if (backend === 'maestro' && platform === 'android' && androidConnectionMode === 'dadb') {
|
||||
try {
|
||||
const adbOverrideDirectoryPath = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'maestro-tests-adb-override-')
|
||||
);
|
||||
await fs.writeFile(path.join(adbOverrideDirectoryPath, 'adb'), '#!/bin/sh\nexit 1\n', {
|
||||
mode: 0o755,
|
||||
});
|
||||
|
||||
// DADB starts an ADB server when it can find an adb binary. Stop the existing
|
||||
// server first, then make only the Maestro process find the failing shim.
|
||||
try {
|
||||
await spawn('adb', ['kill-server'], { env: { ...spawnEnv }, logger, signal });
|
||||
logger.info(
|
||||
'Using a direct DADB connection for Android Maestro tests after stopping the ADB server.'
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Using a direct DADB connection for Android Maestro tests, but failed to stop the ADB server.'
|
||||
);
|
||||
}
|
||||
spawnEnv.PATH = spawnEnv.PATH
|
||||
? `${adbOverrideDirectoryPath}${path.delimiter}${spawnEnv.PATH}`
|
||||
: adbOverrideDirectoryPath;
|
||||
} catch (err) {
|
||||
// Intentionally skip cleanup because the worker is disposable.
|
||||
throw new SystemError('Failed to enable direct DADB connection for Maestro', {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
// Do not restart ADB or remove the override. This keeps Maestro in direct DADB mode.
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
// maestro-runner writes its JUnit report and screenshot metadata to this directory.
|
||||
const runnerOutputDirectory = path.join(
|
||||
testsDirectory,
|
||||
`${platform}-maestro-runner-attempt-${attempt}`
|
||||
);
|
||||
const outputPath =
|
||||
outputFormat === 'junit'
|
||||
? path.join(junitReportDirectory, `${platform}-maestro-junit-attempt-${attempt}.xml`)
|
||||
: outputFormat
|
||||
: backend === 'maestro' && outputFormat
|
||||
? path.join(testsDirectory, `${platform}-maestro-${outputFormat}.${outputFormat}`)
|
||||
: null;
|
||||
|
||||
const maestroArgs = buildMaestroArgs({
|
||||
flow_path: flowsToRun,
|
||||
outputPath,
|
||||
output_format: outputFormat,
|
||||
const { executable, args: maestroArgs } = buildMaestroArgs({
|
||||
backend,
|
||||
platform,
|
||||
flowPaths: flowsToRun,
|
||||
output: backend === 'maestro-runner' ? runnerOutputDirectory : outputPath,
|
||||
outputFormat,
|
||||
shards,
|
||||
include_tags: includeTags,
|
||||
exclude_tags: excludeTags,
|
||||
includeTags,
|
||||
excludeTags,
|
||||
});
|
||||
logger.info(
|
||||
`Running maestro (attempt ${attempt + 1}/${totalAttempts}): maestro ${maestroArgs.join(' ')}`
|
||||
`Running ${executable} (attempt ${attempt + 1}/${totalAttempts}): ${executable} ${maestroArgs.join(' ')}`
|
||||
);
|
||||
|
||||
// The runner output directory is deterministic and can survive a prior run; clear it
|
||||
// best-effort so a crash before it writes fresh output can't resurrect stale results.
|
||||
if (backend === 'maestro-runner') {
|
||||
try {
|
||||
await fs.rm(runnerOutputDirectory, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
logger.warn({ err }, `Failed to clear ${runnerOutputDirectory} before the attempt.`);
|
||||
}
|
||||
}
|
||||
|
||||
const attemptStartedAtMs = Date.now();
|
||||
|
||||
try {
|
||||
await spawn('maestro', maestroArgs, {
|
||||
await spawn(executable, maestroArgs, {
|
||||
cwd: stepCtx.workingDirectory,
|
||||
env: spawnEnv,
|
||||
logger,
|
||||
@@ -264,56 +387,108 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
lastAttemptExitCode = 0;
|
||||
} catch (err: any) {
|
||||
if (err && (err.code === 'ENOENT' || err.code === 'EACCES')) {
|
||||
throw new SystemError('Failed to invoke maestro', { cause: err });
|
||||
throw new SystemError(`Failed to invoke ${executable}`, { cause: err });
|
||||
}
|
||||
if (err && typeof err.status === 'number') {
|
||||
lastAttemptExitCode = err.status;
|
||||
} else {
|
||||
throw new SystemError('Unexpected spawn failure invoking maestro', { cause: err });
|
||||
throw new SystemError(`Unexpected spawn failure invoking ${executable}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// maestro-runner writes a report directory. Copy its JUnit file into the existing
|
||||
// per-attempt directory so retry parsing and final report merging remain shared.
|
||||
if (backend === 'maestro-runner' && outputPath) {
|
||||
try {
|
||||
await fs.copyFile(path.join(runnerOutputDirectory, 'junit-report.xml'), outputPath);
|
||||
} catch (err: any) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
`Failed to collect the maestro-runner JUnit report for attempt ${attempt + 1}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (backend === 'maestro-runner') {
|
||||
reportDirectories.push(runnerOutputDirectory);
|
||||
}
|
||||
|
||||
// Harvest this attempt's failure screenshots before any retry subsetting. Gated on
|
||||
// junit: test-case-result rows (and therefore the summary icons) only exist for junit
|
||||
// runs, so harvesting other formats would just create orphan artifacts the website hides.
|
||||
if (outputFormat === 'junit') {
|
||||
const failedFlowNames = outputPath
|
||||
? await parseFailedFlowNamesFromJUnitFile(outputPath)
|
||||
: new Set<string>();
|
||||
harvested.push(
|
||||
...(await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: attemptStartedAtMs,
|
||||
attemptIndex: attempt,
|
||||
failedFlowNames,
|
||||
logger,
|
||||
}))
|
||||
);
|
||||
let screenshots: HarvestedScreenshot[];
|
||||
switch (backend) {
|
||||
case 'maestro': {
|
||||
const failedFlowNames = outputPath
|
||||
? await parseFailedFlowNamesFromJUnitFile(outputPath)
|
||||
: new Set<string>();
|
||||
screenshots = await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: attemptStartedAtMs,
|
||||
attemptIndex: attempt,
|
||||
failedFlowNames,
|
||||
logger,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'maestro-runner':
|
||||
screenshots = await harvestMaestroRunnerFailureScreenshotsAsync({
|
||||
reportDirectory: runnerOutputDirectory,
|
||||
capturedSinceMs: attemptStartedAtMs,
|
||||
attemptIndex: attempt,
|
||||
logger,
|
||||
});
|
||||
break;
|
||||
}
|
||||
harvested.push(...screenshots);
|
||||
}
|
||||
|
||||
if (lastAttemptExitCode === 0 || attempt === retries) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (retryFailedOnly && outputFormat === 'junit' && outputPath) {
|
||||
if (
|
||||
retryFailedOnly &&
|
||||
(backend === 'maestro-runner' || (outputFormat === 'junit' && outputPath))
|
||||
) {
|
||||
let failed: string[] | null;
|
||||
if (await junitFileHasFileAttrs(outputPath)) {
|
||||
failed = await parseFailedFlowsFromFileAttrs({
|
||||
junitFile: outputPath,
|
||||
workingDirectory: stepCtx.workingDirectory,
|
||||
});
|
||||
} else {
|
||||
// Legacy (Maestro < 2.6.0): map failed testcase names back to flow
|
||||
// paths via the flow-file scan. DELETE this arm once the fleet is
|
||||
// on >= 2.6.0.
|
||||
const nameToPath = await (nameToPathPromise ??= buildFlowNameToPathMap({
|
||||
inputFlowPaths: flowPaths,
|
||||
projectRoot: stepCtx.workingDirectory,
|
||||
logger,
|
||||
}));
|
||||
failed = nameToPath
|
||||
? await parseFailedFlowsFromJUnit({ junitFile: outputPath, nameToPath })
|
||||
: null;
|
||||
switch (backend) {
|
||||
case 'maestro-runner':
|
||||
failed = await parseFailedFlowsFromMaestroRunnerReport({
|
||||
reportDirectory: runnerOutputDirectory,
|
||||
workingDirectory: stepCtx.workingDirectory,
|
||||
});
|
||||
break;
|
||||
case 'maestro':
|
||||
if (!outputPath) {
|
||||
failed = null;
|
||||
break;
|
||||
}
|
||||
if (await junitFileHasFileAttrs(outputPath)) {
|
||||
failed = await parseFailedFlowsFromFileAttrs({
|
||||
junitFile: outputPath,
|
||||
workingDirectory: stepCtx.workingDirectory,
|
||||
});
|
||||
break;
|
||||
}
|
||||
// Legacy (Maestro < 2.6.0): map failed testcase names back to flow
|
||||
// paths via the flow-file scan. DELETE this arm once the fleet is
|
||||
// on >= 2.6.0.
|
||||
const nameToPath = await (nameToPathPromise ??= buildFlowNameToPathMap({
|
||||
inputFlowPaths: flowPaths,
|
||||
projectRoot: stepCtx.workingDirectory,
|
||||
logger,
|
||||
}));
|
||||
failed = nameToPath
|
||||
? await parseFailedFlowsFromJUnit({
|
||||
junitFile: outputPath,
|
||||
nameToPath,
|
||||
})
|
||||
: null;
|
||||
break;
|
||||
}
|
||||
if (failed !== null && failed.length > 0) {
|
||||
flowsToRun = failed;
|
||||
@@ -366,7 +541,13 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
// Upload before the ERR_MAESTRO_TESTS_FAILED throw below so fully-failed runs (which need
|
||||
// screenshots most) still upload. Harvest only ran for junit, so guard the same way.
|
||||
if (outputFormat === 'junit') {
|
||||
await uploadFailureScreenshotsAsync({ harvested, junitReportDirectory, ctx, logger });
|
||||
await uploadFailureScreenshotsAsync({
|
||||
harvested,
|
||||
backend,
|
||||
reportDirectories,
|
||||
ctx,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
// The retry loop exits via success (0), numeric status (retryable),
|
||||
@@ -387,12 +568,14 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
// the maestro test result. Caller guards on junit (harvest only runs for junit).
|
||||
async function uploadFailureScreenshotsAsync({
|
||||
harvested,
|
||||
junitReportDirectory,
|
||||
backend,
|
||||
reportDirectories,
|
||||
ctx,
|
||||
logger,
|
||||
}: {
|
||||
harvested: HarvestedScreenshot[];
|
||||
junitReportDirectory: string;
|
||||
backend: MaestroBackend;
|
||||
reportDirectories: string[];
|
||||
ctx: CustomBuildContext;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
@@ -402,9 +585,25 @@ async function uploadFailureScreenshotsAsync({
|
||||
// test verdict (the whole step is verdict-neutral for screenshots).
|
||||
let selected: HarvestedScreenshot[];
|
||||
try {
|
||||
const pureFailureFlowNames = computePureFailureFlowNames(
|
||||
await parseJUnitTestCases(junitReportDirectory)
|
||||
);
|
||||
let flowResults: { name: string; status: 'passed' | 'failed' }[];
|
||||
switch (backend) {
|
||||
case 'maestro':
|
||||
flowResults = (
|
||||
await Promise.all(reportDirectories.map(directory => parseJUnitTestCases(directory)))
|
||||
).flat();
|
||||
break;
|
||||
case 'maestro-runner': {
|
||||
const results = await Promise.all(
|
||||
reportDirectories.map(directory => parseMaestroRunnerReport(directory))
|
||||
);
|
||||
// Use whichever reports parsed. An unreadable report (e.g. a final retry that crashed
|
||||
// before writing report.json) contributes no flows rather than discarding screenshots
|
||||
// harvested from the attempts that did report — mirroring the maestro path above.
|
||||
flowResults = results.flatMap(result => result?.flows ?? []);
|
||||
break;
|
||||
}
|
||||
}
|
||||
const pureFailureFlowNames = computePureFailureFlowNames(flowResults);
|
||||
selected = selectFailureScreenshots(harvested, pureFailureFlowNames);
|
||||
} catch (err: any) {
|
||||
logger.warn({ err }, 'Failed to classify failure screenshots; skipping screenshot upload.');
|
||||
|
||||
@@ -258,13 +258,14 @@ export async function restoreGradleCacheAsync({
|
||||
const gradleCachesPath = path.join(os.homedir(), '.gradle', 'caches');
|
||||
|
||||
const buildCachePath = path.join(gradleCachesPath, 'build-cache-1');
|
||||
const journalPath = path.join(gradleCachesPath, 'journal-1');
|
||||
|
||||
const { archivePath, matchedKey } = await downloadCacheAsync({
|
||||
logger,
|
||||
jobId,
|
||||
expoApiServerURL,
|
||||
robotAccessToken,
|
||||
paths: [buildCachePath],
|
||||
paths: [buildCachePath, journalPath],
|
||||
key: cacheKey,
|
||||
keyPrefixes: [GRADLE_CACHE_KEY_PREFIX],
|
||||
platform: Platform.ANDROID,
|
||||
|
||||
@@ -167,6 +167,7 @@ export async function saveGradleCacheAsync({
|
||||
|
||||
const gradleCachesPath = path.join(os.homedir(), '.gradle', 'caches');
|
||||
const buildCachePath = path.join(gradleCachesPath, 'build-cache-1');
|
||||
const journalPath = path.join(gradleCachesPath, 'journal-1');
|
||||
|
||||
try {
|
||||
await fs.promises.access(buildCachePath);
|
||||
@@ -186,9 +187,11 @@ export async function saveGradleCacheAsync({
|
||||
);
|
||||
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
|
||||
|
||||
await fs.promises.mkdir(journalPath, { recursive: true });
|
||||
|
||||
logger.info('Compressing Gradle build cache...');
|
||||
const { archivePath } = await compressCacheAsync({
|
||||
paths: [buildCachePath],
|
||||
paths: [buildCachePath, journalPath],
|
||||
workingDirectory: gradleCachesPath,
|
||||
verbose: env.EXPO_DEBUG === '1',
|
||||
logger,
|
||||
@@ -204,7 +207,7 @@ export async function saveGradleCacheAsync({
|
||||
robotAccessToken,
|
||||
archivePath,
|
||||
key: cacheKey,
|
||||
paths: [buildCachePath],
|
||||
paths: [buildCachePath, journalPath],
|
||||
size,
|
||||
platform: Platform.ANDROID,
|
||||
});
|
||||
|
||||
@@ -348,7 +348,10 @@ export async function compressCacheAsync({
|
||||
for (const { absolutePath, archivePath: targetRelativePath } of allFiles) {
|
||||
const targetPath = path.join(tempDir, targetRelativePath);
|
||||
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
// We want to keep source timestamps since Gradle may check them when pruning cache.
|
||||
const { atime, mtime } = await fs.promises.stat(absolutePath);
|
||||
await fs.promises.copyFile(absolutePath, targetPath);
|
||||
await fs.promises.utimes(targetPath, atime, mtime);
|
||||
|
||||
if (verbose) {
|
||||
logger.info(`- ${targetRelativePath}`);
|
||||
|
||||
@@ -55,6 +55,16 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'max_idle_time_minutes',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'max_duration_seconds',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger, global }, { inputs, env, signal }) => {
|
||||
// Fail fast before any expensive setup if the injected env
|
||||
@@ -66,6 +76,9 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
|
||||
|
||||
const packageVersion = inputs.package_version.value as string | undefined;
|
||||
// A missing or non-positive value disables the idle timeout (opt-in feature).
|
||||
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
|
||||
const { runtimePlatform } = global;
|
||||
logger.info(
|
||||
`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`
|
||||
@@ -139,7 +152,15 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
maxDurationSeconds,
|
||||
signal,
|
||||
idleTimeout:
|
||||
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} finally {
|
||||
if (serveSim) {
|
||||
|
||||
@@ -5,8 +5,12 @@ import {
|
||||
BuildRuntimePlatform,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
} 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 {
|
||||
AndroidDeviceName,
|
||||
@@ -49,7 +53,18 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger }, { inputs, env }) => {
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'logcat_directory',
|
||||
required: true,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger }, { inputs, outputs, env }) => {
|
||||
const logcatDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
|
||||
);
|
||||
outputs.logcat_directory.set(logcatDirectory);
|
||||
|
||||
if (env.EAS_NO_EMULATOR_HOST_SUPPORT_CHECK !== '1') {
|
||||
await assertAndroidEmulatorHostSupportAsync({ env });
|
||||
} else {
|
||||
@@ -122,6 +137,7 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
const startResult = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName,
|
||||
env,
|
||||
logcatDirectory,
|
||||
});
|
||||
attemptSerialId = startResult.serialId;
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
@@ -211,6 +227,7 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
const startResult = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName: cloneIdentifier,
|
||||
env,
|
||||
logcatDirectory,
|
||||
});
|
||||
cloneSerialId = startResult.serialId;
|
||||
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
type BuildStepEnv,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { AndroidEmulatorUtils } from '../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
|
||||
import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
startServeSimWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
const APPIUM_HOST = '127.0.0.1';
|
||||
const APPIUM_PORT = 4723;
|
||||
const APPIUM_STARTUP_TIMEOUT_MS = 120_000;
|
||||
const DEFAULT_APPIUM_VERSION = '^3';
|
||||
|
||||
const AppiumInstalledDriversSchema = z.record(
|
||||
z.string(),
|
||||
z.object({ installed: z.boolean().optional() }).passthrough()
|
||||
);
|
||||
|
||||
export function createStartAppiumRemoteSessionBuildFunction(
|
||||
ctx: CustomBuildContext
|
||||
): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_appium_remote_session',
|
||||
name: 'Start Appium remote session',
|
||||
__metricsId: 'eas/start_appium_remote_session',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'max_idle_time_minutes',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger, global }, { inputs, env, signal }) => {
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
|
||||
const packageVersion = inputs.package_version.value as string | undefined;
|
||||
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
|
||||
const { runtimePlatform } = global;
|
||||
const versionSpec = resolveAppium3VersionSpec(packageVersion);
|
||||
|
||||
logger.info(
|
||||
`Starting Appium remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
const device = await resolveAppiumDeviceAsync({ runtimePlatform, env, logger });
|
||||
const { appiumHome, appiumBinPath, appiumEnv } = await installAppiumAsync({
|
||||
versionSpec,
|
||||
driverName: device.driverName,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
const appiumProcess = spawnDetached({
|
||||
command: appiumBinPath,
|
||||
args: [
|
||||
'--address',
|
||||
APPIUM_HOST,
|
||||
'--port',
|
||||
String(APPIUM_PORT),
|
||||
'--base-path',
|
||||
'/',
|
||||
'--log-level',
|
||||
'error',
|
||||
// Appium 3 gates session listing (GET /appium/sessions) behind the
|
||||
// session_discovery insecure feature. We rely on it to poll for
|
||||
// Appium Event Timings, so enable it for all drivers.
|
||||
'--allow-insecure',
|
||||
'*:session_discovery',
|
||||
'--default-capabilities',
|
||||
JSON.stringify({ 'appium:eventTimings': true }),
|
||||
],
|
||||
env: appiumEnv,
|
||||
});
|
||||
try {
|
||||
await waitForAppiumReadyAsync({ appiumProcess, logger });
|
||||
} catch (error) {
|
||||
await appiumProcess.stopAsync();
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
|
||||
const eventCollection = await startAppiumEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
appiumUrl: `http://${APPIUM_HOST}:${APPIUM_PORT}/`,
|
||||
logger,
|
||||
});
|
||||
let appiumTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let serveSim: Awaited<ReturnType<typeof startServeSimWithTunnelAsync>> | undefined;
|
||||
try {
|
||||
appiumTunnel = await startNgrokTunnelAsync({
|
||||
port: APPIUM_PORT,
|
||||
subdomainPrefix: 'appium',
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
logger,
|
||||
});
|
||||
|
||||
switch (runtimePlatform) {
|
||||
case BuildRuntimePlatform.DARWIN:
|
||||
serveSim = await startServeSimWithTunnelAsync(ctx, {
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
break;
|
||||
case BuildRuntimePlatform.LINUX:
|
||||
break;
|
||||
}
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
appiumUrl: appiumTunnel.url,
|
||||
capabilities: {
|
||||
platformName: device.platformName,
|
||||
'appium:automationName': device.automationName,
|
||||
'appium:udid': device.udid,
|
||||
},
|
||||
...(serveSim ? { webPreviewUrl: serveSim.previewUrl } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
signal,
|
||||
idleTimeout:
|
||||
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} finally {
|
||||
if (serveSim) {
|
||||
await serveSim.stopAsync();
|
||||
}
|
||||
if (appiumTunnel) {
|
||||
await appiumTunnel.stopAsync();
|
||||
}
|
||||
await eventCollection.stopAsync();
|
||||
await appiumProcess.stopAsync();
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveAppium3VersionSpec(packageVersion: string | undefined): string {
|
||||
const versionSpec = packageVersion ?? DEFAULT_APPIUM_VERSION;
|
||||
const range = semver.validRange(versionSpec);
|
||||
if (!range || !semver.subset(range, '>=3.0.0 <4.0.0-0')) {
|
||||
throw new SystemError(
|
||||
`Appium 3 is required for EAS Simulator sessions. Received package version "${versionSpec}".`
|
||||
);
|
||||
}
|
||||
return versionSpec;
|
||||
}
|
||||
|
||||
type AppiumDevice = {
|
||||
platformName: 'iOS' | 'Android';
|
||||
automationName: 'XCUITest' | 'UiAutomator2';
|
||||
driverName: 'xcuitest' | 'uiautomator2';
|
||||
udid: string;
|
||||
};
|
||||
|
||||
export async function resolveAppiumDeviceAsync({
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<AppiumDevice> {
|
||||
switch (runtimePlatform) {
|
||||
case BuildRuntimePlatform.DARWIN: {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
const [bootedDevice] = await IosSimulatorUtils.getAvailableDevicesAsync({
|
||||
env,
|
||||
filter: 'booted',
|
||||
});
|
||||
if (!bootedDevice) {
|
||||
throw new SystemError('Could not find a booted iOS simulator for the Appium session.');
|
||||
}
|
||||
return {
|
||||
platformName: 'iOS',
|
||||
automationName: 'XCUITest',
|
||||
driverName: 'xcuitest',
|
||||
udid: bootedDevice.udid,
|
||||
};
|
||||
}
|
||||
case BuildRuntimePlatform.LINUX: {
|
||||
const attachedDevices = await AndroidEmulatorUtils.getAttachedDevicesAsync({ env });
|
||||
const bootedDevice = attachedDevices.find(device => device.state === 'device');
|
||||
if (!bootedDevice) {
|
||||
throw new SystemError('Could not find a booted Android emulator for the Appium session.');
|
||||
}
|
||||
return {
|
||||
platformName: 'Android',
|
||||
automationName: 'UiAutomator2',
|
||||
driverName: 'uiautomator2',
|
||||
udid: bootedDevice.serialId,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function installAppiumAsync({
|
||||
versionSpec,
|
||||
driverName,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
versionSpec: string;
|
||||
driverName: AppiumDevice['driverName'];
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<{ appiumHome: string; appiumBinPath: string; appiumEnv: BuildStepEnv }> {
|
||||
const appiumHome = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-appium-home-'));
|
||||
await fs.promises.writeFile(
|
||||
path.join(appiumHome, 'package.json'),
|
||||
`${JSON.stringify({ name: 'eas-appium-home', private: true })}\n`
|
||||
);
|
||||
const appiumEnv: BuildStepEnv = { ...env, APPIUM_HOME: appiumHome };
|
||||
const appiumBinPath = path.join(appiumHome, 'node_modules', '.bin', 'appium');
|
||||
|
||||
logger.info(`Installing appium@${versionSpec}.`);
|
||||
await spawn('npm', ['install', '--prefix', appiumHome, `appium@${versionSpec}`], {
|
||||
env: appiumEnv,
|
||||
logger,
|
||||
});
|
||||
const { stdout } = await spawn(appiumBinPath, ['driver', 'list', '--installed', '--json'], {
|
||||
env: appiumEnv,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const installedDrivers = AppiumInstalledDriversSchema.parse(JSON.parse(stdout));
|
||||
if (installedDrivers[driverName]?.installed) {
|
||||
logger.info(`Updating the installed Appium ${driverName} driver.`);
|
||||
await spawn(appiumBinPath, ['driver', 'update', driverName], { env: appiumEnv, logger });
|
||||
} else {
|
||||
logger.info(`Installing the Appium ${driverName} driver.`);
|
||||
await spawn(appiumBinPath, ['driver', 'install', driverName], { env: appiumEnv, logger });
|
||||
}
|
||||
return { appiumHome, appiumBinPath, appiumEnv };
|
||||
}
|
||||
|
||||
async function waitForAppiumReadyAsync({
|
||||
appiumProcess,
|
||||
logger,
|
||||
}: {
|
||||
appiumProcess: { getOutput: () => string };
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
const deadline = Date.now() + APPIUM_STARTUP_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await turtleFetch(`http://${APPIUM_HOST}:${APPIUM_PORT}/status`, 'GET', {
|
||||
timeout: 2_000,
|
||||
retries: 0,
|
||||
logger,
|
||||
});
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
const output = appiumProcess.getOutput();
|
||||
throw new SystemError(
|
||||
`Timed out waiting for Appium to become ready.${output ? `\nAppium output:\n${output}` : ''}`
|
||||
);
|
||||
}
|
||||
@@ -68,6 +68,16 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'max_idle_time_minutes',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'max_duration_seconds',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger, global }, { inputs, env, signal }) => {
|
||||
// Fail fast before any expensive setup if the injected env
|
||||
@@ -79,6 +89,9 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
|
||||
|
||||
const packageVersion = inputs.package_version.value as string | undefined;
|
||||
// A missing or non-positive value disables the idle timeout (opt-in feature).
|
||||
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
|
||||
warnIfArgentPackageVersionCannotBeVerified({ packageVersion, logger });
|
||||
const versionSpec = packageVersion ?? 'latest';
|
||||
const { runtimePlatform } = global;
|
||||
@@ -214,7 +227,15 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
maxDurationSeconds,
|
||||
signal,
|
||||
idleTimeout:
|
||||
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} finally {
|
||||
if (serveSim) {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
@@ -21,9 +26,17 @@ export function createStartServeSimRemoteSessionBuildFunction(
|
||||
name: 'Start serve-sim remote session',
|
||||
__metricsId: 'eas/start_serve_sim_remote_session',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
|
||||
fn: async ({ logger }, { env, signal }) => {
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'max_duration_seconds',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger }, { inputs, env, signal }) => {
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
|
||||
|
||||
logger.info('Starting serve-sim remote session.');
|
||||
|
||||
@@ -49,6 +62,7 @@ export function createStartServeSimRemoteSessionBuildFunction(
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
maxDurationSeconds,
|
||||
signal,
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -159,6 +159,37 @@ describe(startAgentDeviceEventCollectionAsync, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('tracks the local arrival time of the most recent event', async () => {
|
||||
const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'agent-device-events-'));
|
||||
const eventsDir = path.join(stateDir, 'sessions', 'default');
|
||||
await fs.promises.mkdir(eventsDir, { recursive: true });
|
||||
const collection = await startAgentDeviceEventCollectionAsync({
|
||||
ctx: createContext(),
|
||||
deviceRunSessionId: 'session-id',
|
||||
stateDir,
|
||||
logger: createLogger(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
|
||||
try {
|
||||
expect(collection.getLastEventObservedAt()).toBeUndefined();
|
||||
|
||||
const before = new Date();
|
||||
await fs.promises.writeFile(
|
||||
path.join(eventsDir, 'events.ndjson'),
|
||||
`${JSON.stringify(createAgentDeviceEvent({ requestId: 'request-1', command: 'tap' }))}\n`
|
||||
);
|
||||
await waitForAsync(() => expect(mockEventLogStream.write).toHaveBeenCalledTimes(1));
|
||||
|
||||
const observedAt = collection.getLastEventObservedAt();
|
||||
expect(observedAt).toBeInstanceOf(Date);
|
||||
expect(observedAt!.getTime()).toBeGreaterThanOrEqual(before.getTime());
|
||||
} finally {
|
||||
await collection.stopAsync();
|
||||
await fs.promises.rm(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves unknown kinds and tolerates optional upstream field changes', async () => {
|
||||
const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'agent-device-events-'));
|
||||
const eventsDir = path.join(stateDir, 'sessions', 'default');
|
||||
@@ -547,6 +578,9 @@ describe(startAgentDeviceEventCollectionAsync, () => {
|
||||
);
|
||||
expect(mockEventLogStream.init).not.toHaveBeenCalled();
|
||||
expect(HttpLogStream).not.toHaveBeenCalled();
|
||||
// Without collection, activity is invisible: the collection reports fresh
|
||||
// activity so an enabled idle timeout never stops the session.
|
||||
expect(collection.getLastEventObservedAt()).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('continues persisting the artifact when real-time log setup fails', async () => {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { startAppiumEventCollectionAsync } from '../appiumEvents';
|
||||
import { startDeviceRunSessionEventCollectionAsync } from '../deviceRunSessionEvents';
|
||||
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../deviceRunSessionEvents', () => ({
|
||||
startDeviceRunSessionEventCollectionAsync: jest.fn(async () => ({
|
||||
getLastEventObservedAt: () => undefined,
|
||||
stopAsync: async () => undefined,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe(startAppiumEventCollectionAsync, () => {
|
||||
it('writes new Appium Event Timings commands to a normal event file', async () => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(response({ value: [{ id: 'appium-session-id' }] }))
|
||||
.mockResolvedValueOnce(
|
||||
response({
|
||||
value: {
|
||||
commands: [
|
||||
{ cmd: 'getOrientation', startTime: 1_786_693_000_000, endTime: 1_786_693_000_025 },
|
||||
{ cmd: 'getLogEvents', startTime: 1_786_693_000_026, endTime: 1_786_693_000_027 },
|
||||
],
|
||||
},
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(response({ value: [] }));
|
||||
|
||||
const collection = await startAppiumEventCollectionAsync({
|
||||
ctx: {} as never,
|
||||
deviceRunSessionId: 'device-session-id',
|
||||
appiumUrl: 'http://127.0.0.1:4723/',
|
||||
logger: { warn: jest.fn() } as never,
|
||||
pollIntervalMs: 60_000,
|
||||
});
|
||||
const source = jest.mocked(startDeviceRunSessionEventCollectionAsync).mock.calls[0][0].source;
|
||||
const [eventFile] = await source.findEventFilesAsync();
|
||||
let contents = '';
|
||||
await waitForAsync(async () => {
|
||||
contents = await fs.promises.readFile(eventFile, 'utf8');
|
||||
expect(contents).toContain('getOrientation');
|
||||
});
|
||||
const lines = contents.trim().split('\n');
|
||||
expect(lines).toHaveLength(1);
|
||||
const [line] = lines;
|
||||
expect(
|
||||
source.parseLine({
|
||||
line,
|
||||
sourceKey: source.sourceKeyForFile(eventFile),
|
||||
sequenceNumber: 1,
|
||||
deviceRunSessionId: 'device-session-id',
|
||||
})
|
||||
).toMatchObject({
|
||||
event: {
|
||||
producer: 'appium',
|
||||
type: 'operation.completed',
|
||||
durationMs: 25,
|
||||
summary: 'getOrientation',
|
||||
},
|
||||
});
|
||||
|
||||
// Appium 3 removed GET /sessions; session discovery must use /appium/sessions.
|
||||
expect(turtleFetch).toHaveBeenCalledWith(
|
||||
'http://127.0.0.1:4723/appium/sessions',
|
||||
'GET',
|
||||
expect.anything()
|
||||
);
|
||||
expect(turtleFetch).toHaveBeenCalledWith(
|
||||
'http://127.0.0.1:4723/session/appium-session-id/appium/events',
|
||||
'POST',
|
||||
expect.anything()
|
||||
);
|
||||
|
||||
await collection.stopAsync();
|
||||
});
|
||||
});
|
||||
|
||||
async function waitForAsync(assertion: () => Promise<void>): Promise<void> {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
try {
|
||||
await assertion();
|
||||
return;
|
||||
} catch {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
}
|
||||
await assertion();
|
||||
}
|
||||
|
||||
function response(value: unknown): Awaited<ReturnType<typeof turtleFetch>> {
|
||||
return { json: async () => value } as never;
|
||||
}
|
||||
@@ -135,10 +135,69 @@ describe(uploadArgentArtifactAsync, () => {
|
||||
artifactId: 'artifact-id',
|
||||
name: 'report.json (artifact-id)',
|
||||
filename: 'report.json',
|
||||
kind: undefined,
|
||||
size: data.length,
|
||||
stream: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
async function uploadAsync(artifact: {
|
||||
id: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
isDirectory?: boolean;
|
||||
}): Promise<CustomBuildContext> {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValueOnce(new Response(Readable.from([Buffer.from('artifact-data')])));
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { stream }) => {
|
||||
await readStreamAsync(stream);
|
||||
});
|
||||
|
||||
await uploadArgentArtifactAsync(ctx, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
toolsUrl: 'http://127.0.0.1:1234',
|
||||
logger: createLoggerMock(),
|
||||
artifact,
|
||||
});
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
it.each([
|
||||
{ mimeType: 'image/png', filename: 'screen.png', kind: 'screenshot' },
|
||||
{ mimeType: 'image/jpeg', filename: 'screen.jpg', kind: 'screenshot' },
|
||||
// Media types are case-insensitive and may carry parameters.
|
||||
{ mimeType: 'IMAGE/PNG; charset=binary', filename: 'screen.png', kind: 'screenshot' },
|
||||
{ mimeType: 'video/mp4', filename: 'session.mp4', kind: 'screen-recording' },
|
||||
{ mimeType: 'video/quicktime', filename: 'session.mov', kind: 'screen-recording' },
|
||||
{ mimeType: 'application/json', filename: 'report.json', kind: undefined },
|
||||
{ mimeType: 'text/plain', filename: 'log.txt', kind: undefined },
|
||||
])('uploads $mimeType with kind $kind', async ({ mimeType, filename, kind }) => {
|
||||
const ctx = await uploadAsync({ id: 'artifact-id', filename, mimeType });
|
||||
|
||||
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ filename, kind })
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves directory artifacts unclassified because they are uploaded as a tarball', async () => {
|
||||
const ctx = await uploadAsync({
|
||||
id: 'artifact-id',
|
||||
filename: 'screenshots',
|
||||
mimeType: 'image/png',
|
||||
isDirectory: true,
|
||||
});
|
||||
|
||||
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ filename: 'screenshots.tar.gz', kind: undefined })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(pollArgentArtifactsForUploadAsync, () => {
|
||||
|
||||
@@ -2,6 +2,10 @@ import { bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import * as ngrok from '@ngrok/ngrok';
|
||||
import {
|
||||
clearTimeout as clearTimeoutCallback,
|
||||
setTimeout as setTimeoutCallback,
|
||||
} from 'node:timers';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
@@ -20,6 +24,7 @@ import {
|
||||
} from '../remoteDeviceRunSession';
|
||||
|
||||
jest.mock('@ngrok/ngrok');
|
||||
jest.mock('node:timers');
|
||||
jest.mock('node:timers/promises');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../../../utils/retry', () => ({ sleepAsync: jest.fn() }));
|
||||
@@ -51,7 +56,8 @@ function createStatusCtxMock(
|
||||
| { status: 'NEW' | 'IN_PROGRESS' | 'STOPPED' | 'ERRORED' }
|
||||
| { error: Error }
|
||||
| { data: unknown }
|
||||
)[]
|
||||
)[],
|
||||
{ ensureStoppedError }: { ensureStoppedError?: Error } = {}
|
||||
): CustomBuildContext {
|
||||
const query = jest.fn(() => {
|
||||
const result = results.shift();
|
||||
@@ -80,9 +86,25 @@ function createStatusCtxMock(
|
||||
};
|
||||
});
|
||||
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => {
|
||||
if (ensureStoppedError) {
|
||||
return { error: ensureStoppedError };
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
ensureDeviceRunSessionStopped: { id: 'drs-id', status: 'STOPPED' },
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
return {
|
||||
graphqlClient: {
|
||||
query,
|
||||
mutation,
|
||||
},
|
||||
} as unknown as CustomBuildContext;
|
||||
}
|
||||
@@ -343,8 +365,13 @@ describe(fetchServeSimTurnArgsAsync, () => {
|
||||
});
|
||||
|
||||
describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
const durationTimeout = {} as NodeJS.Timeout;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(Sentry).capture.mockReset();
|
||||
jest.mocked(setTimeoutCallback).mockReset();
|
||||
jest.mocked(setTimeoutCallback).mockReturnValue(durationTimeout);
|
||||
jest.mocked(clearTimeoutCallback).mockReset();
|
||||
jest.mocked(setTimeoutAsync).mockReset();
|
||||
jest.mocked(setTimeoutAsync).mockResolvedValue(undefined);
|
||||
});
|
||||
@@ -361,6 +388,57 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
|
||||
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(2);
|
||||
expect(logger.info).toHaveBeenCalledWith('Device run session drs-id was stopped.');
|
||||
expect(setTimeoutCallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns normally when the maximum duration elapses', async () => {
|
||||
const ctx = createStatusCtxMock([{ status: 'IN_PROGRESS' }]);
|
||||
const logger = createLoggerMock();
|
||||
const waitPromise = waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger,
|
||||
maxDurationSeconds: 1,
|
||||
});
|
||||
|
||||
expect(setTimeoutCallback).toHaveBeenCalledWith(expect.any(Function), 1_000);
|
||||
const durationTimeoutCallback = jest.mocked(setTimeoutCallback).mock.calls[0][0];
|
||||
durationTimeoutCallback();
|
||||
await waitPromise;
|
||||
|
||||
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(1);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Device run session drs-id reached its maximum duration.'
|
||||
);
|
||||
expect(clearTimeoutCallback).toHaveBeenCalledWith(durationTimeout);
|
||||
});
|
||||
|
||||
it('clears the duration timeout when the session stops first', async () => {
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx: createStatusCtxMock([{ status: 'STOPPED' }]),
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger: createLoggerMock(),
|
||||
maxDurationSeconds: 30,
|
||||
});
|
||||
|
||||
expect(clearTimeoutCallback).toHaveBeenCalledWith(durationTimeout);
|
||||
});
|
||||
|
||||
it('does not poll when the build step is already aborted', async () => {
|
||||
const ctx = createStatusCtxMock([]);
|
||||
const abortController = new AbortController();
|
||||
abortController.abort();
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger: createLoggerMock(),
|
||||
maxDurationSeconds: 30,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
expect(ctx.graphqlClient.query).not.toHaveBeenCalled();
|
||||
expect(setTimeoutCallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when the device run session errors', async () => {
|
||||
@@ -371,8 +449,28 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger: createLoggerMock(),
|
||||
maxDurationSeconds: 30,
|
||||
})
|
||||
).rejects.toThrow('Device run session drs-id errored.');
|
||||
expect(clearTimeoutCallback).toHaveBeenCalledWith(durationTimeout);
|
||||
});
|
||||
|
||||
it('clears the duration timeout when the build step is aborted', async () => {
|
||||
const ctx = createStatusCtxMock([{ status: 'IN_PROGRESS' }]);
|
||||
const abortController = new AbortController();
|
||||
const waitPromise = waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger: createLoggerMock(),
|
||||
maxDurationSeconds: 30,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
abortController.abort();
|
||||
await waitPromise;
|
||||
|
||||
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(1);
|
||||
expect(clearTimeoutCallback).toHaveBeenCalledWith(durationTimeout);
|
||||
});
|
||||
|
||||
it('logs and retries transient polling errors', async () => {
|
||||
@@ -423,6 +521,97 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
{ level: 'warning' }
|
||||
);
|
||||
});
|
||||
|
||||
describe('with an idle timeout', () => {
|
||||
beforeEach(() => {
|
||||
// Fake timers make Date.now() advance by exactly the poll interval per
|
||||
// loop iteration, so idle time accumulates deterministically.
|
||||
jest.useFakeTimers();
|
||||
jest.mocked(setTimeoutAsync).mockImplementation(async delayMs => {
|
||||
jest.advanceTimersByTime(delayMs ?? 0);
|
||||
return undefined;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
function manyInProgressStatuses(): { status: 'IN_PROGRESS' }[] {
|
||||
return Array.from({ length: 20 }, () => ({ status: 'IN_PROGRESS' as const }));
|
||||
}
|
||||
|
||||
it('stops the session when no activity is observed within the max idle time', async () => {
|
||||
const ctx = createStatusCtxMock(manyInProgressStatuses());
|
||||
const logger = createLoggerMock();
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger,
|
||||
idleTimeout: {
|
||||
maxIdleTimeMinutes: 1,
|
||||
getLastEventObservedAt: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// One minute at the 5-second poll interval is 12 status polls.
|
||||
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(12);
|
||||
expect(ctx.graphqlClient.mutation).toHaveBeenCalledTimes(1);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Device run session drs-id had no activity for 1 minute(s) (max idle time). Stopping the session.'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the session alive while events keep arriving', async () => {
|
||||
const ctx = createStatusCtxMock([...manyInProgressStatuses(), { status: 'STOPPED' }]);
|
||||
const logger = createLoggerMock();
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger,
|
||||
idleTimeout: {
|
||||
maxIdleTimeMinutes: 1,
|
||||
// Fresh activity on every check; 20 polls exceed one minute, so the
|
||||
// session would have been stopped without these events.
|
||||
getLastEventObservedAt: () => new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(21);
|
||||
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenCalledWith('Device run session drs-id was stopped.');
|
||||
});
|
||||
|
||||
it('still returns when the session cannot be marked stopped', async () => {
|
||||
const ctx = createStatusCtxMock(manyInProgressStatuses(), {
|
||||
ensureStoppedError: new Error('forbidden'),
|
||||
});
|
||||
const logger = createLoggerMock();
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger,
|
||||
idleTimeout: {
|
||||
maxIdleTimeMinutes: 1,
|
||||
getLastEventObservedAt: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(ctx.graphqlClient.mutation).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
'Could not mark device run session drs-id as stopped. The session job ends anyway.'
|
||||
);
|
||||
expect(jest.mocked(Sentry).capture).toHaveBeenCalledWith(
|
||||
'Could not mark idle device run session as stopped',
|
||||
expect.any(Error),
|
||||
{ level: 'warning', extras: { deviceRunSessionId: 'drs-id' } }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(ensureFfmpegInstalledAsync, () => {
|
||||
|
||||
@@ -83,6 +83,36 @@ describe(uploadServeSimMetricsFileAsync, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('labels the artifact with the device name when the meta carries one', async () => {
|
||||
await writeFile(filePath, NDJSON);
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementation(async (_ctx, { stream }) => {
|
||||
await readStreamAsync(stream);
|
||||
});
|
||||
|
||||
await uploadServeSimMetricsFileAsync(ctx, {
|
||||
deviceRunSessionId: 'session-id',
|
||||
udid: 'AAAA',
|
||||
filePath,
|
||||
metadata: {
|
||||
deviceName: 'iPhone 15 Pro',
|
||||
hostCores: 8,
|
||||
sampleIntervalMs: 1000,
|
||||
schemaVersion: 1,
|
||||
},
|
||||
logger: createLoggerMock(),
|
||||
});
|
||||
|
||||
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
name: 'Performance metrics (iPhone 15 Pro)',
|
||||
metadata: expect.objectContaining({ deviceName: 'iPhone 15 Pro', udid: 'AAAA' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('skips the upload when the file is missing', async () => {
|
||||
await uploadServeSimMetricsFileAsync(ctx, {
|
||||
deviceRunSessionId: 'session-id',
|
||||
|
||||
@@ -6,6 +6,7 @@ import { z } from 'zod';
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
type DeviceRunSessionEvent,
|
||||
type DeviceRunSessionEventCollection,
|
||||
type DeviceRunSessionEventParseFailure,
|
||||
startDeviceRunSessionEventCollectionAsync,
|
||||
} from './deviceRunSessionEvents';
|
||||
@@ -46,7 +47,7 @@ export async function startAgentDeviceEventCollectionAsync({
|
||||
stateDir: string;
|
||||
logger: bunyan;
|
||||
pollIntervalMs?: number;
|
||||
}): Promise<{ stopAsync: () => Promise<void> }> {
|
||||
}): Promise<DeviceRunSessionEventCollection> {
|
||||
return startDeviceRunSessionEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
|
||||
+144
-8
@@ -23,7 +23,12 @@ android {
|
||||
versionName = versionNameVal
|
||||
}
|
||||
}
|
||||
applicationVariants.all { variant ->
|
||||
}
|
||||
|
||||
// AGP 9's new DSL removes the applicationVariants API, we set the version on each output through the androidComponents API instead.
|
||||
def easAndroidExtension = project.extensions.getByName('android')
|
||||
if (easAndroidExtension.hasProperty('applicationVariants')) {
|
||||
easAndroidExtension.applicationVariants.all { variant ->
|
||||
variant.outputs.each { output ->
|
||||
if (versionCodeVal) {
|
||||
output.versionCodeOverride = Integer.parseInt(versionCodeVal)
|
||||
@@ -33,6 +38,18 @@ android {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
def easAndroidComponents = project.extensions.getByName('androidComponents')
|
||||
easAndroidComponents.onVariants(easAndroidComponents.selector().all()) { variant ->
|
||||
variant.outputs.each { output ->
|
||||
if (versionCodeVal) {
|
||||
output.versionCode.set(Integer.parseInt(versionCodeVal))
|
||||
}
|
||||
if (versionNameVal) {
|
||||
output.versionName.set(versionNameVal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"
|
||||
`;
|
||||
@@ -56,7 +73,12 @@ android {
|
||||
versionName = versionNameVal
|
||||
}
|
||||
}
|
||||
applicationVariants.all { variant ->
|
||||
}
|
||||
|
||||
// AGP 9's new DSL removes the applicationVariants API, we set the version on each output through the androidComponents API instead.
|
||||
def easAndroidExtension = project.extensions.getByName('android')
|
||||
if (easAndroidExtension.hasProperty('applicationVariants')) {
|
||||
easAndroidExtension.applicationVariants.all { variant ->
|
||||
variant.outputs.each { output ->
|
||||
if (versionCodeVal) {
|
||||
output.versionCodeOverride = Integer.parseInt(versionCodeVal)
|
||||
@@ -66,6 +88,18 @@ android {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
def easAndroidComponents = project.extensions.getByName('androidComponents')
|
||||
easAndroidComponents.onVariants(easAndroidComponents.selector().all()) { variant ->
|
||||
variant.outputs.each { output ->
|
||||
if (versionCodeVal) {
|
||||
output.versionCode.set(Integer.parseInt(versionCodeVal))
|
||||
}
|
||||
if (versionNameVal) {
|
||||
output.versionName.set(versionNameVal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"
|
||||
`;
|
||||
@@ -91,7 +125,12 @@ android {
|
||||
versionName = versionNameVal
|
||||
}
|
||||
}
|
||||
applicationVariants.all { variant ->
|
||||
}
|
||||
|
||||
// AGP 9's new DSL removes the applicationVariants API, we set the version on each output through the androidComponents API instead.
|
||||
def easAndroidExtension = project.extensions.getByName('android')
|
||||
if (easAndroidExtension.hasProperty('applicationVariants')) {
|
||||
easAndroidExtension.applicationVariants.all { variant ->
|
||||
variant.outputs.each { output ->
|
||||
if (versionCodeVal) {
|
||||
output.versionCodeOverride = Integer.parseInt(versionCodeVal)
|
||||
@@ -101,6 +140,18 @@ android {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
def easAndroidComponents = project.extensions.getByName('androidComponents')
|
||||
easAndroidComponents.onVariants(easAndroidComponents.selector().all()) { variant ->
|
||||
variant.outputs.each { output ->
|
||||
if (versionCodeVal) {
|
||||
output.versionCode.set(Integer.parseInt(versionCodeVal))
|
||||
}
|
||||
if (versionNameVal) {
|
||||
output.versionName.set(versionNameVal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"
|
||||
`;
|
||||
@@ -126,7 +177,12 @@ android {
|
||||
versionName = versionNameVal
|
||||
}
|
||||
}
|
||||
applicationVariants.all { variant ->
|
||||
}
|
||||
|
||||
// AGP 9's new DSL removes the applicationVariants API, we set the version on each output through the androidComponents API instead.
|
||||
def easAndroidExtension = project.extensions.getByName('android')
|
||||
if (easAndroidExtension.hasProperty('applicationVariants')) {
|
||||
easAndroidExtension.applicationVariants.all { variant ->
|
||||
variant.outputs.each { output ->
|
||||
if (versionCodeVal) {
|
||||
output.versionCodeOverride = Integer.parseInt(versionCodeVal)
|
||||
@@ -136,6 +192,18 @@ android {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
def easAndroidComponents = project.extensions.getByName('androidComponents')
|
||||
easAndroidComponents.onVariants(easAndroidComponents.selector().all()) { variant ->
|
||||
variant.outputs.each { output ->
|
||||
if (versionCodeVal) {
|
||||
output.versionCode.set(Integer.parseInt(versionCodeVal))
|
||||
}
|
||||
if (versionNameVal) {
|
||||
output.versionName.set(versionNameVal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"
|
||||
`;
|
||||
@@ -163,7 +231,12 @@ android {
|
||||
versionName = versionNameVal
|
||||
}
|
||||
}
|
||||
applicationVariants.all { variant ->
|
||||
}
|
||||
|
||||
// AGP 9's new DSL removes the applicationVariants API, we set the version on each output through the androidComponents API instead.
|
||||
def easAndroidExtension = project.extensions.getByName('android')
|
||||
if (easAndroidExtension.hasProperty('applicationVariants')) {
|
||||
easAndroidExtension.applicationVariants.all { variant ->
|
||||
variant.outputs.each { output ->
|
||||
if (versionCodeVal) {
|
||||
output.versionCodeOverride = Integer.parseInt(versionCodeVal)
|
||||
@@ -173,6 +246,18 @@ android {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
def easAndroidComponents = project.extensions.getByName('androidComponents')
|
||||
easAndroidComponents.onVariants(easAndroidComponents.selector().all()) { variant ->
|
||||
variant.outputs.each { output ->
|
||||
if (versionCodeVal) {
|
||||
output.versionCode.set(Integer.parseInt(versionCodeVal))
|
||||
}
|
||||
if (versionNameVal) {
|
||||
output.versionName.set(versionNameVal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"
|
||||
`;
|
||||
@@ -200,7 +285,12 @@ android {
|
||||
versionName = versionNameVal
|
||||
}
|
||||
}
|
||||
applicationVariants.all { variant ->
|
||||
}
|
||||
|
||||
// AGP 9's new DSL removes the applicationVariants API, we set the version on each output through the androidComponents API instead.
|
||||
def easAndroidExtension = project.extensions.getByName('android')
|
||||
if (easAndroidExtension.hasProperty('applicationVariants')) {
|
||||
easAndroidExtension.applicationVariants.all { variant ->
|
||||
variant.outputs.each { output ->
|
||||
if (versionCodeVal) {
|
||||
output.versionCodeOverride = Integer.parseInt(versionCodeVal)
|
||||
@@ -210,6 +300,18 @@ android {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
def easAndroidComponents = project.extensions.getByName('androidComponents')
|
||||
easAndroidComponents.onVariants(easAndroidComponents.selector().all()) { variant ->
|
||||
variant.outputs.each { output ->
|
||||
if (versionCodeVal) {
|
||||
output.versionCode.set(Integer.parseInt(versionCodeVal))
|
||||
}
|
||||
if (versionNameVal) {
|
||||
output.versionName.set(versionNameVal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"
|
||||
`;
|
||||
@@ -237,7 +339,12 @@ android {
|
||||
versionName = versionNameVal
|
||||
}
|
||||
}
|
||||
applicationVariants.all { variant ->
|
||||
}
|
||||
|
||||
// AGP 9's new DSL removes the applicationVariants API, we set the version on each output through the androidComponents API instead.
|
||||
def easAndroidExtension = project.extensions.getByName('android')
|
||||
if (easAndroidExtension.hasProperty('applicationVariants')) {
|
||||
easAndroidExtension.applicationVariants.all { variant ->
|
||||
variant.outputs.each { output ->
|
||||
if (versionCodeVal) {
|
||||
output.versionCodeOverride = Integer.parseInt(versionCodeVal)
|
||||
@@ -247,6 +354,18 @@ android {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
def easAndroidComponents = project.extensions.getByName('androidComponents')
|
||||
easAndroidComponents.onVariants(easAndroidComponents.selector().all()) { variant ->
|
||||
variant.outputs.each { output ->
|
||||
if (versionCodeVal) {
|
||||
output.versionCode.set(Integer.parseInt(versionCodeVal))
|
||||
}
|
||||
if (versionNameVal) {
|
||||
output.versionName.set(versionNameVal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"
|
||||
`;
|
||||
@@ -274,7 +393,12 @@ android {
|
||||
versionName = versionNameVal
|
||||
}
|
||||
}
|
||||
applicationVariants.all { variant ->
|
||||
}
|
||||
|
||||
// AGP 9's new DSL removes the applicationVariants API, we set the version on each output through the androidComponents API instead.
|
||||
def easAndroidExtension = project.extensions.getByName('android')
|
||||
if (easAndroidExtension.hasProperty('applicationVariants')) {
|
||||
easAndroidExtension.applicationVariants.all { variant ->
|
||||
variant.outputs.each { output ->
|
||||
if (versionCodeVal) {
|
||||
output.versionCodeOverride = Integer.parseInt(versionCodeVal)
|
||||
@@ -284,6 +408,18 @@ android {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
def easAndroidComponents = project.extensions.getByName('androidComponents')
|
||||
easAndroidComponents.onVariants(easAndroidComponents.selector().all()) { variant ->
|
||||
variant.outputs.each { output ->
|
||||
if (versionCodeVal) {
|
||||
output.versionCode.set(Integer.parseInt(versionCodeVal))
|
||||
}
|
||||
if (versionNameVal) {
|
||||
output.versionName.set(versionNameVal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"
|
||||
`;
|
||||
@@ -0,0 +1,216 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import {
|
||||
type DeviceRunSessionEventCollection,
|
||||
type DeviceRunSessionEventSource,
|
||||
startDeviceRunSessionEventCollectionAsync,
|
||||
} from './deviceRunSessionEvents';
|
||||
|
||||
const APPIUM_REQUEST_TIMEOUT_MS = 10_000;
|
||||
const POLL_INTERVAL_MS = 1_000;
|
||||
const GET_LOG_EVENTS_COMMAND = 'getLogEvents';
|
||||
|
||||
const AppiumSessionsResponseSchema = z.object({
|
||||
value: z.array(z.object({ id: z.string() }).passthrough()),
|
||||
});
|
||||
|
||||
const AppiumCommandSchema = z.object({
|
||||
appiumSessionId: z.string(),
|
||||
cmd: z.string(),
|
||||
startTime: z.number(),
|
||||
endTime: z.number(),
|
||||
});
|
||||
|
||||
const AppiumEventsResponseSchema = z.object({
|
||||
value: z.object({
|
||||
commands: z.array(AppiumCommandSchema.omit({ appiumSessionId: true })),
|
||||
}),
|
||||
});
|
||||
|
||||
type AppiumCommand = z.infer<typeof AppiumCommandSchema>;
|
||||
|
||||
export async function startAppiumEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
appiumUrl,
|
||||
logger,
|
||||
pollIntervalMs = POLL_INTERVAL_MS,
|
||||
}: {
|
||||
ctx: CustomBuildContext;
|
||||
deviceRunSessionId: string;
|
||||
appiumUrl: string;
|
||||
logger: bunyan;
|
||||
pollIntervalMs?: number;
|
||||
}): Promise<DeviceRunSessionEventCollection> {
|
||||
const eventDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-appium-events-'));
|
||||
const eventFile = path.join(eventDirectory, 'commands.ndjson');
|
||||
await fs.promises.writeFile(eventFile, '');
|
||||
|
||||
let eventCollection: DeviceRunSessionEventCollection;
|
||||
try {
|
||||
eventCollection = await startDeviceRunSessionEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
pollIntervalMs,
|
||||
source: createAppiumEventSource(eventFile),
|
||||
});
|
||||
} catch (error) {
|
||||
await fs.promises.rm(eventDirectory, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
|
||||
const observedCommandKeys = new Set<string>();
|
||||
const controller = new AbortController();
|
||||
let didReportCollectionFailure = false;
|
||||
const collectSafelyAsync = async (): Promise<void> => {
|
||||
try {
|
||||
await collectAppiumCommandsAsync({
|
||||
appiumUrl,
|
||||
eventFile,
|
||||
observedCommandKeys,
|
||||
logger,
|
||||
});
|
||||
didReportCollectionFailure = false;
|
||||
} catch (error) {
|
||||
if (!didReportCollectionFailure) {
|
||||
didReportCollectionFailure = true;
|
||||
logger.warn({ err: error }, 'Could not collect Appium events.');
|
||||
}
|
||||
}
|
||||
};
|
||||
const pollingPromise = (async () => {
|
||||
while (!controller.signal.aborted) {
|
||||
await collectSafelyAsync();
|
||||
try {
|
||||
await setTimeoutAsync(pollIntervalMs, undefined, { signal: controller.signal });
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
})().catch(error => {
|
||||
logger.warn({ err: error }, 'Appium event collection poller failed.');
|
||||
});
|
||||
|
||||
return {
|
||||
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
||||
stopAsync: async () => {
|
||||
controller.abort();
|
||||
await pollingPromise;
|
||||
await collectSafelyAsync();
|
||||
try {
|
||||
await eventCollection.stopAsync();
|
||||
} finally {
|
||||
await fs.promises.rm(eventDirectory, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createAppiumEventSource(eventFile: string): DeviceRunSessionEventSource {
|
||||
return {
|
||||
producer: 'appium',
|
||||
findEventFilesAsync: async () => [eventFile],
|
||||
sourceKeyForFile: file => path.basename(file, path.extname(file)),
|
||||
parseLine: ({ line, sourceKey, sequenceNumber, deviceRunSessionId }) => {
|
||||
if (!line.trim()) {
|
||||
return {};
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
return { failure: 'invalid-json' };
|
||||
}
|
||||
const result = AppiumCommandSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
return { failure: 'invalid-event' };
|
||||
}
|
||||
const command = result.data;
|
||||
const operationId = `${sourceKey}:${sequenceNumber}`;
|
||||
return {
|
||||
event: {
|
||||
v: 1,
|
||||
eventId: `appium:${deviceRunSessionId}:${operationId}`,
|
||||
ts: new Date(command.endTime).toISOString(),
|
||||
producer: 'appium',
|
||||
type: 'operation.completed',
|
||||
operationId,
|
||||
durationMs: Math.max(0, command.endTime - command.startTime),
|
||||
summary: command.cmd,
|
||||
data: {
|
||||
command: command.cmd,
|
||||
appiumSessionId: command.appiumSessionId,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function collectAppiumCommandsAsync({
|
||||
appiumUrl,
|
||||
eventFile,
|
||||
observedCommandKeys,
|
||||
logger,
|
||||
}: {
|
||||
appiumUrl: string;
|
||||
eventFile: string;
|
||||
observedCommandKeys: Set<string>;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
const response = await turtleFetch(new URL('appium/sessions', appiumUrl).toString(), 'GET', {
|
||||
timeout: APPIUM_REQUEST_TIMEOUT_MS,
|
||||
retries: 0,
|
||||
logger,
|
||||
});
|
||||
const sessions = AppiumSessionsResponseSchema.parse(await response.json()).value;
|
||||
const newCommands: { key: string; command: AppiumCommand }[] = [];
|
||||
const pendingCommandKeys = new Set<string>();
|
||||
|
||||
for (const { id: appiumSessionId } of sessions) {
|
||||
const eventsResponse = await turtleFetch(
|
||||
new URL(`session/${encodeURIComponent(appiumSessionId)}/appium/events`, appiumUrl).toString(),
|
||||
'POST',
|
||||
{
|
||||
json: {},
|
||||
timeout: APPIUM_REQUEST_TIMEOUT_MS,
|
||||
retries: 0,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
const commands = AppiumEventsResponseSchema.parse(await eventsResponse.json()).value.commands;
|
||||
for (const command of commands) {
|
||||
const key = [appiumSessionId, command.cmd, command.startTime, command.endTime].join(':');
|
||||
if (
|
||||
command.cmd === GET_LOG_EVENTS_COMMAND ||
|
||||
observedCommandKeys.has(key) ||
|
||||
pendingCommandKeys.has(key)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
pendingCommandKeys.add(key);
|
||||
newCommands.push({ key, command: { appiumSessionId, ...command } });
|
||||
}
|
||||
}
|
||||
|
||||
if (newCommands.length === 0) {
|
||||
return;
|
||||
}
|
||||
await fs.promises.appendFile(
|
||||
eventFile,
|
||||
`${newCommands.map(({ command }) => JSON.stringify(command)).join('\n')}\n`
|
||||
);
|
||||
for (const { key } of newCommands) {
|
||||
observedCommandKeys.add(key);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,18 @@ const ARGENT_ARTIFACT_UPLOAD_POLL_INTERVAL_MS = 5_000;
|
||||
const ARGENT_ARTIFACT_UPLOAD_CLEANUP_TIMEOUT_MS = 30_000;
|
||||
const ARGENT_ARTIFACT_FETCH_TIMEOUT_MS = 10_000;
|
||||
|
||||
// Kinds the EAS dashboard groups device run session artifacts by. Only media types the dashboard
|
||||
// renders specially are mapped; anything else stays unclassified and lands in its "Other" group.
|
||||
// Note that these kinds only affect grouping and labelling. The dashboard gates its inline video
|
||||
// player on the `__eas_screen_recording` metadata flag, which Argent artifacts deliberately do not
|
||||
// set, so a `screen-recording` kind here does not add a player to the session page.
|
||||
const ARGENT_ARTIFACT_KIND_BY_MIME_TYPE = new Map<string, string>([
|
||||
['image/png', 'screenshot'],
|
||||
['image/jpeg', 'screenshot'],
|
||||
['video/mp4', 'screen-recording'],
|
||||
['video/quicktime', 'screen-recording'],
|
||||
]);
|
||||
|
||||
const ArgentArtifactSchema = z.object({
|
||||
id: z.string(),
|
||||
filename: z.string(),
|
||||
@@ -30,6 +42,17 @@ const ArgentArtifactsListResponseSchema = z.object({
|
||||
|
||||
type ArgentArtifact = z.infer<typeof ArgentArtifactSchema>;
|
||||
|
||||
function getArgentArtifactKind(artifact: ArgentArtifact): string | undefined {
|
||||
// Directories are repackaged as a tarball before upload, so the reported media type describes the
|
||||
// contents rather than the file we actually store.
|
||||
if (artifact.isDirectory) {
|
||||
return undefined;
|
||||
}
|
||||
// Media types are case-insensitive and may carry parameters, e.g. `image/png; charset=binary`.
|
||||
const mediaType = artifact.mimeType.split(';')[0].trim().toLowerCase();
|
||||
return ARGENT_ARTIFACT_KIND_BY_MIME_TYPE.get(mediaType);
|
||||
}
|
||||
|
||||
export async function pollArgentArtifactsForUploadAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
@@ -184,7 +207,7 @@ export async function uploadArgentArtifactAsync(
|
||||
artifactId: artifact.id,
|
||||
name: `${filename} (${artifact.id})`,
|
||||
filename,
|
||||
kind: undefined,
|
||||
kind: getArgentArtifactKind(artifact),
|
||||
size,
|
||||
stream: createReadStream(temporaryArtifactPath),
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { z } from 'zod';
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
type DeviceRunSessionEvent,
|
||||
type DeviceRunSessionEventCollection,
|
||||
type DeviceRunSessionEventParseFailure,
|
||||
startDeviceRunSessionEventCollectionAsync,
|
||||
} from './deviceRunSessionEvents';
|
||||
@@ -75,7 +76,7 @@ export async function startArgentEventCollectionAsync({
|
||||
eventLogPath: string;
|
||||
logger: bunyan;
|
||||
pollIntervalMs?: number;
|
||||
}): Promise<{ stopAsync: () => Promise<void> }> {
|
||||
}): Promise<DeviceRunSessionEventCollection> {
|
||||
return startDeviceRunSessionEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
|
||||
@@ -94,6 +94,17 @@ type EventFileState = {
|
||||
decoder: StringDecoder;
|
||||
};
|
||||
|
||||
export type DeviceRunSessionEventCollection = {
|
||||
stopAsync: () => Promise<void>;
|
||||
/**
|
||||
* Local arrival time of the most recently collected event, or `undefined`
|
||||
* when no event has been observed yet. Uses the collector's clock rather
|
||||
* than the event's embedded timestamp so producer clock skew cannot affect
|
||||
* idle detection.
|
||||
*/
|
||||
getLastEventObservedAt: () => Date | undefined;
|
||||
};
|
||||
|
||||
export async function startDeviceRunSessionEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
@@ -106,7 +117,7 @@ export async function startDeviceRunSessionEventCollectionAsync({
|
||||
source: DeviceRunSessionEventSource;
|
||||
logger: bunyan;
|
||||
pollIntervalMs?: number;
|
||||
}): Promise<{ stopAsync: () => Promise<void> }> {
|
||||
}): Promise<DeviceRunSessionEventCollection> {
|
||||
const { producer } = source;
|
||||
let didReportEventLogFailure = false;
|
||||
const reportEventLogFailure = (error: Error, operation: 'setup' | 'cleanup'): void => {
|
||||
@@ -136,7 +147,13 @@ export async function startDeviceRunSessionEventCollectionAsync({
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
logger.warn({ err: error }, 'Could not persist device run session events to the artifact.');
|
||||
reportEventLogFailure(error, 'setup');
|
||||
return { stopAsync: async () => {} };
|
||||
return {
|
||||
stopAsync: async () => {},
|
||||
// Collection is disabled, so session activity is invisible here. Report
|
||||
// fresh activity so an enabled idle timeout never stops a session it
|
||||
// cannot observe.
|
||||
getLastEventObservedAt: () => new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
let didReportRealtimeLogFailure = false;
|
||||
@@ -166,6 +183,7 @@ export async function startDeviceRunSessionEventCollectionAsync({
|
||||
|
||||
const states = new Map<string, EventFileState>();
|
||||
const controller = new AbortController();
|
||||
let lastEventObservedAt: Date | undefined;
|
||||
let parseFailureCount = 0;
|
||||
const parseFailureCounts: Record<DeviceRunSessionEventParseFailure, number> = {
|
||||
'invalid-json': 0,
|
||||
@@ -190,6 +208,7 @@ export async function startDeviceRunSessionEventCollectionAsync({
|
||||
source,
|
||||
deviceRunSessionId,
|
||||
writeEvent: event => {
|
||||
lastEventObservedAt = new Date();
|
||||
eventLogStream.write(event);
|
||||
realtimeLogStream?.write({ ...event, logId: event.eventId });
|
||||
},
|
||||
@@ -259,6 +278,7 @@ export async function startDeviceRunSessionEventCollectionAsync({
|
||||
});
|
||||
|
||||
return {
|
||||
getLastEventObservedAt: () => lastEventObservedAt,
|
||||
stopAsync: async () => {
|
||||
controller.abort();
|
||||
await pollingPromise;
|
||||
@@ -310,7 +330,7 @@ function createRealtimeLogStream(
|
||||
? 'http://localhost:4999/logs/'
|
||||
: ctx.env.EXPO_STAGING
|
||||
? 'https://staging-logs.expo.dev/logs/'
|
||||
: undefined;
|
||||
: 'https://logs.expo.dev/logs/';
|
||||
const jobRunId = ctx.env.EAS_BUILD_ID;
|
||||
const robotAccessToken = ctx.job.secrets?.robotAccessToken;
|
||||
if (!baseUrl || !jobRunId || !robotAccessToken) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { z } from 'zod';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import { createServer } from 'node:net';
|
||||
import { clearTimeout, setTimeout } from 'node:timers';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
@@ -50,6 +51,17 @@ const DEVICE_RUN_SESSION_STATUS_QUERY = graphql(`
|
||||
}
|
||||
`);
|
||||
|
||||
const ENSURE_DEVICE_RUN_SESSION_STOPPED_MUTATION = graphql(`
|
||||
mutation EnsureDeviceRunSessionStopped($deviceRunSessionId: ID!) {
|
||||
deviceRunSession {
|
||||
ensureDeviceRunSessionStopped(deviceRunSessionId: $deviceRunSessionId) {
|
||||
id
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const DEVICE_RUN_SESSION_STATUS_POLL_INTERVAL_MS = 5_000;
|
||||
|
||||
export function getDeviceRunSessionIdOrThrow(env: BuildStepEnv): string {
|
||||
@@ -118,59 +130,153 @@ export async function selectXcodeDeveloperDirectoryAsync({
|
||||
});
|
||||
}
|
||||
|
||||
export type DeviceRunSessionIdleTimeout = {
|
||||
/** Stop the session after this many minutes without observed activity. */
|
||||
maxIdleTimeMinutes: number;
|
||||
/**
|
||||
* Local arrival time of the most recent session event, or `undefined` when
|
||||
* no event has been observed yet. The idle clock starts when the wait
|
||||
* begins, so a session nobody ever connects to still times out.
|
||||
*/
|
||||
getLastEventObservedAt: () => Date | undefined;
|
||||
};
|
||||
|
||||
export async function waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
signal,
|
||||
maxDurationSeconds,
|
||||
signal: cancelSignal,
|
||||
idleTimeout,
|
||||
}: {
|
||||
ctx: CustomBuildContext;
|
||||
deviceRunSessionId: string;
|
||||
logger: bunyan;
|
||||
maxDurationSeconds?: number;
|
||||
signal?: AbortSignal;
|
||||
idleTimeout?: DeviceRunSessionIdleTimeout;
|
||||
}): Promise<void> {
|
||||
logger.info(
|
||||
`Remote session is live. Polling device run session ${deviceRunSessionId} until it is stopped.`
|
||||
);
|
||||
let pollErrorCount = 0;
|
||||
const durationAbortController = new AbortController();
|
||||
const signal = cancelSignal
|
||||
? AbortSignal.any([cancelSignal, durationAbortController.signal])
|
||||
: durationAbortController.signal;
|
||||
// Nothing to wait for if the step was already aborted before we started;
|
||||
// return before logging so we don't claim to be polling a session we never poll.
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
const durationTimeout =
|
||||
maxDurationSeconds === undefined
|
||||
? undefined
|
||||
: setTimeout(() => {
|
||||
logger.info(`Device run session ${deviceRunSessionId} reached its maximum duration.`);
|
||||
durationAbortController.abort();
|
||||
}, maxDurationSeconds * 1_000);
|
||||
|
||||
while (!signal?.aborted) {
|
||||
try {
|
||||
const result = await ctx.graphqlClient
|
||||
.query(DEVICE_RUN_SESSION_STATUS_QUERY, { deviceRunSessionId })
|
||||
.toPromise();
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
const status = result.data?.deviceRunSessions?.byId?.status;
|
||||
if (!status) {
|
||||
throw new Error(`Device run session ${deviceRunSessionId} status response was missing.`);
|
||||
}
|
||||
pollErrorCount = 0;
|
||||
if (status === 'STOPPED') {
|
||||
logger.info(`Device run session ${deviceRunSessionId} was stopped.`);
|
||||
return;
|
||||
}
|
||||
if (status === 'ERRORED') {
|
||||
throw new SystemError(`Device run session ${deviceRunSessionId} errored.`);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof SystemError) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
pollErrorCount += 1;
|
||||
if (pollErrorCount === 1 || pollErrorCount % 5 === 0) {
|
||||
Sentry.capture('Could not poll device run session status', error, { level: 'warning' });
|
||||
logger.warn(
|
||||
{ err: error, failedStatusPollCount: pollErrorCount },
|
||||
'Could not poll device run session status; will retry.'
|
||||
);
|
||||
}
|
||||
try {
|
||||
logger.info(
|
||||
`Remote session is live. Polling device run session ${deviceRunSessionId} until it is stopped.`
|
||||
);
|
||||
if (durationTimeout !== undefined) {
|
||||
logger.info(
|
||||
`The device run session will stop automatically after ${maxDurationSeconds} seconds.`
|
||||
);
|
||||
}
|
||||
await sleepUntilAbortedAsync(DEVICE_RUN_SESSION_STATUS_POLL_INTERVAL_MS, signal);
|
||||
if (idleTimeout) {
|
||||
logger.info(
|
||||
`The session stops automatically after ${idleTimeout.maxIdleTimeMinutes} minute(s) without activity.`
|
||||
);
|
||||
}
|
||||
let pollErrorCount = 0;
|
||||
let lastActivityAt = new Date();
|
||||
|
||||
while (!signal.aborted) {
|
||||
if (idleTimeout) {
|
||||
const lastEventObservedAt = idleTimeout.getLastEventObservedAt();
|
||||
if (lastEventObservedAt && lastEventObservedAt > lastActivityAt) {
|
||||
lastActivityAt = lastEventObservedAt;
|
||||
}
|
||||
if (Date.now() - lastActivityAt.getTime() >= idleTimeout.maxIdleTimeMinutes * 60_000) {
|
||||
logger.info(
|
||||
`Device run session ${deviceRunSessionId} had no activity for ` +
|
||||
`${idleTimeout.maxIdleTimeMinutes} minute(s) (max idle time). Stopping the session.`
|
||||
);
|
||||
await ensureDeviceRunSessionStoppedSafelyAsync({ ctx, deviceRunSessionId, logger });
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = await ctx.graphqlClient
|
||||
.query(DEVICE_RUN_SESSION_STATUS_QUERY, { deviceRunSessionId })
|
||||
.toPromise();
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
const status = result.data?.deviceRunSessions?.byId?.status;
|
||||
if (!status) {
|
||||
throw new Error(`Device run session ${deviceRunSessionId} status response was missing.`);
|
||||
}
|
||||
pollErrorCount = 0;
|
||||
if (status === 'STOPPED') {
|
||||
logger.info(`Device run session ${deviceRunSessionId} was stopped.`);
|
||||
return;
|
||||
}
|
||||
if (status === 'ERRORED') {
|
||||
throw new SystemError(`Device run session ${deviceRunSessionId} errored.`);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof SystemError) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
pollErrorCount += 1;
|
||||
if (pollErrorCount === 1 || pollErrorCount % 5 === 0) {
|
||||
Sentry.capture('Could not poll device run session status', error, { level: 'warning' });
|
||||
logger.warn(
|
||||
{ err: error, failedStatusPollCount: pollErrorCount },
|
||||
'Could not poll device run session status; will retry.'
|
||||
);
|
||||
}
|
||||
}
|
||||
await sleepUntilAbortedAsync(DEVICE_RUN_SESSION_STATUS_POLL_INTERVAL_MS, signal);
|
||||
}
|
||||
} finally {
|
||||
if (durationTimeout !== undefined) {
|
||||
clearTimeout(durationTimeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Best effort: when this fails, the caller still tears the session down and the
|
||||
// job run finishes, which clients also treat as the session ending.
|
||||
async function ensureDeviceRunSessionStoppedSafelyAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
}: {
|
||||
ctx: CustomBuildContext;
|
||||
deviceRunSessionId: string;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(ENSURE_DEVICE_RUN_SESSION_STOPPED_MUTATION, { deviceRunSessionId })
|
||||
.toPromise();
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not mark idle device run session as stopped', error, {
|
||||
level: 'warning',
|
||||
extras: { deviceRunSessionId },
|
||||
});
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
`Could not mark device run session ${deviceRunSessionId} as stopped. The session job ends anyway.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,17 +36,19 @@ export async function uploadServeSimMetricsFileAsync(
|
||||
logger.info(`serve-sim metrics file for ${udid} is empty; skipping upload.`);
|
||||
return;
|
||||
}
|
||||
const deviceName = typeof metadata?.deviceName === 'string' ? metadata.deviceName : undefined;
|
||||
try {
|
||||
logger.info(`Uploading serve-sim metrics for ${udid} (${formatBytes(size)}).`);
|
||||
logger.info(`Uploading serve-sim metrics for ${deviceName ?? udid} (${formatBytes(size)}).`);
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
artifactId: `metrics-${udid}`,
|
||||
name: `Performance metrics (${udid.slice(0, 8)})`,
|
||||
name: `Performance metrics (${deviceName ?? udid.slice(0, 8)})`,
|
||||
filename: METRICS_ARTIFACT_FILENAME,
|
||||
kind: 'performance-metrics',
|
||||
metadata: {
|
||||
__eas_type: 'performance-metrics',
|
||||
udid,
|
||||
...(deviceName && { deviceName }),
|
||||
...(metadata && {
|
||||
hostCores: metadata.hostCores,
|
||||
sampleIntervalMs: metadata.sampleIntervalMs,
|
||||
|
||||
@@ -20,7 +20,12 @@ android {
|
||||
versionName = versionNameVal
|
||||
}
|
||||
}
|
||||
applicationVariants.all { variant ->
|
||||
}
|
||||
|
||||
// AGP 9's new DSL removes the applicationVariants API, we set the version on each output through the androidComponents API instead.
|
||||
def easAndroidExtension = project.extensions.getByName('android')
|
||||
if (easAndroidExtension.hasProperty('applicationVariants')) {
|
||||
easAndroidExtension.applicationVariants.all { variant ->
|
||||
variant.outputs.each { output ->
|
||||
if (versionCodeVal) {
|
||||
output.versionCodeOverride = Integer.parseInt(versionCodeVal)
|
||||
@@ -30,5 +35,17 @@ android {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
def easAndroidComponents = project.extensions.getByName('androidComponents')
|
||||
easAndroidComponents.onVariants(easAndroidComponents.selector().all()) { variant ->
|
||||
variant.outputs.each { output ->
|
||||
if (versionCodeVal) {
|
||||
output.versionCode.set(Integer.parseInt(versionCodeVal))
|
||||
}
|
||||
if (versionNameVal) {
|
||||
output.versionName.set(versionNameVal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -1,14 +1,18 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
|
||||
import assert from 'assert';
|
||||
import FastGlob from 'fast-glob';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import { Socket } from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Sentry } from '../sentry';
|
||||
import { retryAsync } from './retry';
|
||||
|
||||
/** Android Virtual Device is the device we run. */
|
||||
@@ -294,10 +298,36 @@ export namespace AndroidEmulatorUtils {
|
||||
export async function startAsync({
|
||||
deviceName,
|
||||
env,
|
||||
logcatDirectory,
|
||||
}: {
|
||||
deviceName: AndroidVirtualDeviceName;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<{ emulatorPromise: SpawnPromise<SpawnResult>; serialId: AndroidDeviceSerialId }> {
|
||||
logcatDirectory: string;
|
||||
}): Promise<{
|
||||
emulatorPromise: SpawnPromise<SpawnResult>;
|
||||
serialId: AndroidDeviceSerialId;
|
||||
logcatOutputPath: string;
|
||||
emulatorOutputPath: string;
|
||||
}> {
|
||||
let logcatOutputPath: string;
|
||||
let emulatorOutputPath: string;
|
||||
try {
|
||||
await fs.promises.mkdir(logcatDirectory, { recursive: true });
|
||||
const safeDeviceName = deviceName.replace(/[^a-zA-Z0-9_.-]/g, '_');
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
.toString(16)
|
||||
.padStart(8, '0');
|
||||
const outputName = `${safeDeviceName}-${timestamp}-${randomBytes(2).toString('hex')}`;
|
||||
logcatOutputPath = path.join(logcatDirectory, `${outputName}-logcat.log`);
|
||||
emulatorOutputPath = path.join(logcatDirectory, `${outputName}-emulator.log`);
|
||||
await fs.promises.writeFile(logcatOutputPath, '');
|
||||
await fs.promises.writeFile(emulatorOutputPath, '');
|
||||
} catch (err) {
|
||||
throw new SystemError(`Failed to prepare Android emulator output for ${deviceName}.`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
|
||||
const emulatorPromise = spawn(
|
||||
`${process.env.ANDROID_HOME}/emulator/emulator`,
|
||||
[
|
||||
@@ -306,6 +336,10 @@ export namespace AndroidEmulatorUtils {
|
||||
'-writable-system',
|
||||
'-noaudio',
|
||||
'-no-snapshot-save',
|
||||
'-logcat',
|
||||
'*:v',
|
||||
'-logcat-output',
|
||||
logcatOutputPath,
|
||||
'-avd',
|
||||
deviceName,
|
||||
'-accel',
|
||||
@@ -316,7 +350,8 @@ export namespace AndroidEmulatorUtils {
|
||||
],
|
||||
{
|
||||
detached: true,
|
||||
stdio: 'inherit',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
ignoreStdio: true,
|
||||
env: {
|
||||
...env,
|
||||
// We don't need to wait for emulator to exit gracefully.
|
||||
@@ -324,6 +359,43 @@ export namespace AndroidEmulatorUtils {
|
||||
},
|
||||
}
|
||||
);
|
||||
const emulatorOutputStream = fs.createWriteStream(emulatorOutputPath, { flags: 'a' });
|
||||
let reportedEmulatorOutputError = false;
|
||||
emulatorOutputStream.on('error', err => {
|
||||
process.stderr.write(
|
||||
`Failed to write Android emulator output to ${emulatorOutputPath}: ${err}\n`
|
||||
);
|
||||
if (!reportedEmulatorOutputError) {
|
||||
reportedEmulatorOutputError = true;
|
||||
Sentry.capture('Failed to write Android emulator process output', err, {
|
||||
level: 'warning',
|
||||
tags: {
|
||||
errorCode: (err as NodeJS.ErrnoException).code ?? 'unknown',
|
||||
},
|
||||
extras: {
|
||||
deviceName,
|
||||
emulatorOutputPath,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
// Only into the log file -- this process' stdout/stderr is not watched or uploaded.
|
||||
emulatorPromise.child.stdout?.pipe(emulatorOutputStream, { end: false });
|
||||
emulatorPromise.child.stderr?.pipe(emulatorOutputStream, { end: false });
|
||||
emulatorPromise.child.once('close', () => {
|
||||
emulatorOutputStream.end();
|
||||
});
|
||||
// Piped stdio creates socket handles in this process which, unlike inherited
|
||||
// file descriptors, keep the event loop alive for as long as the emulator runs.
|
||||
// We never stop the emulator explicitly, so without unref-ing these the process
|
||||
// would never exit on its own -- defeating the `detached` + `unref()` below.
|
||||
// Output is still captured for as long as this process lives.
|
||||
if (emulatorPromise.child.stdout instanceof Socket) {
|
||||
emulatorPromise.child.stdout.unref();
|
||||
}
|
||||
if (emulatorPromise.child.stderr instanceof Socket) {
|
||||
emulatorPromise.child.stderr.unref();
|
||||
}
|
||||
// If emulator fails to start, throw its error.
|
||||
if (!emulatorPromise.child.pid) {
|
||||
await emulatorPromise;
|
||||
@@ -352,7 +424,7 @@ export namespace AndroidEmulatorUtils {
|
||||
|
||||
// We don't want to await the SpawnPromise here.
|
||||
// eslint-disable-next-line @typescript-eslint/return-await
|
||||
return { emulatorPromise, serialId };
|
||||
return { emulatorPromise, serialId, logcatOutputPath, emulatorOutputPath };
|
||||
}
|
||||
|
||||
export async function waitForReadyAsync({
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { asyncResult } from '@expo/results';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout } from 'timers/promises';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
@@ -16,6 +19,8 @@ jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
|
||||
describe('AndroidEmulatorUtils', () => {
|
||||
const logcatDirectory = path.join(os.tmpdir(), 'android-emulator-utils-integration-logs');
|
||||
|
||||
beforeEach(async () => {
|
||||
const devices = await AndroidEmulatorUtils.getAttachedDevicesAsync({ env: process.env });
|
||||
for (const { serialId } of devices) {
|
||||
@@ -42,6 +47,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
console.error('Failed to delete emulator', error);
|
||||
}
|
||||
}
|
||||
await fs.promises.rm(logcatDirectory, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
describe('getAvailableDevicesAsync', () => {
|
||||
@@ -71,6 +77,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
({ serialId } = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName,
|
||||
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
logcatDirectory,
|
||||
}));
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
serialId,
|
||||
@@ -94,6 +101,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName,
|
||||
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
logcatDirectory,
|
||||
});
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
serialId,
|
||||
@@ -123,6 +131,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
await AndroidEmulatorUtils.startAsync({
|
||||
deviceName: cloneDeviceName,
|
||||
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
logcatDirectory,
|
||||
});
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
serialId: serialIdClone,
|
||||
@@ -156,6 +165,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName,
|
||||
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
logcatDirectory,
|
||||
});
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
serialId,
|
||||
@@ -206,6 +216,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
const startResult = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName,
|
||||
env: envForAttempt,
|
||||
logcatDirectory,
|
||||
});
|
||||
serialId = startResult.serialId;
|
||||
emulatorPromise = asyncResult(startResult.emulatorPromise);
|
||||
@@ -262,6 +273,88 @@ describe('AndroidEmulatorUtils', () => {
|
||||
expect(attemptCounter).toBe(2);
|
||||
}, 360_000);
|
||||
|
||||
it('captures emulator and native logcat output across an ADB server restart', async () => {
|
||||
const testLogcatDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'android-emulator-logcat-e2e-')
|
||||
);
|
||||
const deviceName =
|
||||
`android-emulator-logcat-e2e-${randomUUID().slice(0, 8)}` as AndroidVirtualDeviceName;
|
||||
let emulatorPromise: Promise<unknown> | null = null;
|
||||
let serialId: AndroidDeviceSerialId | null = null;
|
||||
|
||||
try {
|
||||
const logger = createMockLogger({ logToConsole: true });
|
||||
const marker = `ENG-20762-${randomUUID()}`;
|
||||
|
||||
await AndroidEmulatorUtils.createAsync({
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
env: process.env,
|
||||
logger,
|
||||
});
|
||||
const startResult = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName,
|
||||
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
logcatDirectory: testLogcatDirectory,
|
||||
});
|
||||
serialId = startResult.serialId;
|
||||
emulatorPromise = asyncResult(startResult.emulatorPromise);
|
||||
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({ serialId, env: process.env });
|
||||
await retryAsync(
|
||||
async () => {
|
||||
const contents = await fs.promises.readFile(startResult.emulatorOutputPath, 'utf-8');
|
||||
if (!contents.includes('Android emulator version')) {
|
||||
throw new Error('Did not find the emulator version in emulator process output yet.');
|
||||
}
|
||||
},
|
||||
{
|
||||
logger,
|
||||
retryOptions: {
|
||||
retries: 10,
|
||||
retryIntervalMs: 1_000,
|
||||
},
|
||||
}
|
||||
);
|
||||
await spawn('adb', ['kill-server'], { env: process.env });
|
||||
await spawn('adb', ['start-server'], { env: process.env });
|
||||
await spawn('adb', ['-s', serialId, 'shell', 'log', '-t', 'EAS_CLI_TEST', marker], {
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
await retryAsync(
|
||||
async () => {
|
||||
const contents = await fs.promises.readFile(startResult.logcatOutputPath, 'utf-8');
|
||||
if (!contents.includes(marker)) {
|
||||
throw new Error(`Did not find marker ${marker} in logcat output yet.`);
|
||||
}
|
||||
},
|
||||
{
|
||||
logger,
|
||||
retryOptions: {
|
||||
retries: 10,
|
||||
retryIntervalMs: 1_000,
|
||||
},
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
...(serialId ? { serialId } : {}),
|
||||
deviceName,
|
||||
env: process.env,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Failed to clean up emulator during logcat integration test', error);
|
||||
}
|
||||
if (emulatorPromise) {
|
||||
await emulatorPromise;
|
||||
}
|
||||
await fs.promises.rm(testLogcatDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}, 180_000);
|
||||
|
||||
it('should work with screen recording', async () => {
|
||||
const deviceName = 'android-emulator-screen-recording-test' as AndroidVirtualDeviceName;
|
||||
await AndroidEmulatorUtils.createAsync({
|
||||
@@ -275,6 +368,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName,
|
||||
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
logcatDirectory,
|
||||
});
|
||||
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { EventEmitter, once } from 'node:events';
|
||||
import fs from 'node:fs';
|
||||
import { Socket } from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { AndroidEmulatorUtils } from '../AndroidEmulatorUtils';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { AndroidEmulatorUtils, AndroidVirtualDeviceName } from '../AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../retry';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
@@ -13,15 +20,154 @@ jest.mock('../retry', () => ({
|
||||
retryAsync: jest.fn(async fn => await fn(0)),
|
||||
}));
|
||||
|
||||
jest.mock('../../sentry', () => ({
|
||||
Sentry: {
|
||||
capture: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedRetryAsync = jest.mocked(retryAsync);
|
||||
|
||||
describe('AndroidEmulatorUtils', () => {
|
||||
let temporaryDirectories: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
temporaryDirectories = [];
|
||||
jest.mocked(Sentry.capture).mockReset();
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
mockedRetryAsync.mockImplementation(async fn => await fn(0));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
jest.restoreAllMocks();
|
||||
await Promise.all(
|
||||
temporaryDirectories.map(async temporaryDirectory => {
|
||||
await fs.promises.rm(temporaryDirectory, { force: true, recursive: true });
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
describe(AndroidEmulatorUtils.startAsync, () => {
|
||||
function mockSuccessfulStart(deviceName: AndroidVirtualDeviceName) {
|
||||
// Under `stdio: 'pipe'` these are net.Sockets, which is what startAsync
|
||||
// narrows on before unref-ing them.
|
||||
const stdout = new Socket();
|
||||
const stderr = new Socket();
|
||||
jest.spyOn(stdout, 'unref').mockReturnValue(stdout);
|
||||
jest.spyOn(stderr, 'unref').mockReturnValue(stderr);
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
pid: 1234,
|
||||
unref: jest.fn(),
|
||||
stdout,
|
||||
stderr,
|
||||
});
|
||||
const spawnPromise = Promise.resolve({ stdout: '', stderr: '' }) as any;
|
||||
spawnPromise.child = child;
|
||||
mockedSpawn.mockImplementation(((command: string, args: string[]) => {
|
||||
if (command.endsWith('/emulator/emulator')) {
|
||||
return spawnPromise;
|
||||
}
|
||||
if (command === 'adb' && args[0] === 'devices') {
|
||||
return Promise.resolve({ stdout: 'emulator-5554\tdevice\n', stderr: '' });
|
||||
}
|
||||
if (command === 'adb' && args[0] === '-s' && args[2] === 'emu') {
|
||||
return Promise.resolve({ stdout: `${deviceName}\nOK\n`, stderr: '' });
|
||||
}
|
||||
throw new Error(`Unexpected command: ${command} ${args.join(' ')}`);
|
||||
}) as any);
|
||||
return { child };
|
||||
}
|
||||
|
||||
it('captures logcat and emulator process output', async () => {
|
||||
const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'logcat-staging-'));
|
||||
temporaryDirectories.push(outputDir);
|
||||
const deviceName = 'eas-simulator' as AndroidVirtualDeviceName;
|
||||
const { child } = mockSuccessfulStart(deviceName);
|
||||
const createWriteStreamSpy = jest.spyOn(fs, 'createWriteStream');
|
||||
|
||||
const result = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName,
|
||||
env: process.env,
|
||||
logcatDirectory: outputDir,
|
||||
});
|
||||
|
||||
expect(result.logcatOutputPath).toMatch(
|
||||
new RegExp(`^${outputDir}/eas-simulator-[a-f0-9]{8}-[a-f0-9]{4}-logcat\\.log$`)
|
||||
);
|
||||
expect(result.emulatorOutputPath).toMatch(
|
||||
new RegExp(`^${outputDir}/eas-simulator-[a-f0-9]{8}-[a-f0-9]{4}-emulator\\.log$`)
|
||||
);
|
||||
await expect(fs.promises.access(result.logcatOutputPath)).resolves.toBeUndefined();
|
||||
await expect(fs.promises.access(result.emulatorOutputPath)).resolves.toBeUndefined();
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/emulator\/emulator$/),
|
||||
expect.arrayContaining(['-logcat', '*:v', '-logcat-output', result.logcatOutputPath]),
|
||||
expect.objectContaining({
|
||||
detached: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
ignoreStdio: true,
|
||||
})
|
||||
);
|
||||
expect(mockedSpawn).not.toHaveBeenCalledWith(
|
||||
'adb',
|
||||
expect.arrayContaining(['logcat']),
|
||||
expect.anything()
|
||||
);
|
||||
expect(child.unref).toHaveBeenCalled();
|
||||
// Piped stdio would otherwise keep this process' event loop alive
|
||||
// for as long as the emulator runs.
|
||||
expect(child.stdout.unref).toHaveBeenCalled();
|
||||
expect(child.stderr.unref).toHaveBeenCalled();
|
||||
const emulatorOutputStream = createWriteStreamSpy.mock.results[0].value;
|
||||
const stderrWriteSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
const writeError = Object.assign(new Error('disk full'), { code: 'ENOSPC' });
|
||||
emulatorOutputStream.emit('error', writeError);
|
||||
emulatorOutputStream.emit('error', writeError);
|
||||
expect(stderrWriteSpy).toHaveBeenCalledTimes(2);
|
||||
expect(Sentry.capture).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Failed to write Android emulator process output',
|
||||
writeError,
|
||||
{
|
||||
level: 'warning',
|
||||
tags: { errorCode: 'ENOSPC' },
|
||||
extras: {
|
||||
deviceName,
|
||||
emulatorOutputPath: result.emulatorOutputPath,
|
||||
},
|
||||
}
|
||||
);
|
||||
const emulatorOutputStreamClosed = once(emulatorOutputStream, 'close');
|
||||
child.emit('close');
|
||||
await emulatorOutputStreamClosed;
|
||||
});
|
||||
|
||||
it('throws a SystemError when the staging directory cannot be prepared', async () => {
|
||||
const outputDir = '/unwritable/logcat-staging';
|
||||
const deviceName = 'eas-simulator' as AndroidVirtualDeviceName;
|
||||
const mkdirError = new Error('mkdir failed');
|
||||
const mkdirSpy = jest.spyOn(fs.promises, 'mkdir').mockRejectedValueOnce(mkdirError);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
AndroidEmulatorUtils.startAsync({
|
||||
deviceName,
|
||||
env: process.env,
|
||||
logcatDirectory: outputDir,
|
||||
})
|
||||
).rejects.toEqual(
|
||||
new SystemError('Failed to prepare Android emulator output for eas-simulator.', {
|
||||
cause: mkdirError,
|
||||
})
|
||||
);
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
mkdirSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe(AndroidEmulatorUtils.waitForReadyAsync, () => {
|
||||
it('checks boot completion and verifies network with netcat to 1.1.1.1:443', async () => {
|
||||
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { EasCliNpmTags } from '@expo/eas-build-job';
|
||||
import {
|
||||
EasCliNpmTags,
|
||||
EasCliVersionsFetchTimeoutError,
|
||||
fetchEasCliVersionsAsync,
|
||||
} from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { resolveEasCommandPrefixAndEnvAsync, runEasCliCommand } from '../easCli';
|
||||
import {
|
||||
resetCachedEasCliVersionsForTest,
|
||||
resolveEasCommandPrefixAndEnvAsync,
|
||||
runEasCliCommand,
|
||||
} from '../easCli';
|
||||
import { isAtLeastNpm7Async } from '../packageManager';
|
||||
import { Sentry } from '../../sentry';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
@@ -12,12 +21,29 @@ jest.mock('../packageManager', () => ({
|
||||
...jest.requireActual('../packageManager'),
|
||||
isAtLeastNpm7Async: jest.fn(async () => true),
|
||||
}));
|
||||
jest.mock('@expo/eas-build-job', () => ({
|
||||
...jest.requireActual('@expo/eas-build-job'),
|
||||
fetchEasCliVersionsAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../sentry', () => ({
|
||||
Sentry: { capture: jest.fn() },
|
||||
}));
|
||||
|
||||
const STAGING_VERSION = 'staging-version';
|
||||
const PRODUCTION_VERSION = 'production-version';
|
||||
|
||||
describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
const originalEnvironment = process.env.ENVIRONMENT;
|
||||
|
||||
beforeEach(() => {
|
||||
resetCachedEasCliVersionsForTest();
|
||||
jest.mocked(spawn).mockReset();
|
||||
jest.mocked(Sentry.capture).mockReset();
|
||||
jest.mocked(fetchEasCliVersionsAsync).mockReset();
|
||||
jest.mocked(fetchEasCliVersionsAsync).mockResolvedValue({
|
||||
STAGING: STAGING_VERSION,
|
||||
PRODUCTION: PRODUCTION_VERSION,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -40,9 +66,10 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
['--help'],
|
||||
expect.objectContaining({ stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
);
|
||||
expect(fetchEasCliVersionsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves staging tag in development when easd --help fails', async () => {
|
||||
it('resolves the staging version in development when easd --help fails', async () => {
|
||||
process.env.ENVIRONMENT = 'development';
|
||||
jest.mocked(spawn).mockResolvedValueOnce({ status: 1 } as any);
|
||||
|
||||
@@ -50,12 +77,12 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${EasCliNpmTags.STAGING}`],
|
||||
args: ['-y', `eas-cli@${STAGING_VERSION}`],
|
||||
extraEnv: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves staging tag in development when easd probe throws', async () => {
|
||||
it('resolves the staging version in development when easd probe throws', async () => {
|
||||
process.env.ENVIRONMENT = 'development';
|
||||
jest.mocked(spawn).mockRejectedValueOnce(new Error('easd not installed') as any);
|
||||
|
||||
@@ -63,31 +90,80 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${EasCliNpmTags.STAGING}`],
|
||||
args: ['-y', `eas-cli@${STAGING_VERSION}`],
|
||||
extraEnv: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves staging tag and EXPO_STAGING env in staging environment', async () => {
|
||||
it('resolves the staging version and EXPO_STAGING env in staging environment', async () => {
|
||||
process.env.ENVIRONMENT = 'staging';
|
||||
const result = await resolveEasCommandPrefixAndEnvAsync();
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${EasCliNpmTags.STAGING}`],
|
||||
args: ['-y', `eas-cli@${STAGING_VERSION}`],
|
||||
extraEnv: { EXPO_STAGING: '1' },
|
||||
});
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves production tag by default', async () => {
|
||||
it('resolves the production version by default', async () => {
|
||||
process.env.ENVIRONMENT = 'production';
|
||||
const result = await resolveEasCommandPrefixAndEnvAsync();
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${EasCliNpmTags.PRODUCTION}`],
|
||||
args: ['-y', `eas-cli@${PRODUCTION_VERSION}`],
|
||||
extraEnv: {},
|
||||
});
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fetches cli-versions.json only once across multiple calls', async () => {
|
||||
process.env.ENVIRONMENT = 'production';
|
||||
await resolveEasCommandPrefixAndEnvAsync();
|
||||
await resolveEasCommandPrefixAndEnvAsync();
|
||||
expect(fetchEasCliVersionsAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('falls back to the npm dist-tags and reports to Sentry when fetching cli-versions.json fails', async () => {
|
||||
const fetchError = new Error('not found');
|
||||
jest.mocked(fetchEasCliVersionsAsync).mockRejectedValue(fetchError);
|
||||
process.env.ENVIRONMENT = 'staging';
|
||||
const stagingResult = await resolveEasCommandPrefixAndEnvAsync();
|
||||
expect(stagingResult).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${EasCliNpmTags.STAGING}`],
|
||||
extraEnv: { EXPO_STAGING: '1' },
|
||||
});
|
||||
|
||||
process.env.ENVIRONMENT = 'production';
|
||||
const productionResult = await resolveEasCommandPrefixAndEnvAsync();
|
||||
expect(productionResult).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${EasCliNpmTags.PRODUCTION}`],
|
||||
extraEnv: {},
|
||||
});
|
||||
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to fetch cli-versions.json'),
|
||||
fetchError,
|
||||
expect.objectContaining({ level: 'warning' })
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a timeout-specific Sentry error when the fetch times out', async () => {
|
||||
const timeoutError = new EasCliVersionsFetchTimeoutError('https://example.com', 10_000);
|
||||
jest.mocked(fetchEasCliVersionsAsync).mockRejectedValue(timeoutError);
|
||||
process.env.ENVIRONMENT = 'production';
|
||||
|
||||
const result = await resolveEasCommandPrefixAndEnvAsync();
|
||||
expect(result.args).toContain(`eas-cli@${EasCliNpmTags.PRODUCTION}`);
|
||||
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Timed out fetching cli-versions.json'),
|
||||
timeoutError,
|
||||
expect.objectContaining({ level: 'warning' })
|
||||
);
|
||||
});
|
||||
|
||||
it('omits -y when npm is older than v7', async () => {
|
||||
@@ -96,7 +172,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
const result = await resolveEasCommandPrefixAndEnvAsync();
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: [`eas-cli@${EasCliNpmTags.PRODUCTION}`],
|
||||
args: [`eas-cli@${PRODUCTION_VERSION}`],
|
||||
extraEnv: {},
|
||||
});
|
||||
});
|
||||
@@ -106,7 +182,13 @@ describe(runEasCliCommand, () => {
|
||||
const originalEnvironment = process.env.ENVIRONMENT;
|
||||
|
||||
beforeEach(() => {
|
||||
resetCachedEasCliVersionsForTest();
|
||||
jest.mocked(spawn).mockReset();
|
||||
jest.mocked(fetchEasCliVersionsAsync).mockReset();
|
||||
jest.mocked(fetchEasCliVersionsAsync).mockResolvedValue({
|
||||
STAGING: STAGING_VERSION,
|
||||
PRODUCTION: PRODUCTION_VERSION,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -126,7 +208,7 @@ describe(runEasCliCommand, () => {
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'npx',
|
||||
expect.arrayContaining([`eas-cli@${EasCliNpmTags.STAGING}`, 'deploy', '--json']),
|
||||
expect.arrayContaining([`eas-cli@${STAGING_VERSION}`, 'deploy', '--json']),
|
||||
expect.objectContaining({
|
||||
cwd: '/tmp/project',
|
||||
env: expect.objectContaining({
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { BuildJob, ManagedArtifactType } from '@expo/eas-build-job';
|
||||
import fs from 'fs-extra';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { createTestAndroidJob, createTestIosJob } from '../../__tests__/utils/job';
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import { maybeUploadSourceMapAsync, resolveIosSourceMapPathAsync } from '../sourceMaps';
|
||||
|
||||
function enableSourceMapUpload<TJob extends BuildJob>(job: TJob): TJob {
|
||||
return {
|
||||
...job,
|
||||
experimental: {
|
||||
...job.experimental,
|
||||
uploadSourceMaps: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createContext<TJob extends BuildJob>(
|
||||
job: TJob,
|
||||
env: Record<string, string> = {}
|
||||
): { ctx: BuildContext<TJob>; uploadArtifact: jest.Mock } {
|
||||
const uploadArtifact = jest.fn(async () => ({ filename: null }));
|
||||
const ctx = new BuildContext(job, {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
...env,
|
||||
},
|
||||
uploadArtifact,
|
||||
});
|
||||
return { ctx, uploadArtifact };
|
||||
}
|
||||
|
||||
describe(maybeUploadSourceMapAsync.name, () => {
|
||||
it('uploads the final Android source map without sourcesContent', async () => {
|
||||
const finalSourceMapPath =
|
||||
'/workingdir/build/android/app/build/generated/sourcemaps/react/release/index.android.bundle.map';
|
||||
vol.fromJSON({
|
||||
[finalSourceMapPath]: JSON.stringify({
|
||||
version: 3,
|
||||
sources: ['App.tsx'],
|
||||
sourcesContent: ['customer source'],
|
||||
names: [],
|
||||
mappings: '',
|
||||
sections: [
|
||||
{
|
||||
map: {
|
||||
version: 3,
|
||||
sources: ['nested.ts'],
|
||||
sourcesContent: ['nested customer source'],
|
||||
names: [],
|
||||
mappings: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
[`${finalSourceMapPath.slice(0, -4)}.packager.map`]: JSON.stringify({ version: 3 }),
|
||||
[`${finalSourceMapPath.slice(0, -4)}.compiler.map`]: JSON.stringify({ version: 3 }),
|
||||
});
|
||||
const { ctx, uploadArtifact } = createContext(enableSourceMapUpload(createTestAndroidJob()));
|
||||
|
||||
await maybeUploadSourceMapAsync(ctx);
|
||||
|
||||
expect(uploadArtifact).toHaveBeenCalledWith({
|
||||
artifact: {
|
||||
type: ManagedArtifactType.SOURCE_MAP,
|
||||
paths: ['/workingdir/observe-source-maps/android.map'],
|
||||
},
|
||||
logger: ctx.logger,
|
||||
});
|
||||
const uploadedSourceMap = await fs.readJson('/workingdir/observe-source-maps/android.map');
|
||||
expect(uploadedSourceMap).not.toHaveProperty('sourcesContent');
|
||||
expect(uploadedSourceMap.sections[0].map).not.toHaveProperty('sourcesContent');
|
||||
});
|
||||
|
||||
it('does not guess when an Android build produces multiple final source maps', async () => {
|
||||
vol.fromJSON({
|
||||
'/workingdir/build/android/app/build/generated/sourcemaps/react/release/one.map':
|
||||
JSON.stringify({ version: 3 }),
|
||||
'/workingdir/build/android/other/build/generated/sourcemaps/react/release/two.map':
|
||||
JSON.stringify({ version: 3 }),
|
||||
});
|
||||
const { ctx, uploadArtifact } = createContext(enableSourceMapUpload(createTestAndroidJob()));
|
||||
const markBuildPhaseHasWarnings = jest.spyOn(ctx, 'markBuildPhaseHasWarnings');
|
||||
|
||||
await expect(maybeUploadSourceMapAsync(ctx)).resolves.toBeUndefined();
|
||||
|
||||
expect(uploadArtifact).not.toHaveBeenCalled();
|
||||
expect(markBuildPhaseHasWarnings).toHaveBeenCalled();
|
||||
expect(ctx.logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Failed to upload source map.'
|
||||
);
|
||||
});
|
||||
|
||||
it('does nothing when source-map upload is disabled', async () => {
|
||||
const { ctx, uploadArtifact } = createContext(createTestAndroidJob());
|
||||
|
||||
await maybeUploadSourceMapAsync(ctx);
|
||||
|
||||
expect(uploadArtifact).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not upload source maps from local builds', async () => {
|
||||
const { ctx, uploadArtifact } = createContext(enableSourceMapUpload(createTestAndroidJob()), {
|
||||
EAS_BUILD_RUNNER: 'local-build-plugin',
|
||||
});
|
||||
|
||||
await maybeUploadSourceMapAsync(ctx);
|
||||
|
||||
expect(uploadArtifact).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveIosSourceMapPathAsync.name, () => {
|
||||
it('uses an absolute worker-owned path by default', async () => {
|
||||
const { ctx } = createContext(createTestIosJob());
|
||||
|
||||
await expect(resolveIosSourceMapPathAsync(ctx)).resolves.toBe(
|
||||
'/workingdir/observe-source-maps/main.jsbundle.map'
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves a configured path relative to the iOS project', async () => {
|
||||
const { ctx } = createContext(createTestIosJob(), {
|
||||
SOURCEMAP_FILE: 'build/custom/main.jsbundle.map',
|
||||
});
|
||||
|
||||
await expect(resolveIosSourceMapPathAsync(ctx)).resolves.toBe(
|
||||
'/workingdir/build/ios/build/custom/main.jsbundle.map'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,53 @@
|
||||
import spawn, { SpawnOptions, SpawnResult } from '@expo/turtle-spawn';
|
||||
import { EasCliNpmTags, Env } from '@expo/eas-build-job';
|
||||
import {
|
||||
EasCliNpmTags,
|
||||
EasCliVersions,
|
||||
EasCliVersionsFetchTimeoutError,
|
||||
Env,
|
||||
fetchEasCliVersionsAsync,
|
||||
} from '@expo/eas-build-job';
|
||||
|
||||
import { isAtLeastNpm7Async } from './packageManager';
|
||||
import { Sentry } from '../sentry';
|
||||
|
||||
let cachedEasCliVersionsPromise: Promise<EasCliVersions> | undefined;
|
||||
|
||||
/**
|
||||
* Resolves the `eas-cli` versions to install for staging/production. Prefers the
|
||||
* versions committed to `cli-versions.json` (fetched from expo/eas-cli); on any
|
||||
* failure (e.g. GitHub is unreachable or slow) reports to Sentry and falls back
|
||||
* to the `latest-eas-build*` npm dist-tags so the build can still proceed.
|
||||
*
|
||||
* Memoized: `resolveEasCommandPrefixAndEnvAsync` runs more than once per build,
|
||||
* so the (never-rejecting) result promise is cached to fetch GitHub at most once
|
||||
* per process.
|
||||
*/
|
||||
async function resolveEasCliVersionsAsync(): Promise<EasCliVersions> {
|
||||
if (!cachedEasCliVersionsPromise) {
|
||||
cachedEasCliVersionsPromise = fetchEasCliVersionsWithFallbackAsync();
|
||||
}
|
||||
return await cachedEasCliVersionsPromise;
|
||||
}
|
||||
|
||||
async function fetchEasCliVersionsWithFallbackAsync(): Promise<EasCliVersions> {
|
||||
try {
|
||||
return await fetchEasCliVersionsAsync();
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof EasCliVersionsFetchTimeoutError
|
||||
? 'Timed out fetching cli-versions.json; falling back to npm dist-tags'
|
||||
: 'Failed to fetch cli-versions.json; falling back to npm dist-tags';
|
||||
Sentry.capture(message, error instanceof Error ? error : new Error(String(error)), {
|
||||
level: 'warning',
|
||||
});
|
||||
return { STAGING: EasCliNpmTags.STAGING, PRODUCTION: EasCliNpmTags.PRODUCTION };
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: clears the memoized versions promise so each test fetches fresh. */
|
||||
export function resetCachedEasCliVersionsForTest(): void {
|
||||
cachedEasCliVersionsPromise = undefined;
|
||||
}
|
||||
|
||||
async function probeEasdAsync(): Promise<boolean> {
|
||||
try {
|
||||
@@ -24,21 +70,24 @@ export async function resolveEasCommandPrefixAndEnvAsync(): Promise<{
|
||||
if (await probeEasdAsync()) {
|
||||
return { cmd: 'easd', args: [], extraEnv: {} };
|
||||
}
|
||||
const versions = await resolveEasCliVersionsAsync();
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`],
|
||||
args: [...npxArgsPrefix, `eas-cli@${versions.STAGING}`],
|
||||
extraEnv: {},
|
||||
};
|
||||
} else if (process.env.ENVIRONMENT === 'staging') {
|
||||
const versions = await resolveEasCliVersionsAsync();
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`],
|
||||
args: [...npxArgsPrefix, `eas-cli@${versions.STAGING}`],
|
||||
extraEnv: { EXPO_STAGING: '1' },
|
||||
};
|
||||
} else {
|
||||
const versions = await resolveEasCliVersionsAsync();
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.PRODUCTION}`],
|
||||
args: [...npxArgsPrefix, `eas-cli@${versions.PRODUCTION}`],
|
||||
extraEnv: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { BuildJob, ManagedArtifactType, Platform, SystemError } from '@expo/eas-build-job';
|
||||
import fg from 'fast-glob';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { BuildContext } from '../context';
|
||||
|
||||
const ANDROID_SOURCE_MAP_PATTERN = 'android/**/build/generated/sourcemaps/react/**/*.map';
|
||||
const SOURCE_MAP_UPLOAD_DIRECTORY = 'observe-source-maps';
|
||||
|
||||
export function isSourceMapUploadEnabled(ctx: BuildContext<BuildJob>): boolean {
|
||||
return !ctx.isLocal && ctx.job.experimental?.uploadSourceMaps === true;
|
||||
}
|
||||
|
||||
export async function resolveIosSourceMapPathAsync(ctx: BuildContext<BuildJob>): Promise<string> {
|
||||
const configuredPath = ctx.env.SOURCEMAP_FILE;
|
||||
const sourceMapPath = configuredPath
|
||||
? path.resolve(ctx.getReactNativeProjectDirectory(), 'ios', configuredPath)
|
||||
: path.join(ctx.workingdir, SOURCE_MAP_UPLOAD_DIRECTORY, 'main.jsbundle.map');
|
||||
|
||||
await fs.ensureDir(path.dirname(sourceMapPath));
|
||||
return sourceMapPath;
|
||||
}
|
||||
|
||||
export async function maybeUploadSourceMapAsync(ctx: BuildContext<BuildJob>): Promise<void> {
|
||||
if (!isSourceMapUploadEnabled(ctx)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sourceMapPath = await resolveSourceMapPathAsync(ctx);
|
||||
const strippedSourceMapPath = await stripSourcesContentAsync(ctx, sourceMapPath);
|
||||
|
||||
ctx.logger.info(`Uploading source map: ${sourceMapPath}`);
|
||||
await ctx.uploadArtifact({
|
||||
artifact: {
|
||||
type: ManagedArtifactType.SOURCE_MAP,
|
||||
paths: [strippedSourceMapPath],
|
||||
},
|
||||
logger: ctx.logger,
|
||||
});
|
||||
} catch (err: any) {
|
||||
ctx.logger.warn({ err }, 'Failed to upload source map.');
|
||||
ctx.markBuildPhaseHasWarnings();
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveSourceMapPathAsync(ctx: BuildContext<BuildJob>): Promise<string> {
|
||||
if (ctx.job.platform === Platform.IOS) {
|
||||
const sourceMapPath = await resolveIosSourceMapPathAsync(ctx);
|
||||
if (!(await fs.pathExists(sourceMapPath))) {
|
||||
throw new SystemError(`The iOS source map was not generated at ${sourceMapPath}.`);
|
||||
}
|
||||
return sourceMapPath;
|
||||
}
|
||||
|
||||
if (ctx.job.platform === Platform.ANDROID) {
|
||||
const projectDir = ctx.getReactNativeProjectDirectory();
|
||||
const sourceMapPaths = (
|
||||
await fg(ANDROID_SOURCE_MAP_PATTERN, {
|
||||
absolute: true,
|
||||
cwd: projectDir,
|
||||
onlyFiles: true,
|
||||
})
|
||||
).filter(sourceMapPath => !isIntermediateAndroidSourceMap(sourceMapPath));
|
||||
|
||||
if (sourceMapPaths.length === 0) {
|
||||
throw new SystemError('The Android build did not generate a final composed source map.');
|
||||
}
|
||||
if (sourceMapPaths.length > 1) {
|
||||
throw new SystemError(
|
||||
`Found multiple final Android source maps: ${sourceMapPaths.join(', ')}. ` +
|
||||
'Refusing to upload a source map that may not match the application archive.'
|
||||
);
|
||||
}
|
||||
return sourceMapPaths[0];
|
||||
}
|
||||
|
||||
throw new SystemError('Source-map upload is not supported for this build platform.');
|
||||
}
|
||||
|
||||
function isIntermediateAndroidSourceMap(sourceMapPath: string): boolean {
|
||||
return sourceMapPath.endsWith('.packager.map') || sourceMapPath.endsWith('.compiler.map');
|
||||
}
|
||||
|
||||
async function stripSourcesContentAsync(
|
||||
ctx: BuildContext<BuildJob>,
|
||||
sourceMapPath: string
|
||||
): Promise<string> {
|
||||
const sourceMap = JSON.parse(await fs.readFile(sourceMapPath, 'utf8')) as unknown;
|
||||
if (!isRecord(sourceMap) || sourceMap.version !== 3) {
|
||||
throw new SystemError(`Invalid source map at ${sourceMapPath}.`);
|
||||
}
|
||||
|
||||
removeSourcesContent(sourceMap);
|
||||
|
||||
const uploadDirectory = path.join(ctx.workingdir, SOURCE_MAP_UPLOAD_DIRECTORY);
|
||||
const uploadPath = path.join(uploadDirectory, `${ctx.job.platform}.map`);
|
||||
await fs.ensureDir(uploadDirectory);
|
||||
await fs.writeFile(uploadPath, JSON.stringify(sourceMap), 'utf8');
|
||||
return uploadPath;
|
||||
}
|
||||
|
||||
function removeSourcesContent(value: unknown): void {
|
||||
if (Array.isArray(value)) {
|
||||
for (const child of value) {
|
||||
removeSourcesContent(child);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
delete value.sourcesContent;
|
||||
for (const child of Object.values(value)) {
|
||||
removeSourcesContent(child);
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "21.5.0",
|
||||
"version": "22.1.0",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "21.5.0"
|
||||
"@expo/steps": "22.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
@@ -44,6 +44,7 @@
|
||||
"@types/node": "20.14.2",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@vercel/ncc": "^0.38.1",
|
||||
"arg": "^5.0.2",
|
||||
"chalk": "4.1.2",
|
||||
"fs-extra": "^11.2.0",
|
||||
"ora": "^6.3.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "21.0.0",
|
||||
"version": "22.0.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"version": "21.0.0",
|
||||
"version": "22.0.0",
|
||||
"description": "A build cache provider plugin for the Expo CLI",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "21.5.0",
|
||||
"version": "22.0.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,9 +25,9 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "21.0.0",
|
||||
"@expo/logger": "22.0.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/turtle-spawn": "21.0.0",
|
||||
"@expo/turtle-spawn": "22.0.0",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
@@ -47,6 +47,9 @@ describe('Android.JobSchema', () => {
|
||||
},
|
||||
},
|
||||
expoBuildUrl: 'https://expo.dev/fake/build/url',
|
||||
experimental: {
|
||||
uploadSourceMaps: true,
|
||||
},
|
||||
initiatingUserId: randomUUID(),
|
||||
appId: randomUUID(),
|
||||
};
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { EnvSchema, SshSettingsZ, StaticWorkflowInterpolationContextZ } from '../common';
|
||||
import {
|
||||
EasCliVersionsFetchTimeoutError,
|
||||
EnvSchema,
|
||||
SshSettingsZ,
|
||||
StaticWorkflowInterpolationContextZ,
|
||||
fetchEasCliVersionsAsync,
|
||||
} from '../common';
|
||||
|
||||
describe('EnvSchema', () => {
|
||||
it('accepts explicit undefined values', () => {
|
||||
@@ -594,3 +600,78 @@ describe('GitHub context event payload passthrough', () => {
|
||||
expect(parsed.github?.event).toMatchObject(context.github.event);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchEasCliVersionsAsync', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('fetches and parses cli-versions.json', async () => {
|
||||
const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ STAGING: '21.5.1', PRODUCTION: '21.5.0' }),
|
||||
} as Response);
|
||||
|
||||
await expect(fetchEasCliVersionsAsync()).resolves.toEqual({
|
||||
STAGING: '21.5.1',
|
||||
PRODUCTION: '21.5.0',
|
||||
});
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://raw.githubusercontent.com/expo/eas-cli/main/cli-versions.json',
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) })
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on a non-OK response', async () => {
|
||||
jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({}),
|
||||
} as Response);
|
||||
|
||||
await expect(fetchEasCliVersionsAsync()).rejects.toThrow(/HTTP 404/);
|
||||
});
|
||||
|
||||
it('throws when the payload is missing a required property', async () => {
|
||||
jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ STAGING: '21.5.1' }),
|
||||
} as Response);
|
||||
|
||||
await expect(fetchEasCliVersionsAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws when a version is not valid semver', async () => {
|
||||
jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ STAGING: 'latest-eas-build-staging', PRODUCTION: '21.5.0' }),
|
||||
} as Response);
|
||||
|
||||
await expect(fetchEasCliVersionsAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws a timeout error when the request exceeds the timeout', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
jest.spyOn(global, 'fetch').mockImplementation(
|
||||
(_url, init) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
(init?.signal as AbortSignal | undefined)?.addEventListener('abort', () => {
|
||||
reject(new Error('The operation was aborted.'));
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const assertion = expect(fetchEasCliVersionsAsync()).rejects.toBeInstanceOf(
|
||||
EasCliVersionsFetchTimeoutError
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(30_000);
|
||||
await assertion;
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -49,6 +49,9 @@ describe('Ios.JobSchema', () => {
|
||||
},
|
||||
},
|
||||
expoBuildUrl: 'https://expo.dev/fake/build/url',
|
||||
experimental: {
|
||||
uploadSourceMaps: true,
|
||||
},
|
||||
initiatingUserId: randomUUID(),
|
||||
appId: randomUUID(),
|
||||
};
|
||||
|
||||
@@ -119,6 +119,7 @@ export interface Job {
|
||||
|
||||
experimental?: {
|
||||
prebuildCommand?: string;
|
||||
uploadSourceMaps?: boolean;
|
||||
};
|
||||
expoBuildUrl?: string;
|
||||
githubTriggerOptions?: {
|
||||
@@ -186,6 +187,7 @@ export const JobSchema = Joi.object({
|
||||
|
||||
experimental: Joi.object({
|
||||
prebuildCommand: Joi.string(),
|
||||
uploadSourceMaps: Joi.boolean(),
|
||||
}),
|
||||
expoBuildUrl: Joi.string().uri().optional(),
|
||||
githubTriggerOptions: Joi.object({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export enum ManagedArtifactType {
|
||||
APPLICATION_ARCHIVE = 'APPLICATION_ARCHIVE',
|
||||
BUILD_ARTIFACTS = 'BUILD_ARTIFACTS',
|
||||
SOURCE_MAP = 'SOURCE_MAP',
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Joi from 'joi';
|
||||
import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { BuildPhase, BuildPhaseResult } from './logs';
|
||||
@@ -399,3 +400,64 @@ export enum EasCliNpmTags {
|
||||
STAGING = 'latest-eas-build-staging',
|
||||
PRODUCTION = 'latest-eas-build',
|
||||
}
|
||||
|
||||
/**
|
||||
* The `eas-cli` versions used by EAS Build for the staging and production
|
||||
* environments. Replaces the `latest-eas-build*` npm dist-tags: instead of
|
||||
* moving dist-tags, the versions are committed to `cli-versions.json` at the
|
||||
* root of the expo/eas-cli repository.
|
||||
*/
|
||||
export interface EasCliVersions {
|
||||
STAGING: string;
|
||||
PRODUCTION: string;
|
||||
}
|
||||
|
||||
const SemverStringZ = z
|
||||
.string()
|
||||
.refine(value => semver.valid(value) !== null, { message: 'Expected a valid semver version.' });
|
||||
|
||||
const EasCliVersionsZ = z.object({
|
||||
STAGING: SemverStringZ,
|
||||
PRODUCTION: SemverStringZ,
|
||||
});
|
||||
|
||||
const CLI_VERSIONS_URL = 'https://raw.githubusercontent.com/expo/eas-cli/main/cli-versions.json';
|
||||
const CLI_VERSIONS_FETCH_TIMEOUT_MS = 10_000;
|
||||
|
||||
/**
|
||||
* Thrown by {@link fetchEasCliVersionsAsync} when the request does not complete
|
||||
* within {@link CLI_VERSIONS_FETCH_TIMEOUT_MS}. Callers can distinguish this
|
||||
* from other fetch failures (e.g. to report it separately).
|
||||
*/
|
||||
export class EasCliVersionsFetchTimeoutError extends Error {
|
||||
constructor(url: string, timeoutMs: number) {
|
||||
super(`Timed out after ${timeoutMs}ms fetching ${url}.`);
|
||||
this.name = 'EasCliVersionsFetchTimeoutError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches and parses `cli-versions.json` from the `main` branch of
|
||||
* expo/eas-cli. Throws {@link EasCliVersionsFetchTimeoutError} if the request
|
||||
* exceeds {@link CLI_VERSIONS_FETCH_TIMEOUT_MS}, or a generic error if the file
|
||||
* cannot be fetched or does not match the expected shape; callers should fall
|
||||
* back to {@link EasCliNpmTags}.
|
||||
*/
|
||||
export async function fetchEasCliVersionsAsync(): Promise<EasCliVersions> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), CLI_VERSIONS_FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(CLI_VERSIONS_URL, { signal: controller.signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${CLI_VERSIONS_URL} (HTTP ${response.status}).`);
|
||||
}
|
||||
return EasCliVersionsZ.parse(await response.json());
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
throw new EasCliVersionsFetchTimeoutError(CLI_VERSIONS_URL, CLI_VERSIONS_FETCH_TIMEOUT_MS);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,9 @@ export {
|
||||
BuildMode,
|
||||
BuildTrigger,
|
||||
EasCliNpmTags,
|
||||
EasCliVersions,
|
||||
EasCliVersionsFetchTimeoutError,
|
||||
fetchEasCliVersionsAsync,
|
||||
Env,
|
||||
EnvironmentSecret,
|
||||
EnvironmentSecretType,
|
||||
|
||||
@@ -133,6 +133,7 @@ export interface Job {
|
||||
|
||||
experimental?: {
|
||||
prebuildCommand?: string;
|
||||
uploadSourceMaps?: boolean;
|
||||
};
|
||||
expoBuildUrl?: string;
|
||||
githubTriggerOptions?: {
|
||||
@@ -222,6 +223,7 @@ export const JobSchema = Joi.object({
|
||||
|
||||
experimental: Joi.object({
|
||||
prebuildCommand: Joi.string(),
|
||||
uploadSourceMaps: Joi.boolean(),
|
||||
}),
|
||||
expoBuildUrl: Joi.string().uri().optional(),
|
||||
githubTriggerOptions: Joi.object({
|
||||
|
||||
+503
-173
File diff suppressed because it is too large.
Load diff
Generated
+1848
-61
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "21.5.0",
|
||||
"version": "22.1.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -48,11 +48,11 @@
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "21.5.0",
|
||||
"@expo/eas-json": "21.5.0",
|
||||
"@expo/eas-build-job": "22.0.0",
|
||||
"@expo/eas-json": "22.0.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "21.0.0",
|
||||
"@expo/logger": "22.0.0",
|
||||
"@expo/multipart-body-parser": "2.0.0",
|
||||
"@expo/osascript": "2.1.4",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
@@ -64,7 +64,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "21.5.0",
|
||||
"@expo/steps": "22.1.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
@@ -72,7 +72,7 @@
|
||||
"@sentry/node": "7.77.0",
|
||||
"@urql/core": "4.0.11",
|
||||
"@urql/exchange-retry": "1.2.0",
|
||||
"agent-cli-detector": "0.1.2",
|
||||
"agent-cli-detector": "0.1.6",
|
||||
"ajv": "8.11.0",
|
||||
"ajv-formats": "2.1.1",
|
||||
"better-opn": "3.0.2",
|
||||
@@ -118,6 +118,7 @@
|
||||
"prompts": "2.4.2",
|
||||
"qrcode-terminal": "0.12.0",
|
||||
"resolve-from": "5.0.0",
|
||||
"sandbox-cli-detector": "0.2.0",
|
||||
"semver": "7.5.4",
|
||||
"set-interval-async": "3.0.3",
|
||||
"slash": "3.0.0",
|
||||
@@ -221,6 +222,9 @@
|
||||
"secret": {
|
||||
"description": "manage project and account secrets"
|
||||
},
|
||||
"simulator": {
|
||||
"description": "manage remote simulator sessions"
|
||||
},
|
||||
"testflight": {
|
||||
"description": "read TestFlight feedback and crashes"
|
||||
},
|
||||
|
||||
@@ -11,7 +11,7 @@ import { getPrivateExpoConfigAsync } from '../../project/expoConfig';
|
||||
import { uploadAccountScopedFileAsync } from '../../project/uploadAccountScopedFileAsync';
|
||||
import { uploadAccountScopedProjectSourceAsync } from '../../project/uploadAccountScopedProjectSourceAsync';
|
||||
import { ensureActorHasPrimaryAccount } from '../../user/actions';
|
||||
import { detectProjectSdkVersionAsync } from '../../commands/go';
|
||||
import { detectProjectSdkVersionAsync, toRepackTargetSdkVersion } from '../../commands/go';
|
||||
import { mockTestCommand } from './utils';
|
||||
|
||||
jest.mock('@expo/config', () => ({
|
||||
@@ -81,6 +81,25 @@ describe('detectProjectSdkVersionAsync', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('toRepackTargetSdkVersion', () => {
|
||||
it.each([
|
||||
['57', '57.0.0'],
|
||||
['57.0.0', '57.0.0'],
|
||||
['57.0.9', '57.0.0'],
|
||||
['57.0.0-canary-20260101', '57.0.0'],
|
||||
])('resolves %s to the %s repack target', (sdkVersion, expected) => {
|
||||
expect(toRepackTargetSdkVersion(sdkVersion)).toBe(expected);
|
||||
});
|
||||
|
||||
it('returns undefined when no version is requested', () => {
|
||||
expect(toRepackTargetSdkVersion(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the requested version unchanged when it holds no version number', () => {
|
||||
expect(toRepackTargetSdkVersion('latest')).toBe('latest');
|
||||
});
|
||||
});
|
||||
|
||||
const mockAccount = { id: 'account-id', name: 'testuser' };
|
||||
const mockActor = {
|
||||
__typename: 'User' as const,
|
||||
@@ -149,6 +168,17 @@ describe('Go command', () => {
|
||||
expect(Log.log).not.toHaveBeenCalledWith(expect.stringContaining('Auto-selected'));
|
||||
});
|
||||
|
||||
it('requests the repack configuration with the repack target SDK version', async () => {
|
||||
mockGetConfigFilePaths.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
|
||||
|
||||
await makeCmd(['--sdk-version', '57']).run();
|
||||
|
||||
expect(WorkflowRunQuery.expoGoRepackConfigurationAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ sdkVersion: '57.0.0' })
|
||||
);
|
||||
});
|
||||
|
||||
it('prompts for SDK version when no project config is found', async () => {
|
||||
mockGetConfigFilePaths.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
|
||||
jest.mocked(WorkflowRunQuery.expoGoSupportedSdkVersionsAsync).mockResolvedValue([
|
||||
|
||||
@@ -38,14 +38,9 @@ export function getMockAppFragment(): AppFragment {
|
||||
ownerAccount: {
|
||||
id: 'test-account-id',
|
||||
name: 'testuser',
|
||||
users: [
|
||||
{
|
||||
role: Role.Owner,
|
||||
actor: {
|
||||
id: 'test-user-id',
|
||||
},
|
||||
},
|
||||
],
|
||||
viewerUserPermission: {
|
||||
role: Role.Owner,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -180,7 +180,7 @@ describe(WorkflowLogView, () => {
|
||||
createdAt: '2022-01-01T00:00:00.000Z',
|
||||
updatedAt: '2022-01-01T00:00:00.000Z',
|
||||
isForIosSimulator: false,
|
||||
project: {
|
||||
app: {
|
||||
id: mockProjectId,
|
||||
__typename: 'App',
|
||||
name: 'App',
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import crypto from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import { computeFileMd5Base64Async } from '../uploads';
|
||||
|
||||
describe(computeFileMd5Base64Async, () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-cli-uploads-test-'));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.remove(tmpDir);
|
||||
});
|
||||
|
||||
it('computes the base64 MD5 checksum of a small file', async () => {
|
||||
const file = path.join(tmpDir, 'small.txt');
|
||||
await fs.writeFile(file, 'abc');
|
||||
// md5("abc") = 900150983cd24fb0d6963f7d28e17f72
|
||||
expect(await computeFileMd5Base64Async(file)).toBe('kAFQmDzST7DWlj99KOF/cg==');
|
||||
});
|
||||
|
||||
it('computes the same checksum as a one-shot hash for a file larger than one stream chunk', async () => {
|
||||
const file = path.join(tmpDir, 'large.bin');
|
||||
const data = Buffer.alloc(256 * 1024);
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
data[i] = i % 251;
|
||||
}
|
||||
await fs.writeFile(file, data);
|
||||
const expected = crypto.createHash('md5').update(data).digest('base64');
|
||||
expect(await computeFileMd5Base64Async(file)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { Actor, getActorDisplayName } from '../user/User';
|
||||
import UserSettings from '../user/UserSettings';
|
||||
import { easCliVersion } from '../utils/easCli';
|
||||
import { getAgentTelemetryContext } from './agent';
|
||||
import { getSandboxTelemetryContext } from './sandbox';
|
||||
|
||||
const PLATFORM_TO_ANALYTICS_PLATFORM: Partial<Record<NodeJS.Platform, string>> = {
|
||||
darwin: 'Mac',
|
||||
@@ -227,10 +228,12 @@ class RudderstackAnalytics implements AnalyticsWithOrchestration {
|
||||
private getRudderStackContext(): Record<string, any> {
|
||||
const platform = PLATFORM_TO_ANALYTICS_PLATFORM[os.platform()] || os.platform();
|
||||
const agent = getAgentTelemetryContext();
|
||||
const sandboxProvider = getSandboxTelemetryContext();
|
||||
return {
|
||||
os: { name: platform, version: os.release() },
|
||||
device: { type: platform, model: platform },
|
||||
app: { name: 'eas cli', version: easCliVersion ?? undefined },
|
||||
...(sandboxProvider ? { sandbox_provider: sandboxProvider } : {}),
|
||||
...(agent ? { agent } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import RudderAnalytics from '@expo/rudder-sdk-node';
|
||||
import UserSettings from '../../user/UserSettings';
|
||||
import { CommandEvent, createAnalyticsAsync } from '../AnalyticsManager';
|
||||
import { getAgentTelemetryContext } from '../agent';
|
||||
import { getSandboxTelemetryContext } from '../sandbox';
|
||||
|
||||
const mockIdentify = jest.fn();
|
||||
const mockTrack = jest.fn();
|
||||
@@ -28,8 +29,12 @@ jest.mock('../../user/UserSettings', () => ({
|
||||
jest.mock('../agent', () => ({
|
||||
getAgentTelemetryContext: jest.fn(),
|
||||
}));
|
||||
jest.mock('../sandbox', () => ({
|
||||
getSandboxTelemetryContext: jest.fn(),
|
||||
}));
|
||||
|
||||
const getAgentTelemetryContextMock = jest.mocked(getAgentTelemetryContext);
|
||||
const getSandboxTelemetryContextMock = jest.mocked(getSandboxTelemetryContext);
|
||||
const userSettingsMock = jest.mocked(UserSettings);
|
||||
|
||||
const originalHttpsProxy = process.env.https_proxy;
|
||||
@@ -42,6 +47,8 @@ beforeEach(() => {
|
||||
jest.mocked(RudderAnalytics).mockClear();
|
||||
getAgentTelemetryContextMock.mockReset();
|
||||
getAgentTelemetryContextMock.mockReturnValue(null);
|
||||
getSandboxTelemetryContextMock.mockReset();
|
||||
getSandboxTelemetryContextMock.mockReturnValue(null);
|
||||
userSettingsMock.getAsync.mockImplementation(async (key, defaultValue) => {
|
||||
if (key === 'analyticsDeviceId') {
|
||||
return 'persistent-device-id';
|
||||
@@ -68,14 +75,17 @@ afterAll(() => {
|
||||
}
|
||||
});
|
||||
|
||||
it('omits agent context when no agent is detected', async () => {
|
||||
it('omits agent and sandbox context when neither is detected', async () => {
|
||||
const analytics = await createAnalyticsAsync();
|
||||
|
||||
analytics.logEvent(CommandEvent.ACTION, { action: 'eas build' });
|
||||
|
||||
expect(mockTrack).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
context: expect.not.objectContaining({ agent: expect.anything() }),
|
||||
context: expect.not.objectContaining({
|
||||
agent: expect.anything(),
|
||||
sandbox_provider: expect.anything(),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -97,3 +107,18 @@ it('adds detected agent context to analytics events', async () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('adds detected sandbox context to analytics events', async () => {
|
||||
getSandboxTelemetryContextMock.mockReturnValue('e2b');
|
||||
const analytics = await createAnalyticsAsync();
|
||||
|
||||
analytics.logEvent(CommandEvent.ACTION, { action: 'eas build' });
|
||||
|
||||
expect(mockTrack).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
context: expect.objectContaining({
|
||||
sandbox_provider: 'e2b',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
export {};
|
||||
|
||||
const detectSandboxMock = jest.fn();
|
||||
const logDebugMock = jest.fn();
|
||||
|
||||
jest.mock('sandbox-cli-detector', () => ({
|
||||
detectSandbox: detectSandboxMock,
|
||||
}));
|
||||
|
||||
jest.mock('../../log', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
debug: logDebugMock,
|
||||
},
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
detectSandboxMock.mockReset();
|
||||
logDebugMock.mockReset();
|
||||
});
|
||||
|
||||
it('returns detected sandbox telemetry context', async () => {
|
||||
detectSandboxMock.mockReturnValue({
|
||||
detected: true,
|
||||
sandbox: { id: 'e2b', name: 'E2B' },
|
||||
});
|
||||
const { getSandboxTelemetryContext } = await import('../sandbox');
|
||||
|
||||
expect(getSandboxTelemetryContext()).toBe('e2b');
|
||||
});
|
||||
|
||||
it('returns null when no known sandbox is detected', async () => {
|
||||
detectSandboxMock.mockReturnValue({ detected: false, sandbox: null });
|
||||
const { getSandboxTelemetryContext } = await import('../sandbox');
|
||||
|
||||
expect(getSandboxTelemetryContext()).toBeNull();
|
||||
});
|
||||
|
||||
it('caches the detected sandbox telemetry context', async () => {
|
||||
detectSandboxMock.mockReturnValue({
|
||||
detected: true,
|
||||
sandbox: { id: 'e2b', name: 'E2B' },
|
||||
});
|
||||
const { getSandboxTelemetryContext } = await import('../sandbox');
|
||||
|
||||
expect(getSandboxTelemetryContext()).toBe('e2b');
|
||||
expect(getSandboxTelemetryContext()).toBe('e2b');
|
||||
expect(detectSandboxMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns null and logs debug details when detection throws', async () => {
|
||||
detectSandboxMock.mockImplementation(() => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
const { getSandboxTelemetryContext } = await import('../sandbox');
|
||||
|
||||
expect(getSandboxTelemetryContext()).toBeNull();
|
||||
expect(logDebugMock).toHaveBeenCalledWith('Failed to detect sandbox:', 'boom');
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { detectSandbox } from 'sandbox-cli-detector';
|
||||
|
||||
import Log from '../log';
|
||||
|
||||
type SandboxTelemetryContext = string | null;
|
||||
|
||||
let sandboxTelemetryContext: SandboxTelemetryContext | undefined;
|
||||
|
||||
export function getSandboxTelemetryContext(): SandboxTelemetryContext {
|
||||
if (sandboxTelemetryContext === undefined) {
|
||||
sandboxTelemetryContext = resolveSandboxTelemetryContext();
|
||||
}
|
||||
|
||||
return sandboxTelemetryContext;
|
||||
}
|
||||
|
||||
function resolveSandboxTelemetryContext(): SandboxTelemetryContext {
|
||||
try {
|
||||
const { detected, sandbox } = detectSandbox();
|
||||
if (!detected || sandbox == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sandbox.id;
|
||||
} catch (error: any) {
|
||||
Log.debug('Failed to detect sandbox:', error?.message ?? error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,7 @@ function createMockBuildFragment({
|
||||
id: buildId ?? uuid(),
|
||||
logFiles: [],
|
||||
priority: BuildPriority.Normal,
|
||||
project: {
|
||||
app: {
|
||||
__typename: 'App',
|
||||
slug: 'test-project',
|
||||
id: projectId,
|
||||
|
||||
Loaded 100 of 256 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user