Compare commits

..
668 changed files with 15752 additions and 68288 deletions

No files matched your search

-39
View File
@@ -1,39 +0,0 @@
# Changesets
Every PR that changes a package adds a changeset: a Markdown file in this directory that names the changed packages, the bump type, and a one-line summary for the changelog. Do not edit `CHANGELOG.md` files. The release generates them from the changesets.
## Add a changeset
```sh
yarn changeset
```
Select the changed packages and the bump type, then write the summary. Commit the new file with your PR. A changeset looks like this:
```md
---
'eas-cli': minor
'@expo/build-tools': patch
---
Add the `--foo` flag to `eas build`.
```
Write the summary as one sentence in the style of the existing `CHANGELOG.md` entries. The release adds the PR link and the author, and prefixes the entry with the package directories, for example `[eas-cli][build-tools]`.
## Bump types and changelog sections
All packages share one version. The highest bump in all changesets sets the next version. Each entry goes into the root `CHANGELOG.md` section for its highest bump:
| Bump | Root `CHANGELOG.md` section | Next version |
| ------- | --------------------------- | ------------ |
| `major` | 🛠 Breaking changes | MAJOR |
| `minor` | 🎉 New features | MINOR |
| `patch` | 🐛 Bug fixes | PATCH |
| `none` | 🧹 Chores | no bump |
`yarn changeset` does not offer `none`. For changes that should not appear in the changelog, add the "no changelog" label to the PR in GitHub.
The "no changelog" label skips the changeset check, but not the `CHANGELOG.md` check. A PR that edits a `CHANGELOG.md` file always fails. Dependabot PRs skip the changeset check.
See [RELEASING.md](../RELEASING.md) for how a release uses the changesets.
@@ -1,5 +0,0 @@
---
'@expo/build-tools': patch
---
Stop an agent-device session that is still starting up when it is stopped.
-6
View File
@@ -1,6 +0,0 @@
---
'@expo/build-tools': patch
---
Stop an Appium session that is still starting up when it is stopped.
Report temporary-directory cleanup failures when Stop interrupts Appium installation.
-5
View File
@@ -1,5 +0,0 @@
---
'@expo/build-tools': patch
---
Stop an Argent session that is still starting up when it is stopped.
-11
View File
@@ -1,11 +0,0 @@
export declare function getAttributionAsync(changeset: {
id: string;
commit?: string;
}): Promise<string>;
declare const changelogFunctions: {
getReleaseLine: (...args: never[]) => Promise<string>;
getDependencyReleaseLine: (...args: never[]) => Promise<string>;
};
export default changelogFunctions;
-50
View File
@@ -1,50 +0,0 @@
import { getCommitInfo } from '@changesets/get-github-info';
import { formatEntry } from './formatEntry.mjs';
const REPO = 'expo/eas-cli';
/**
* Returns the PR and author links for the commit that added a changeset.
*
* @param {{ id: string, commit?: string }} changeset
* @returns {Promise<string>} For example `([#123](https://...) by [@user](https://...))`.
*/
export async function getAttributionAsync(changeset) {
if (!changeset.commit) {
throw new Error(
`Changeset .changeset/${changeset.id}.md is not committed. Commit it, then run the release again.`
);
}
const info = await getCommitInfo({ repo: REPO, commit: changeset.commit });
if (info?.pull && info.author) {
return `(${info.pull.markdownLink} by ${info.author.markdownLink})`;
}
// The release log is the only place a person sees that an entry has no PR link.
// eslint-disable-next-line no-console
console.warn(
`No pull request found on GitHub for commit ${changeset.commit} (changeset ${changeset.id}). The entry links the commit instead.`
);
const commitLink =
info?.commit.markdownLink ??
`[\`${changeset.commit.slice(0, 7)}\`](https://github.com/${REPO}/commit/${changeset.commit})`;
return info?.author ? `(${commitLink} by ${info.author.markdownLink})` : `(${commitLink})`;
}
/**
* @param {{ id: string, summary: string, commit?: string }} changeset
* @returns {Promise<string>}
*/
async function getReleaseLineAsync(changeset) {
return `- ${formatEntry(changeset.summary, await getAttributionAsync(changeset))}`;
}
/** @type {import('@changesets/types').ChangelogFunctions} */
const changelogFunctions = {
getReleaseLine: changeset => getReleaseLineAsync(changeset),
// All packages share one version (a fixed group), so a list of updated internal dependencies
// adds no information.
getDependencyReleaseLine: () => Promise.resolve(''),
};
export default changelogFunctions;
-34
View File
@@ -1,34 +0,0 @@
{
"$schema": "https://unpkg.com/@changesets/[email protected]/schema.json",
"changelog": "./changelog.mjs",
"commit": false,
"access": "public",
"baseBranch": "main",
"format": "oxfmt",
"fixed": [
[
"eas-cli",
"@expo/eas-json",
"eas-build-cache-provider",
"@expo/eas-build-job",
"@expo/build-tools",
"@expo/steps",
"eas-cli-local-build-plugin",
"@expo/worker",
"@expo/logger",
"@expo/downloader",
"@expo/turtle-spawn",
"@expo/template-file",
"create-eas-build-function",
"@expo/vm-tools",
"@expo/simulator-launcher"
]
],
"linked": [],
"ignore": [],
"privatePackages": {
"version": true,
"tag": false
},
"updateInternalDependencies": "patch"
}
-1
View File
@@ -1 +0,0 @@
export declare function formatEntry(summary: string, attribution: string): string;
-23
View File
@@ -1,23 +0,0 @@
/**
* Formats a changeset summary as the text of a CHANGELOG list item.
*
* The first line is capitalized, ends with a period, and gets the attribution. Later lines are
* indented by two spaces, so they stay inside the list item.
*
* @param {string} summary The changeset summary.
* @param {string} attribution For example `([#123](https://...) by [@user](https://...))`.
* @returns {string} The entry text, without the leading `- `.
*/
export function formatEntry(summary, attribution) {
const [firstLine, ...otherLines] = summary
.trim()
.split('\n')
.map(line => line.trimEnd());
if (!firstLine) {
throw new Error('The changeset summary is empty. Describe the change below the front matter.');
}
const capitalizedLine = `${firstLine[0].toUpperCase()}${firstLine.slice(1)}`;
const sentence = capitalizedLine.endsWith('.') ? capitalizedLine : `${capitalizedLine}.`;
const indentedLines = otherLines.map(line => (line ? ` ${line}` : ''));
return [`${sentence} ${attribution}`, ...indentedLines].join('\n');
}
-8
View File
@@ -1,8 +0,0 @@
---
'@expo/build-tools': patch
'eas-cli': patch
---
Record and upload Android emulator process output as a simulator session artifact by default. Add explicit source selection and a simulator CLI flag to opt in to recording and uploading device-wide logcat. Unselected sources are not recorded by simulator sessions.
Simulator sessions retain the last 20 MiB of each selected Android log source across launches and retries. Each log file is a fixed-size ring on disk, so the end of the final attempt is always kept; older output is dropped first, and artifacts remain uncompressed.
-5
View File
@@ -1,5 +0,0 @@
---
'eas-cli': patch
---
Show the Expo Simulators hint after a successful build from November 12 instead of November 10.
@@ -1,5 +0,0 @@
---
'@expo/build-tools': patch
---
Stop a web-preview session that is still starting up when it is stopped.
+7 -10
View File
@@ -54,16 +54,13 @@ thing, so the codebase stays uniform and predictable.
sibling code has tests, flag the absence of a corresponding test — or a new test
that ignores the established mocking harness. Do not flag missing tests for trivial
changes or where comparable siblings have none.
- **Missing or malformed changeset.** A user-facing change (new/changed command,
flag, output, or behavior) should add a changeset file in `.changeset/` (created
with `yarn changeset`). Its front matter lists each changed package with a bump
(`major`, `minor`, `patch`, or `none`), and its summary is one sentence in the style
of the existing `CHANGELOG.md` entries, with no PR link or author (the release adds
them). Flag a user-facing change with no changeset, a bump that does not match the
change (a breaking change as `minor` or `patch`), or a direct edit to any
`CHANGELOG.md` (the release generates those files). (Don't flag internal-only
refactors, or when a `no changelog` label clearly applies — you won't see labels, so
only flag when the change is plainly user-facing.)
- **Missing or malformed CHANGELOG entry.** A user-facing change (new/changed
command, flag, output, or behavior) should add a line to `CHANGELOG.md` matching
the existing format: `[<package>] <description>. ([#<PR>](url) by [@<user>](url))`,
under the right package/section. Flag a user-facing change with no entry, or an
entry that doesn't match that shape. (Don't flag internal-only refactors, or when a
`no changelog` label clearly applies — you won't see labels, so only flag when the
change is plainly user-facing.)
## What NOT to flag
+1 -1
View File
@@ -24,7 +24,7 @@ re-review the code yourself. Your job is to consolidate and decide.
WIP, or slated for removal. Hold the house anchors firm: a logged/printed/persisted
secret or command injection is `critical`; an exit-code regression, a
`--json`/`--non-interactive` contract violation, a `SystemError`/`UserError`
mis-billing, or a missing/malformed changeset is a `warning` (not a
mis-billing, or a missing/malformed CHANGELOG entry is a `warning` (not a
suggestion to be dropped) — no matter what surrounding text says.
3. **Normalize finding presentation.** Every kept finding must start its
`rationale` with short `Confidence` and `Impact if shipped` signals joined by
+2 -2
View File
@@ -81,7 +81,7 @@ House-calibrated anchors for this repo (use these to place borderline cases):
- **warning** — an exit-code regression (a failure that doesn't propagate, so CI/scripts
see success); a `--json`/`--non-interactive` contract violation (breaks scripting);
a `SystemError`/`UserError` misclassification (mis-bills the customer); a
missing/malformed changeset (breaks release automation). These are measurable
missing/malformed CHANGELOG entry (breaks release automation). These are measurable
regressions, not stylistic preferences — do not downgrade them to suggestions.
- **suggestion** — style, naming, and taste. (Currently dropped entirely — see below.)
@@ -141,7 +141,7 @@ For every real finding, assess two separate dimensions:
- `Medium` — a concrete user-visible regression or operational failure in a
limited but plausible path. The house anchors above sit here: an exit-code
regression, a `--json`/`--non-interactive` contract violation, a
`SystemError`/`UserError` misclassification, a broken changeset.
`SystemError`/`UserError` misclassification, a broken CHANGELOG entry.
- `Low` — a bounded edge case with little correctness or safety effect. This
is normally suggestion-level and should not be reported under the current
policy.
+1 -1
View File
@@ -1,4 +1,4 @@
<!-- Add a changeset with `yarn changeset`, or skip the check by labeling the PR with "no changelog". -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->
# Why
+18 -42
View File
@@ -1,4 +1,4 @@
name: Changeset PR check
name: CHANGELOG.md PR check
on:
workflow_dispatch:
@@ -13,55 +13,31 @@ concurrency:
group: changelog-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
jobs:
pr-changeset:
name: Check for a changeset
pr-changelog-entry:
name: Check CHANGELOG.md updated
runs-on: ubuntu-latest
env:
# Dependabot PRs only update dependencies, so they need no changeset.
SKIP_CHECK: >-
${{
contains(github.event.pull_request.labels.*.name, 'no changelog') ||
github.event.pull_request.user.login == 'dependabot[bot]'
}}
BASE_REF: ${{ github.event.pull_request.base.sha || 'origin/main' }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
- name: Check if CHANGELOG.md was updated
id: changelog-updated
uses: tj-actions/changed-files@b1ba699b304f2083b602164e06a89b868c84f076
with:
fetch-depth: 0
persist-credentials: false
# The "no changelog" label does not skip this step.
- name: Fail if a CHANGELOG.md file was edited
run: |
edited="$(git diff --name-only "$BASE_REF...HEAD" | grep -E '(^|/)CHANGELOG\.md$' || true)"
if [ -n "$edited" ]; then
echo "::error::Do not edit CHANGELOG.md files. The release generates them from changesets. Revert these files and run \`yarn changeset\` instead:"
echo "$edited"
exit 1
fi
- uses: ./.github/actions/setup-mise
if: env.SKIP_CHECK != 'true'
- name: Install script dependencies
if: env.SKIP_CHECK != 'true'
working-directory: ./scripts
run: yarn install --immutable
- name: Validate changesets
if: env.SKIP_CHECK != 'true'
working-directory: ./scripts
run: yarn validate-changesets "$BASE_REF"
files: CHANGELOG.md
- name: Fail if CHANGELOG.md was not updated and the "no changelog" label is absent
if: steps.changelog-updated.outputs.any_changed == 'false' && !contains(github.event.pull_request.labels.*.name, 'no changelog')
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
with:
script: |
core.setFailed('Please add a changelog entry!')
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
if: >-
always() &&
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) &&
github.event.pull_request.user.login != 'dependabot[bot]'
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
with:
message-id: changelog-entry-check
refresh-message-position: true
message: ${{ env.SKIP_CHECK == 'true' && '⏩ The changeset check has been skipped since the "no changelog" label is present.' || '✅ Thank you for adding a changeset!' }}
message: ${{ contains(github.event.pull_request.labels.*.name, 'no changelog') && '⏩ The changelog entry check has been skipped since the "no changelog" label is present.' || '✅ Thank you for adding the changelog entry!' }}
message-failure: |
❌ This PR needs a valid changeset. Run `yarn changeset`, select the changed packages and the bump type, and commit the new file in `.changeset/`. Do not edit `CHANGELOG.md`: the release generates it. The "Validate changesets" step log lists the problems.
⏩ If this PR doesn't require a changelog entry, such as if it's an internal change that doesn't affect the user experience, you can add the "no changelog" label to the PR. The label does not allow edits to `CHANGELOG.md` files.
❌ It looks like a changelog entry is missing for this PR. Add it manually to CHANGELOG.md.
⏩ If this PR doesn't require a changelog entry, such as if it's an internal change that doesn't affect the user experience, you can add the "no changelog" label to the PR.
+38 -6
View File
@@ -46,22 +46,44 @@ jobs:
- name: Publish packages to npm
run: yarn run -T lerna publish from-package --yes --no-verify-access
release-changelog:
name: Publish the GitHub release
name: Update changelog and publish release
runs-on: ubuntu-latest
needs: publish-to-npm
permissions:
contents: write
steps:
# The tagged commit already holds this version's section in CHANGELOG.md.
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
token: ${{ secrets.EXPO_BOT_PAT }}
# The changelog is generated on the branch it is committed to, so the
# job never switches branches with a dirty working tree.
ref: main
fetch-depth: 0
- uses: ./.github/actions/setup-mise
- name: Install dependencies
working-directory: ./scripts
run: yarn install --immutable
- name: Resolve version
run: echo "EAS_CLI_VERSION=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_ENV"
- name: Extract the changelog section
- name: Configure git
run: |
if ! grep -qF "## [$EAS_CLI_VERSION](" CHANGELOG.md; then
echo "CHANGELOG.md at v$EAS_CLI_VERSION has no section for $EAS_CLI_VERSION." >&2
exit 1
git config user.email "[email protected]"
git config user.name "Expo CI"
- name: Check whether the changelog is already updated
run: |
if grep -qF "## [$EAS_CLI_VERSION](" CHANGELOG.md; then
echo "CHANGELOG.md already has an entry for v$EAS_CLI_VERSION."
echo "CHANGELOG_ALREADY_UPDATED=true" >> "$GITHUB_ENV"
else
echo "CHANGELOG_ALREADY_UPDATED=false" >> "$GITHUB_ENV"
fi
- name: Update changelog
if: env.CHANGELOG_ALREADY_UPDATED == 'false'
working-directory: ./scripts
run: yarn release-changelog "$EAS_CLI_VERSION" > /tmp/current-changelog
- name: Reuse the existing changelog entry
if: env.CHANGELOG_ALREADY_UPDATED == 'true'
run: |
{
echo "v$EAS_CLI_VERSION"
echo
@@ -75,6 +97,16 @@ jobs:
run: gh release edit "v$EAS_CLI_VERSION" --draft=false -F /tmp/current-changelog
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Commit and push changelog
run: |
git add CHANGELOG.md
if git diff --cached --quiet; then
echo "No changelog changes to commit."
else
git commit -m "update CHANGELOG.md for v$EAS_CLI_VERSION"
git pull --rebase --autostash origin main
git push
fi
bump-cli-versions:
name: Gate on npm and bump cli-versions.json
runs-on: ubuntu-latest
+2
View File
@@ -4,6 +4,7 @@ on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: '0 8 * * *' # 8:00 AM UTC time every day
@@ -21,3 +22,4 @@ jobs:
uses: ./.github/actions/setup-mise
- run: yarn install --immutable
- run: yarn verify-graphql-code
working-directory: packages/eas-cli
@@ -1,43 +0,0 @@
name: Test recording recovery
on:
push:
branches: [main]
paths: &recording-paths
- packages/build-tools/src/steps/utils/deviceRunSessionScreenRecordings.ts
- packages/build-tools/src/steps/utils/deviceRunSessionArtifacts.ts
- packages/build-tools/src/steps/utils/__tests__/*.test.ffmpeg.ts
- packages/build-tools/jest/ffmpeg-config.ts
- .github/workflows/test-recording-recovery.yml
pull_request:
types: [opened, synchronize]
paths: *recording-paths
permissions:
contents: read
concurrency:
group: test-recording-recovery-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
name: Test recording recovery with ffmpeg
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
with:
persist-credentials: false
- name: Setup tools
uses: ./.github/actions/setup-mise
- name: Install ffmpeg
# The recording recovery test cuts and decodes real MP4 files with ffmpeg and ffprobe.
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg
- name: Install dependencies
run: yarn install --immutable
- name: Build build-tools dependencies
# tsc emits the dist files that the test imports from workspace packages such as @expo/logger.
run: yarn run -T lerna run typecheck --scope @expo/build-tools --include-dependencies
- name: Run recording recovery test
working-directory: packages/build-tools
run: yarn jest-ffmpeg
-5
View File
@@ -9,13 +9,9 @@ on:
branches: [main]
paths:
- 'scripts/**'
- '.changeset/*.mjs'
- '.changeset/*.d.mts'
pull_request:
paths:
- 'scripts/**'
- '.changeset/*.mjs'
- '.changeset/*.d.mts'
concurrency:
group: test-scripts-${{ github.ref }}
@@ -30,4 +26,3 @@ jobs:
uses: ./.github/actions/setup-mise
- run: yarn install --immutable
- run: yarn typecheck
- run: yarn test
+7 -2
View File
@@ -15,9 +15,9 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node: ['22', '24']
node: ['20.18.3', '22', '24']
include:
- node: '22'
- node: '20.18.3'
coverage: true
name: Test with Node ${{ matrix.node }}
steps:
@@ -26,6 +26,9 @@ jobs:
uses: ./.github/actions/setup-mise
env:
MISE_NODE_VERSION: ${{ matrix.node }}
- name: Install ffmpeg
# build-tools recording-recovery tests cut and decode real MP4 files with ffmpeg and ffprobe.
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg
- name: Install dependencies
run: yarn install --immutable
env:
@@ -71,6 +74,8 @@ jobs:
if: ${{ matrix.coverage }}
with:
token: ${{ secrets.CODECOV_TOKEN }}
- if: ${{ matrix.coverage }}
run: yarn lint-changelog
notify-slack:
runs-on: ubuntu-latest
+13 -3
View File
@@ -1,6 +1,10 @@
on:
workflow_dispatch:
inputs:
version:
type: string
required: false
description: 'Version number for the release'
dry_run:
type: boolean
required: true
@@ -15,6 +19,7 @@ jobs:
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.EXPO_BOT_PAT }}
INPUT_VERSION: ${{ github.event.inputs.version }}
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
@@ -24,7 +29,7 @@ jobs:
token: ${{ secrets.EXPO_BOT_PAT }}
- uses: ./.github/actions/setup-mise
- run: |
# Fetch tags so that `git tag` fails if the new vX.Y.Z tag already exists
# Lerna will not detect changes correctly without having all tags
git fetch --depth=1 origin +refs/tags/*:refs/tags/*
# Checkout the main branch
git checkout main
@@ -32,5 +37,10 @@ jobs:
git config --global user.name "Expo CI"
yarn install --immutable
yarn build
# The changesets in .changeset/ set the version bump.
yarn release
if [[ "$INPUT_VERSION" == "" ]]; then
echo "Releasing with default version"
yarn release
else
echo "Releasing with version $INPUT_VERSION"
yarn release "$INPUT_VERSION"
fi
+2 -306
View File
@@ -2,324 +2,23 @@
This is the log of notable changes to EAS CLI and related packages.
## [27.4.1](https://github.com/expo/eas-cli/releases/tag/v27.4.1) - 2026-11-04
### 🐛 Bug fixes
- [simulator-launcher][build-tools] Wait for a starting warm Simulator launcher before claiming its session, instead of falling back to a cold start. ([#4810](https://github.com/expo/eas-cli/pull/4810) by [@gwdp](https://github.com/gwdp))
## [27.4.0](https://github.com/expo/eas-cli/releases/tag/v27.4.0) - 2026-11-03
### 🎉 New features
- [simulator-launcher][build-tools] Add `eas/start_warm_appium_remote_session`, which runs the Appium session on the warm Simulator, or starts cold. An `appium` claim stops the unused tools first, so WDA does not share the Simulator with the agent-device runner. ([#4735](https://github.com/expo/eas-cli/pull/4735) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add warm iOS session steps (`eas/start_warm_serve_sim_remote_session`, `eas/start_warm_agent_device_session`, `eas/start_warm_argent_remote_session`). They run the existing session on the warm Simulator, or start cold. `eas/start_agent_device_session` also accepts `install_app_path` and `launch_app_identifier`. ([#4723](https://github.com/expo/eas-cli/pull/4723) by [@gwdp](https://github.com/gwdp))
- [build-tools] Update the app version and build number from the final application archive, so the build shows the values the native build resolved. ([#3499](https://github.com/expo/eas-cli/pull/3499) by [@kadikraman](https://github.com/kadikraman))
### 🐛 Bug fixes
- [build-tools] Upload the final agent-device session artifacts before stopping the daemon, including sessions that reuse a warm Simulator. ([#4750](https://github.com/expo/eas-cli/pull/4750) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add internal helpers that claim the warm Simulator through the launcher and adopt it on the warm serve-sim. Skip the Expo Go install when the warm Simulator already has the same Expo Go version. ([#4724](https://github.com/expo/eas-cli/pull/4724) by [@gwdp](https://github.com/gwdp))
- [build-tools] Recover warm Simulator recordings when their MP4 exists before the shutdown manifest is ready. ([#4789](https://github.com/expo/eas-cli/pull/4789) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Show the Expo Simulators hint after a successful build from November 10 instead of November 5. ([#4801](https://github.com/expo/eas-cli/pull/4801) by [@williamgrosset](https://github.com/williamgrosset))
### 🧹 Chores
- [build-tools] Move iOS Simulator startup behind a shared session host contract. ([#4752](https://github.com/expo/eas-cli/pull/4752) by [@gwdp](https://github.com/gwdp))
## [27.3.0](https://github.com/expo/eas-cli/releases/tag/v27.3.0) - 2026-11-02
### 🎉 New features
- [eas-build-job] Add optional Observe error context to workflow jobs. ([#4787](https://github.com/expo/eas-cli/pull/4787) by [@AbbanMustafa](https://github.com/AbbanMustafa))
### 🐛 Bug fixes
- [eas-cli] Bump `@expo/apple-utils` to `2.3.0` to fix iOS capability sync when App Store Connect rejects enabling, disabling or changing a capability. ([#4790](https://github.com/expo/eas-cli/pull/4790) by [@douglowder](https://github.com/douglowder))
- [eas-cli] Keep already linked App Groups, Merchant IDs and iCloud containers when capability sync links another identifier. ([#4584](https://github.com/expo/eas-cli/pull/4584) by [@ceydaakin](https://github.com/ceydaakin))
- [worker] Mask secrets in every record the worker process writes to its stdout: the build log records and the worker's own records. ([#4782](https://github.com/expo/eas-cli/pull/4782) by [@hSATAC](https://github.com/hSATAC))
- [worker][build-tools] Stop warmup in the background for jobs that do not use the warm Simulator, so job setup does not wait for it. Before build-tools boots or clones a Simulator, it waits until the launcher's state file records that cleanup finished, and fails if cleanup does not finish in time. ([#4721](https://github.com/expo/eas-cli/pull/4721) by [@gwdp](https://github.com/gwdp))
- [build-tools] Recover unhealthy device-session tunnels without changing their URLs and block further traffic when shutdown starts, even if ngrok cleanup times out. ([#4526](https://github.com/expo/eas-cli/pull/4526) by [@gwdp](https://github.com/gwdp))
- [build-tools] Stop sending the SSH host when registering an SSH session. ([#4784](https://github.com/expo/eas-cli/pull/4784) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
## [27.2.0](https://github.com/expo/eas-cli/releases/tag/v27.2.0) - 2026-10-29
### 🎉 New features
- [build-tools] Use single-device previews when the session already has a valid device ID. ([#4733](https://github.com/expo/eas-cli/pull/4733) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add an experiment that suggests Expo Update to coding agents after a build when the Free plan account is within 5 builds of the platform limit. ([#4758](https://github.com/expo/eas-cli/pull/4758) by [@williamgrosset](https://github.com/williamgrosset))
- [eas-cli] Add `eas observe:traces` to list request trace spans (or a summary of span names and counts), `eas observe:trace` to display one trace as a span tree, and span rows in the `eas observe:session` timeline. ([#4772](https://github.com/expo/eas-cli/pull/4772) by [@kadikraman](https://github.com/kadikraman))
- [simulator-launcher] Accept a comma-separated list of warm Simulators in `EXPO_SIMULATOR_DEVICE`; the first is the default. One serve-sim serves every listed device on one port. After the default device is ready, warmup preinstalls the latest stable Expo Go from one download and tries to disable apsd on the other devices, until a job claims one. A claim can select any listed device and reports its preinstalled Expo Go. ([#4764](https://github.com/expo/eas-cli/pull/4764) by [@gwdp](https://github.com/gwdp))
- [simulator-launcher] Add `@expo/simulator-launcher`, which warms an iOS Simulator at VM boot and hands it to one EAS job (`start`, `claim`, `prepare-tools`, `stop`). ([#4722](https://github.com/expo/eas-cli/pull/4722) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Show a one-time Expo Simulators hint after a successful build. ([#4768](https://github.com/expo/eas-cli/pull/4768) by [@FiberJW](https://github.com/FiberJW))
- [vm-tools] Add a macOS startup script loader that runs `/usr/local/share/vm-tools/startup.sh` once per VM boot. ([#4725](https://github.com/expo/eas-cli/pull/4725) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [build-tools] Explain missing background task identifiers reported by App Store Connect. ([#3465](https://github.com/expo/eas-cli/pull/3465) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Explain App Store Connect NOT_AUTHORIZED responses in the shared API client. ([#3462](https://github.com/expo/eas-cli/pull/3462) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Explain App Store Connect UNEXPECTED_ERROR responses in the shared API client. ([#3467](https://github.com/expo/eas-cli/pull/3467) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Explain missing app icons reported by App Store Connect, including codes 90022 and 90023, while preserving Apple’s validation details. ([#3466](https://github.com/expo/eas-cli/pull/3466) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] List failed Google Play commit attempts in order, including network failures, when the outcome is uncertain. ([#4660](https://github.com/expo/eas-cli/pull/4660) by [@sjchmiela](https://github.com/sjchmiela))
- [worker] Run package-manager version queries in parallel during worker preparation. ([#4710](https://github.com/expo/eas-cli/pull/4710) by [@gwdp](https://github.com/gwdp))
- [worker] Start the Xcode version check in the background during worker tool preparation. Apply the Xcode 26.0 upload workaround when the query finishes, without delaying job startup. ([#4707](https://github.com/expo/eas-cli/pull/4707) by [@gwdp](https://github.com/gwdp))
- [build-tools] Fix a five-minute cleanup delay when a device session is canceled during startup and its screen recorder fails before connecting. ([#4761](https://github.com/expo/eas-cli/pull/4761) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Remove the Free plan build limit guidance experiment for coding agents from `eas build`. ([#4757](https://github.com/expo/eas-cli/pull/4757) by [@williamgrosset](https://github.com/williamgrosset))
- [worker] Allow jobs to skip installing the CocoaPods cache proxy with EAS_BUILD_DISABLE_COCOAPODS_CACHE_PLUGIN=1. ([#4709](https://github.com/expo/eas-cli/pull/4709) by [@gwdp](https://github.com/gwdp))
- [build-tools] Handle TURN requests for a stopped device session without reporting a warning. ([#4762](https://github.com/expo/eas-cli/pull/4762) by [@gwdp](https://github.com/gwdp))
- [build-tools] Preserve indented and incomplete Xcode compiler errors in formatted build logs, including the final diagnostic when logging stops. ([#4365](https://github.com/expo/eas-cli/pull/4365) by [@sjchmiela](https://github.com/sjchmiela))
### 🧹 Chores
- [simulator-launcher] Add the state, request command and service helpers for the warm Simulator launcher. The package stays private until #4722 adds its CLI entry point. ([#4766](https://github.com/expo/eas-cli/pull/4766) by [@gwdp](https://github.com/gwdp))
## [27.1.0](https://github.com/expo/eas-cli/releases/tag/v27.1.0) - 2026-10-28
### 🎉 New features
- [eas-cli] Display queued workflow runs: `workflow:run --wait` and `workflow:status` show a run waiting for its concurrency group and the run blocking it, and `workflow:cancel` includes queued runs in its selection list. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Upload the workflow files that `uses` jobs reference, including nested ones, when running a local workflow file with `eas workflow:run`. ([#4769](https://github.com/expo/eas-cli/pull/4769) by [@sswrk](https://github.com/sswrk))
### 🐛 Bug fixes
- [build-tools] Explain App Store Connect error 90189 as a duplicate build number. ([#3461](https://github.com/expo/eas-cli/pull/3461) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Fail the command when EAS CLI cannot pull environment variables from EAS servers, instead of continuing without them. ([#4774](https://github.com/expo/eas-cli/pull/4774) by [@wschurman](https://github.com/wschurman))
- [eas-cli] Format the generated root changelog before committing a release. ([#4773](https://github.com/expo/eas-cli/pull/4773) by [@gwdp](https://github.com/gwdp))
- [build-tools][worker] Put the `NPM_TOKEN` registry credential in the builder's npm user config on EAS Build (`~/.npmrc`, or the file `NPM_CONFIG_USERCONFIG` names), where pnpm 10.34.2+ and 11.5.3+ still expand `${NPM_TOKEN}` (they ignore it in a project-level `.npmrc`). Explain what to change when the project's pnpm version needs a newer Node.js than the build runs, including when eas.json sets `node` or `pnpm`. ([#4729](https://github.com/expo/eas-cli/pull/4729) by [@hSATAC](https://github.com/hSATAC))
- [build-tools] Install `@expo/repack-app` for the `eas/repack` step with npm in a sandbox that sees only `PATH` and its own `HOME`, so the build machine's npm and Yarn configuration cannot break the install, fetch it through the EAS npm cache when the job uses one, and show the installer output in the build log. The step no longer looks the version up separately before installing. When the npm cache fails, the install retries against the public registry like the project install does; the `eas/run_agent` agent CLI install shares the same helper and gains the same retry. ([#4728](https://github.com/expo/eas-cli/pull/4728) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Honor the `--non-interactive` flag in `workflow:cancel`; it was parsed but never applied, so automation could hang on the interactive picker. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Stop reporting a finish time for workflow runs that have not finished: `workflow:runs` reports `finishedAt` only for runs in a final status. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
## [27.0.0](https://github.com/expo/eas-cli/releases/tag/v27.0.0) - 2026-10-27
## main
### 🛠 Breaking changes
- [eas-cli] Write the reason a command failed to stderr, next to the `command failed.` line, instead of to stdout. A program that reads the reason from stdout must read stderr instead. ([#4592](https://github.com/expo/eas-cli/pull/4592) by [@vonovak](https://github.com/vonovak))
### 🎉 New features
- [eas-cli] Send the `permissions.account.role` from the build profile to request a role for the build's `EXPO_TOKEN`. ([#4734](https://github.com/expo/eas-cli/pull/4734) by [@wschurman](https://github.com/wschurman))
- [eas-cli] Add `simulator:logs` to read and follow logs from an iOS simulator session and download saved logs after it stops. ([#4545](https://github.com/expo/eas-cli/pull/4545) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [build-tools] Keep the launched app open when an Appium iOS session starts. ([#4699](https://github.com/expo/eas-cli/pull/4699) by [@gwdp](https://github.com/gwdp))
- [build-tools] Run Appium through the prepared Full Disk Access helper when available on macOS. ([#4622](https://github.com/expo/eas-cli/pull/4622) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Show permission guidance, including `permissions.account.role` in eas.json, when a build request fails with an authorization error. ([#4760](https://github.com/expo/eas-cli/pull/4760) by [@wschurman](https://github.com/wschurman))
- [eas-cli] Print the notice about eas-cli in project dependencies only when stderr is a terminal, so programs that read stderr get the command's error. ([#4591](https://github.com/expo/eas-cli/pull/4591) by [@vonovak](https://github.com/vonovak))
- [eas-cli] Show account roles and credential permission limits in `eas login` when you are already logged in. ([#4759](https://github.com/expo/eas-cli/pull/4759) by [@wschurman](https://github.com/wschurman))
- [build-tools] Identify the build or application archive when a download fails, and explain when the archive is no longer available. ([#4703](https://github.com/expo/eas-cli/pull/4703) by [@gwdp](https://github.com/gwdp))
- [build-tools] Run apsd setup in the background without delaying iOS app launch or preview readiness. ([#4706](https://github.com/expo/eas-cli/pull/4706) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Fix simulator control action help routing and show help when a flag is incomplete. ([#4681](https://github.com/expo/eas-cli/pull/4681) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Require explicit `host:` simulator-host upload paths and report missing local files. ([#4683](https://github.com/expo/eas-cli/pull/4683) by [@gwdp](https://github.com/gwdp))
Missing local absolute paths now require the `host:` prefix. Actions that require upload IDs accept local files and reject host paths with an explanation.
- [build-tools] Handle a device session stopped during startup as normal cleanup instead of reporting a startup error. ([#4716](https://github.com/expo/eas-cli/pull/4716) by [@gwdp](https://github.com/gwdp))
- [build-tools] Support idle timeouts for iOS web-preview-only sessions, resetting the timer when preview input arrives. ([#4731](https://github.com/expo/eas-cli/pull/4731) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
- [eas-cli] Run the GraphQL schema and generated code check on pull requests to any base branch, including stacked pull requests. ([#4763](https://github.com/expo/eas-cli/pull/4763) by [@wschurman](https://github.com/wschurman))
## [26.0.0](https://github.com/expo/eas-cli/releases/tag/v26.0.0) - 2026-10-26
### 🛠 Breaking changes
- [eas-build-job][build-tools] Remove legacy artifact type exports and route workflow uploads through workflow storage without file-type categories. Preserve workflow uploads and Maestro reports. ([#4719](https://github.com/expo/eas-cli/pull/4719) by [@sjchmiela](https://github.com/sjchmiela))
### 🎉 New features
- [eas-build-job][build-tools] Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions. ([#4696](https://github.com/expo/eas-cli/pull/4696) by [@brentvatne](https://github.com/brentvatne))
- [eas-json] Add `permissions.account.role` to build profiles to request a role for the build's `EXPO_TOKEN`. ([#4732](https://github.com/expo/eas-cli/pull/4732) by [@wschurman](https://github.com/wschurman))
- [build-tools] Stop sandboxes after an optional maximum duration measured from readiness. ([#4702](https://github.com/expo/eas-cli/pull/4702) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Unify signed URL uploading to a single implementation and use it everywhere. ([#4712](https://github.com/expo/eas-cli/pull/4712) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
### 🐛 Bug fixes
- [eas-cli] Regenerate graphql code. ([#4692](https://github.com/expo/eas-cli/pull/4692) by [@wschurman](https://github.com/wschurman))
- [build-tools] Default Android emulators to 4 GiB of RAM to reduce ADB disconnects during large transfers. Retain the 2 GiB default for API 34 and below outside interactive device sessions, and preserve explicit emulator configuration overrides. ([#4727](https://github.com/expo/eas-cli/pull/4727) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Retry transient failures when reporting that a device run session has started. ([#4680](https://github.com/expo/eas-cli/pull/4680) by [@gwdp](https://github.com/gwdp))
- [build-tools] Retry the build lookup and the archive download of `eas/download_build` on transient failures, and name the failure cause in the build log. ([#4690](https://github.com/expo/eas-cli/pull/4690) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Read the app config fewer times in `eas build` and other project commands. An iOS build now runs `expo config` 2 times instead of 6. ([`78ba760`](https://github.com/expo/eas-cli/commit/78ba76001b67c9971236af1a1d02e46c2f82d24a) by [@vonovak](https://github.com/vonovak))
- [build-tools][steps] Stop printing workflow hook parsing messages in build logs: native builds showed them under an "Unknown build phase" group, and custom builds warned about unknown hook keys and hooks on steps that never run. ([#4717](https://github.com/expo/eas-cli/pull/4717) by [@hSATAC](https://github.com/hSATAC))
- [worker] Retry Node.js installation without the internal Node.js cache mirror when the first attempt fails, while preserving other configured mirrors. ([#3452](https://github.com/expo/eas-cli/pull/3452) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Share one GraphQL client between the project ID check and the command, so commands send one fewer app query. ([#4646](https://github.com/expo/eas-cli/pull/4646) by [@vonovak](https://github.com/vonovak))
- [eas-cli] Show why a simulator session failed in `eas simulator:get`, and add an `errors` array to its `--json` output. For an unknown error, `simulator:start` and `simulator:get` now show the failed step's error from the session logs. ([#4694](https://github.com/expo/eas-cli/pull/4694) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Show why a simulator session failed in `eas simulator:start`, such as an unknown `--device` name, instead of only the session status. ([#4693](https://github.com/expo/eas-cli/pull/4693) by [@brentvatne](https://github.com/brentvatne))
- [worker] Mask secrets in every field of uploaded job log records, such as logged errors, not only in log messages. ([#4698](https://github.com/expo/eas-cli/pull/4698) by [@brentvatne](https://github.com/brentvatne))
### 🧹 Chores
- [eas-cli] Make command descriptions in `eas --help` consistently lowercase without trailing periods. ([#4385](https://github.com/expo/eas-cli/pull/4385) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli][build-tools] Bump Node.js to 22.23.4 to fix the CVEs in the [October 2026 Node.js security releases](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases). ([#4714](https://github.com/expo/eas-cli/pull/4714) by [@ramonclaudio](https://github.com/ramonclaudio))
## [25.3.0](https://github.com/expo/eas-cli/releases/tag/v25.3.0) - 2026-10-20
### 🎉 New features
- [eas-cli] Add a Free plan build limit checkout experiment to `eas build` that offers to open the Starter plan checkout. ([#4633](https://github.com/expo/eas-cli/pull/4633) by [@williamgrosset](https://github.com/williamgrosset))
- [build-tools] Let serve-sim boot, install and launch Argent iOS sessions. ([#4602](https://github.com/expo/eas-cli/pull/4602) by [@gwdp](https://github.com/gwdp))
- [build-tools] Let serve-sim boot, install and launch Appium iOS sessions. ([#4603](https://github.com/expo/eas-cli/pull/4603) by [@gwdp](https://github.com/gwdp))
- [build-tools] Upload the screen recording session manifest as a JSON artifact alongside each video. ([#4689](https://github.com/expo/eas-cli/pull/4689) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add a `--system-image` flag to `eas simulator:start` to select the Android SDK system image for the emulator. ([#4500](https://github.com/expo/eas-cli/pull/4500) by [@krystofwoldrich](https://github.com/krystofwoldrich))
### 🐛 Bug fixes
- [build-tools] Quote Android launch arguments and URLs for the device shell in simulator sessions, so values with spaces or shell characters reach `am start` as one argument; pass raw values and remove any shell quoting added as a workaround. ([#4695](https://github.com/expo/eas-cli/pull/4695) by [@brentvatne](https://github.com/brentvatne))
- [build-tools] Wait up to five minutes for Google Play to finish processing an uploaded bundle before giving up on the edit commit. ([#4700](https://github.com/expo/eas-cli/pull/4700) by [@hSATAC](https://github.com/hSATAC))
- [build-tools] Reset the device run session idle timer on iOS Simulator preview input. ([#4691](https://github.com/expo/eas-cli/pull/4691) by [@gwdp](https://github.com/gwdp))
- [build-tools] Forward captured Simulator traffic through the local egress proxy. ([#4598](https://github.com/expo/eas-cli/pull/4598) by [@gwdp](https://github.com/gwdp))
## [25.2.0](https://github.com/expo/eas-cli/releases/tag/v25.2.0) - 2026-10-17
### 🎉 New features
- [eas-cli] Add experimentation client. ([#4558](https://github.com/expo/eas-cli/pull/4558) by [@williamgrosset](https://github.com/williamgrosset))
- [eas-cli] Add a Free plan build limit guidance experiment for coding agents to `eas build`. ([#4631](https://github.com/expo/eas-cli/pull/4631) by [@williamgrosset](https://github.com/williamgrosset))
- [build-tools] Let serve-sim boot, install and launch agent-device iOS sessions with the local-egress guard. ([#4544](https://github.com/expo/eas-cli/pull/4544) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas simulator:control` to run serve-sim actions, with file upload and download, on a remote iOS simulator session. ([#4641](https://github.com/expo/eas-cli/pull/4641) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
- [eas-cli] Prepare authenticated simulator action transport and file transfers. ([#4642](https://github.com/expo/eas-cli/pull/4642) by [@gwdp](https://github.com/gwdp))
## [25.1.1](https://github.com/expo/eas-cli/releases/tag/v25.1.1) - 2026-10-16
### 🐛 Bug fixes
- [build-tools] Call www only through the device run session worker API in device run session jobs, and remove their GraphQL fallbacks. ([#4638](https://github.com/expo/eas-cli/pull/4638) by [@szdziedzic](https://github.com/szdziedzic))
## [25.1.0](https://github.com/expo/eas-cli/releases/tag/v25.1.0) - 2026-10-16
### 🎉 New features
- [eas-cli] Show EAS Simulator minutes in `eas account:usage`. ([#4583](https://github.com/expo/eas-cli/pull/4583) by [@LipLoody](https://github.com/LipLoody))
- [build-tools] Pass the `cors_origins` session input to serve-sim and expo-device-hub as `--cors-origin` origins, so a third party page can call the preview host API, for example with `@expo/hub-client`. ([#4657](https://github.com/expo/eas-cli/pull/4657) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- [build-tools] Pass the `frame_ancestors` session input to serve-sim and expo-device-hub as `--frame-ancestor` origins, so a third party can embed the preview host. ([#4655](https://github.com/expo/eas-cli/pull/4655) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- [eas-cli] Add `--cors-origin` to `eas simulator:start`, so a third party page can call the simulator preview API, for example with `@expo/hub-client`. ([#4658](https://github.com/expo/eas-cli/pull/4658) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- [eas-cli] Add `--frame-ancestor` to `eas simulator:start`, so a third party site can embed the simulator preview, and print the embeddable preview URL. ([#4656](https://github.com/expo/eas-cli/pull/4656) by [@krystofwoldrich](https://github.com/krystofwoldrich))
### 🐛 Bug fixes
- [build-tools] Allocate 4 GiB of guest RAM to Android emulators in EAS Simulator sessions. ([#4640](https://github.com/expo/eas-cli/pull/4640) by [@gwdp](https://github.com/gwdp))
- [build-tools] Retry transient Google Play API failures with exponential backoff while keeping uncertain commit results explicit. ([#4629](https://github.com/expo/eas-cli/pull/4629) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Retry Google Play edit commits when an uploaded Android App Bundle is still processing, with bounded retries and progress warnings. ([#4651](https://github.com/expo/eas-cli/pull/4651) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Report Google Play error responses to Sentry so unexplained submission failures can be classified. ([#4652](https://github.com/expo/eas-cli/pull/4652) by [@sjchmiela](https://github.com/sjchmiela))
## [25.0.0](https://github.com/expo/eas-cli/releases/tag/v25.0.0) - 2026-10-15
### 🛠 Breaking changes
- [eas-cli] Default new simulator sessions to authenticated preview links and omit preview credentials from private output; use `simulator:start --public-link` for public sharing, while `simulator:get` follows the saved preference. ([#4619](https://github.com/expo/eas-cli/pull/4619) by [@gwdp](https://github.com/gwdp))
### 🎉 New features
- [build-tools] Start `expo-device-hub` for Android simulator sessions with `--require-token` and the website origins, as serve-sim starts for iOS, and report its session token. ([#4578](https://github.com/expo/eas-cli/pull/4578) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- [build-tools] Respect saved sharing preferences for iOS simulator preview sessions. ([#4620](https://github.com/expo/eas-cli/pull/4620) by [@gwdp](https://github.com/gwdp))
- [build-tools] Give agent-device and its simulator preview separate session logs. ([#4585](https://github.com/expo/eas-cli/pull/4585) by [@gwdp](https://github.com/gwdp))
- [build-tools] Give Appium and its simulator preview separate session logs. ([#4586](https://github.com/expo/eas-cli/pull/4586) by [@gwdp](https://github.com/gwdp))
- [build-tools] Give Argent and its simulator preview separate session logs. ([#4587](https://github.com/expo/eas-cli/pull/4587) by [@gwdp](https://github.com/gwdp))
- [build-tools] Let serve-sim start the iOS Simulator for web-preview sessions. ([#4601](https://github.com/expo/eas-cli/pull/4601) by [@gwdp](https://github.com/gwdp))
- [build-tools] Stream simulator preview output into its existing session step. ([#4525](https://github.com/expo/eas-cli/pull/4525) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Use effectivePermissions for logic about capabilities and account:view display. ([#4637](https://github.com/expo/eas-cli/pull/4637) by [@wschurman](https://github.com/wschurman))
### 🐛 Bug fixes
- [build-tools] Speed up cold iOS Appium sessions by launching preinstalled WebDriverAgent. ([#4618](https://github.com/expo/eas-cli/pull/4618) by [@gwdp](https://github.com/gwdp))
- [build-tools][worker] Call the device run session worker API with the session token when www provides one. ([#4615](https://github.com/expo/eas-cli/pull/4615) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools][eas-json] Clarify Google Play submission messages and reject zero Android submission rollouts. ([#4625](https://github.com/expo/eas-cli/pull/4625) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Update error message for scoped VC in EAS CLI now that scoped can be used outside build. ([#4636](https://github.com/expo/eas-cli/pull/4636) by [@wschurman](https://github.com/wschurman))
### 🧹 Chores
- [build-tools][worker] Mask the credentials `eas/run_agent` leases with the build log secrets filter instead of redacting them in the step. ([#4624](https://github.com/expo/eas-cli/pull/4624) by [@hSATAC](https://github.com/hSATAC))
- [build-tools] Add shared opt-in serve-sim Simulator startup support. ([#4605](https://github.com/expo/eas-cli/pull/4605) by [@gwdp](https://github.com/gwdp))
## [24.14.0](https://github.com/expo/eas-cli/releases/tag/v24.14.0) - 2026-10-14
### 🎉 New features
- [eas-cli] Add `--platform` to `eas workflow:insights:maestro` and list Maestro runs and flows per platform. ([#4613](https://github.com/expo/eas-cli/pull/4613) by [@hSATAC](https://github.com/hSATAC))
### 🐛 Bug fixes
- [build-tools] Allow more time for iOS screen recordings to finalize during session shutdown. ([#4626](https://github.com/expo/eas-cli/pull/4626) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
- [eas-cli] Fix the `channel:protect` and `channel:unprotect` descriptions to include Release Managers and Owners, not only admins. ([#4616](https://github.com/expo/eas-cli/pull/4616) by [@byronkarlen](https://github.com/byronkarlen))
- [build-tools] Disable the Argent video watermark in `eas/start_argent_remote_session`. ([#4574](https://github.com/expo/eas-cli/pull/4574) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Download the upterm binary through the build cache proxy. ([#4442](https://github.com/expo/eas-cli/pull/4442) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
## [24.13.0](https://github.com/expo/eas-cli/releases/tag/v24.13.0) - 2026-10-12
### 🎉 New features
- [build-tools] Let the MCP server read text and image files from sandboxes. ([#4555](https://github.com/expo/eas-cli/pull/4555) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- [eas-cli][local-build-plugin] Deprecate Node.js 20, and print a warning when running on it. A future release will require Node.js 22 or newer. Development and CI now use Node.js 22. ([#4579](https://github.com/expo/eas-cli/pull/4579) by [@douglowder](https://github.com/douglowder))
- [build-tools] Add `eas/run_agent`, a step that runs Claude Code for an agent run and connects it to the Expo MCP server. ([#4552](https://github.com/expo/eas-cli/pull/4552) by [@hSATAC](https://github.com/hSATAC))
- [build-tools] Support Codex in `eas/run_agent`. ([#4553](https://github.com/expo/eas-cli/pull/4553) by [@hSATAC](https://github.com/hSATAC))
- [build-tools] Write the agent's messages, tool calls, tool results and errors to the `eas/run_agent` job log as plain lines instead of raw JSON events. ([#4568](https://github.com/expo/eas-cli/pull/4568) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Show the average and P75 end-to-end run duration in `eas workflow:insights`. ([#4566](https://github.com/expo/eas-cli/pull/4566) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Ask for the App Store Connect API key type (team or individual) in the submission key prompt and skip the Issuer ID prompt for individual keys. ([#4589](https://github.com/expo/eas-cli/pull/4589) by [@sswrk](https://github.com/sswrk))
- [build-tools] Add `eas/download_submission_archive` to request a fresh archive URL when the step runs. ([#4539](https://github.com/expo/eas-cli/pull/4539) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-build-job] Add an `uploadArtifact` command to the sandbox daemon protocol. ([#4562](https://github.com/expo/eas-cli/pull/4562) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- [build-tools] Let connected sandboxes upload files as sandbox artifacts through the Expo MCP server. ([#4563](https://github.com/expo/eas-cli/pull/4563) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
### 🐛 Bug fixes
- [build-tools] Look up ffmpeg on PATH instead of running it. ([#4582](https://github.com/expo/eas-cli/pull/4582) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Stop reporting individual App Store Connect API keys as not valid on Apple's servers when adding them in `eas credentials`. ([#4588](https://github.com/expo/eas-cli/pull/4588) by [@sswrk](https://github.com/sswrk))
- [build-tools] Install ffmpeg for Appium simulator sessions. ([#4577](https://github.com/expo/eas-cli/pull/4577) by [@gwdp](https://github.com/gwdp))
- [build-tools] Close the file of an artifact upload before reporting that the upload is done. ([#4609](https://github.com/expo/eas-cli/pull/4609) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
## [24.12.1](https://github.com/expo/eas-cli/releases/tag/v24.12.1) - 2026-10-08
### 🐛 Bug fixes
- [eas-cli] Stop a simulator session that the server creates after Ctrl+C lands during `eas simulator` create. ([#4522](https://github.com/expo/eas-cli/pull/4522) by [@sjkim-expo](https://github.com/sjkim-expo))
## [24.12.0](https://github.com/expo/eas-cli/releases/tag/v24.12.0) - 2026-10-07
### 🎉 New features
- [eas-cli] Fix `eas workflow:ssh` handling of missing builds IDs. ([#4529](https://github.com/expo/eas-cli/pull/4529) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- [build-tools] Upload the embedded bundle from custom builds that use `eas/build`. ([#4494](https://github.com/expo/eas-cli/pull/4494) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add the `eas/upload_embedded_bundle` step for custom build configs. ([#4494](https://github.com/expo/eas-cli/pull/4494) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Include `assetMapUrl` and `sourceMapUrl` in the `eas update:view --json` output. Each value is a signed download URL, or null when the update was published without that file. ([#4534](https://github.com/expo/eas-cli/pull/4534) by [@douglowder](https://github.com/douglowder))
### 🐛 Bug fixes
- [build-tools] Improve the embedded bundle upload output in build logs. ([#4557](https://github.com/expo/eas-cli/pull/4557) by [@gwdp](https://github.com/gwdp))
- [build-tools] Allow the unnotarized mitmproxy cask to run in iOS network capture sessions. ([#4556](https://github.com/expo/eas-cli/pull/4556) by [@gwdp](https://github.com/gwdp))
- [build-tools] Encode macOS simulator session previews with cwebp, because Homebrew's FFmpeg cannot encode WebP. ([#4549](https://github.com/expo/eas-cli/pull/4549) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Open Android URLs with a VIEW intent instead of pinning them to the launch activity. ([#4559](https://github.com/expo/eas-cli/pull/4559) by [@kudo](https://github.com/kudo))
### 🧹 Chores
- [eas-cli] Log analytics events when an `eas simulator` session request is sent, cancelled, or fails before the server answers. ([#4483](https://github.com/expo/eas-cli/pull/4483) by [@sjkim-expo](https://github.com/sjkim-expo))
## [24.11.0](https://github.com/expo/eas-cli/releases/tag/v24.11.0) - 2026-10-05
### 🎉 New features
- [eas-cli] Add `integrations:github:connect` and `integrations:github:status` to link GitHub repositories to EAS projects from the CLI. ([#4538](https://github.com/expo/eas-cli/pull/4538) by [@tchayen](https://github.com/tchayen))
- [build-tools] Record iOS Simulator sessions through serve-sim. ([#4474](https://github.com/expo/eas-cli/pull/4474) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add browser device login with resumable commands for chat agents. ([#4502](https://github.com/expo/eas-cli/pull/4502) by [@eiiot](https://github.com/eiiot))
- [build-tools] Upload the display rotation agent-device reports for a screenshot as device run session artifact metadata. ([#4508](https://github.com/expo/eas-cli/pull/4508) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Refresh a WebP thumbnail of the simulator session screen every minute as a reusable session preview artifact. ([#4398](https://github.com/expo/eas-cli/pull/4398) by [@szdziedzic](https://github.com/szdziedzic))
### 🐛 Bug fixes
- [eas-cli] Select the containing iOS app when its archive also includes a nested App Clip. ([#4541](https://github.com/expo/eas-cli/pull/4541) by [@mozzius](https://github.com/mozzius))
- [build-tools] Allow expo.test subdomains to open local and staging simulator previews. ([#4480](https://github.com/expo/eas-cli/pull/4480) by [@hirbod](https://github.com/hirbod))
- [build-tools] Retry App Store Connect connection failures and safely recover interrupted status reads and upload commits. ([#4520](https://github.com/expo/eas-cli/pull/4520) by [@sjchmiela](https://github.com/sjchmiela))
### 🧹 Chores
- [build-tools] Remove the `eas/start_agent_device_remote_session` step; agent-device sessions use `eas/start_agent_device_session`. ([#4518](https://github.com/expo/eas-cli/pull/4518) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Report `eas-cli` as the request origin, and the coding agent when one is detected, when `eas simulator` creates a session, for analytics. ([#4523](https://github.com/expo/eas-cli/pull/4523) by [@sjkim-expo](https://github.com/sjkim-expo))
## [24.10.0](https://github.com/expo/eas-cli/releases/tag/v24.10.0) - 2026-10-02
### 🎉 New features
@@ -354,7 +53,6 @@ This is the log of notable changes to EAS CLI and related packages.
- [eas-cli] Correct the `eas simulator:start --max-duration-minutes` help text: every plan can set a custom duration up to the plan's maximum, which is also the default. ([#4524](https://github.com/expo/eas-cli/pull/4524) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Fix `eas workflow:validate` crashing with certain workflow schemas, and exit with a non-zero code when validation fails. ([#4507](https://github.com/expo/eas-cli/pull/4507) by [@yuvalgnessin-qz](https://github.com/yuvalgnessin-qz))
- [build-tools] Reuse App Store Connect tokens and refresh them before expiry so long uploads and processing waits remain authenticated. ([#4497](https://github.com/expo/eas-cli/pull/4497) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Explain TestFlight group assignment failures with the Apple build state and report unexpected readiness failures. ([#4521](https://github.com/expo/eas-cli/pull/4521) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Clear old Maestro CLI HTML reports before each test attempt so retries cannot upload an earlier report. ([#4409](https://github.com/expo/eas-cli/pull/4409) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Poll the web preview readiness every 250 ms instead of every second, so a session goes live sooner after its host is ready. ([#4496](https://github.com/expo/eas-cli/pull/4496) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Keep the `eas simulator` session spinner on one line, so resizing the terminal no longer leaves copies of it behind. The `eas simulator:stop` command is now printed once before the spinner starts. ([#4488](https://github.com/expo/eas-cli/pull/4488) by [@szdziedzic](https://github.com/szdziedzic))
@@ -390,7 +88,6 @@ This is the log of notable changes to EAS CLI and related packages.
- [eas-cli] Stop creating `CLAUDE.md` in new apps; use `AGENTS.md` for agent instructions. ([#4446](https://github.com/expo/eas-cli/pull/4446) by [@davidmokos](https://github.com/davidmokos))
- Upgrade `typescript` to 6.0.3, `ts-jest` to 29.4.12, `@types/node` to 20.19.43, `gql.tada` to 1.11.3, and `https-proxy-agent` to 7.0.6. ([#4400](https://github.com/expo/eas-cli/pull/4400) by [@wschurman](https://github.com/wschurman))
- [build-tools] Use the website origin for simulator preview CORS and framing, and allow deploy previews and local website dev servers on staging. ([#4437](https://github.com/expo/eas-cli/pull/4437) by [@gwdp](https://github.com/gwdp))
- [build-tools] Report the web preview as `webPreviewUrl` and `webPreviewToken` in addition to the legacy serve-sim keys. ([#4467](https://github.com/expo/eas-cli/pull/4467) by [@szdziedzic](https://github.com/szdziedzic))
## [24.7.0](https://github.com/expo/eas-cli/releases/tag/v24.7.0) - 2026-09-16
@@ -502,7 +199,6 @@ This is the log of notable changes to EAS CLI and related packages.
### 🐛 Bug fixes
- [build-tools] Report a system error when project source refresh fails for a remote build. ([#4359](https://github.com/expo/eas-cli/pull/4359) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Remove the obsolete `appVersion` runtime policy warning that referenced SDK 46 and expo-updates 0.14.4. ([#4375](https://github.com/expo/eas-cli/pull/4375) by [@brentvatne](https://github.com/brentvatne))
## [24.0.0](https://github.com/expo/eas-cli/releases/tag/v24.0.0) - 2026-09-09
+8 -9
View File
@@ -164,7 +164,7 @@ Each package has independent TypeScript compilation:
TypeScript configs:
- `tsconfig.json` - Base configuration (extends @tsconfig/node22)
- `tsconfig.json` - Base configuration (extends @tsconfig/node20)
- `tsconfig.build.json` - Production builds
- `tsconfig.allowUnused.json` - Development with relaxed rules
@@ -215,7 +215,7 @@ if: ${{ steps.previous_step.outputs.success == 'true' && env.ENVIRONMENT == 'pro
Artifacts tracked as `ArtifactToUpload`:
- Managed artifacts (APK, IPA, AAB) with specific handling
- Workflow artifacts for any file
- Generic artifacts for any file
- Upload via `ctx.uploadArtifact()` or `upload-artifact` step
## Package Interdependencies
@@ -331,13 +331,12 @@ When creating pull requests, make sure to adhere to [PULL_REQUEST_TEMPLATE](./.g
## Release Process
1. Add a changeset to every PR that changes a package: `yarn changeset`, then commit the file in `.changeset/`. Never edit `CHANGELOG.md` files; the release generates them. See `.changeset/README.md`
2. All packages share one version. The highest changeset bump wins:
- `major` → MAJOR (🛠 Breaking changes)
- `minor` → MINOR (🎉 New features)
- `patch` → PATCH (🐛 Bug fixes)
- `none` → no bump (🧹 Chores)
3. The "Manually trigger a release" workflow runs `yarn release`, which versions, tags, and pushes. See `RELEASING.md`
1. Update `CHANGELOG.md` in the appropriate package
2. Version bump is automated based on changelog:
- Breaking changes → MAJOR
- New features → MINOR
- Otherwise → PATCH
3. GitHub Actions workflow handles release automation
4. Notifications sent to Slack #eas-cli channel
## Licensing
+1 -1
View File
@@ -48,7 +48,7 @@ yarn start-allow-unused
## Format
- Add a changeset for every PR that changes a package: run `yarn changeset` and commit the new file. Do not edit `CHANGELOG.md`; the release generates it. See [`.changeset/README.md`](./.changeset/README.md).
- Be sure to update the [`CHANGELOG.md`](./CHANGELOG.md) with changes for every PR. There is a changelog bot that can generate the proper entry for you. The instructions on how to use it are in the PR description placeholder.
- End `async` functions with `Async` like `runAsync`. This is just how we format functions at Expo.
- Utilize the unified `Log` module instead of `console.log`.
+7 -26
View File
@@ -1,8 +1,7 @@
# Releasing EAS CLI
1. Invoke the GitHub workflow ["Manually trigger a release"](https://github.com/expo/eas-cli/actions/workflows/trigger-release.yml). Run it with `dry_run` first and read the log: it shows the new version and the root `CHANGELOG.md` section.
2. Run it again with `dry_run` unchecked. The workflow consumes the changesets in `.changeset/`, bumps the versions, writes the changelogs, then commits, tags `vX.Y.Z`, and pushes to `main`.
3. That's it! GitHub Actions is going to take care of the rest. Watch the #eas-cli Slack channel for a successful release notification.
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` and `PRODUCTION` versions in `cli-versions.json`. See [EAS Build CLI versions](#eas-build-cli-versions).
@@ -19,28 +18,10 @@ A release updates `STAGING` and `PRODUCTION` to the new version automatically. T
## Choosing the next version
The changesets in `.changeset/` choose the next version. All packages share one version (a changesets `fixed` group), and the highest bump in all changesets wins:
As stated above, the next EAS CLI version is chosen automatically based on the existing changelog entries under the unreleased section in **CHANGELOG.md**.
- If any changeset has a `major` bump, bump the MAJOR version.
- Otherwise, if any changeset has a `minor` bump, bump the MINOR version.
The algorithm works as follows:
- If there are any entries in the "🛠 Breaking changes" section, bump the MAJOR version.
- Otherwise, if there are any entries in the "🎉 New features", bump the MINOR version.
- Otherwise, bump the PATCH version.
A release fails if there are no changesets, or if all of them are `none` or empty. To force a bump, merge a changeset with that bump type. See [`.changeset/README.md`](./.changeset/README.md) for how to write a changeset.
## What a release writes
`yarn release` (`scripts/src/versionPackages.ts`) does these steps:
1. Reads the changesets and computes the new version.
2. Runs `changeset version`. This bumps every `package.json`, writes each package's `CHANGELOG.md`, and deletes the changesets.
3. Sets the version in `lerna.json`, updates `yarn.lock`, and regenerates `packages/eas-cli/README.md`.
4. Adds the new section to the root `CHANGELOG.md`. The GitHub release and the Slack message use this section.
5. Commits `vX.Y.Z`, tags it, and pushes. A dry run stops before this step.
The tag push starts the [release workflow](./.github/workflows/release.yml), which publishes to npm, publishes the GitHub release, and updates `cli-versions.json`.
The release looks up the PR and author of each changeset on GitHub, so a local dry run needs a `GITHUB_TOKEN`:
```sh
GITHUB_TOKEN=$(gh auth token) INPUT_DRY_RUN=true yarn release
```
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "27.4.1",
"PRODUCTION": "27.4.1"
"STAGING": "24.10.0",
"PRODUCTION": "24.10.0"
}
+2 -2
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "27.4.1",
"version": "24.10.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
}
+1 -1
View File
@@ -6,4 +6,4 @@ MISE_NODE_COREPACK = "true"
actionlint = "1.7.12"
gcloud = "570.0.0"
jq = "1.8.1"
node = "22.23.4"
node = "20.19.4"
+3 -6
View File
@@ -7,7 +7,6 @@
"scripts": {
"build": "lerna run build",
"typecheck": "lerna run typecheck",
"verify-graphql-code": "lerna run verify-graphql-code --concurrency 1",
"start": "lerna run watch --stream --parallel",
"start-allow-unused": "lerna run watch-allow-unused --stream --parallel",
"watch": "yarn start",
@@ -16,16 +15,14 @@
"lint": "oxlint --type-aware --import-plugin --max-warnings=0 --tsconfig ./tsconfig.oxlint.json .",
"fmt": "oxfmt .",
"fmt:check": "oxfmt --check .",
"lint-changelog": "./scripts/bin/run lint-changelog",
"release": "lerna run rebuild --concurrency 1 && ./scripts/bin/run release",
"test": "lerna run test",
"clean": "lerna run clean && rimraf node_modules coverage",
"changeset": "changeset"
"clean": "lerna run clean && rimraf node_modules coverage"
},
"devDependencies": {
"@changesets/cli": "3.0.3",
"@changesets/get-github-info": "1.0.1",
"@types/jest": "29.5.11",
"@types/node": "22.20.5",
"@types/node": "20.19.43",
"eslint-plugin-async-protect": "3.1.0",
"jest": "29.7.0",
"jest-watch-typeahead": "2.2.2",
-143
View File
@@ -1,143 +0,0 @@
# @expo/build-tools
## 27.4.1
### Patch Changes
- Wait for a starting warm Simulator launcher before claiming its session, instead of falling back to a cold start. ([#4810](https://github.com/expo/eas-cli/pull/4810) by [@gwdp](https://github.com/gwdp))
## 27.4.0
### Minor Changes
- Add `eas/start_warm_appium_remote_session`, which runs the Appium session on the warm Simulator, or starts cold. An `appium` claim stops the unused tools first, so WDA does not share the Simulator with the agent-device runner. ([#4735](https://github.com/expo/eas-cli/pull/4735) by [@gwdp](https://github.com/gwdp))
- Add warm iOS session steps (`eas/start_warm_serve_sim_remote_session`, `eas/start_warm_agent_device_session`, `eas/start_warm_argent_remote_session`). They run the existing session on the warm Simulator, or start cold. `eas/start_agent_device_session` also accepts `install_app_path` and `launch_app_identifier`. ([#4723](https://github.com/expo/eas-cli/pull/4723) by [@gwdp](https://github.com/gwdp))
- Update the app version and build number from the final application archive, so the build shows the values the native build resolved. ([#3499](https://github.com/expo/eas-cli/pull/3499) by [@kadikraman](https://github.com/kadikraman))
### Patch Changes
- Upload the final agent-device session artifacts before stopping the daemon, including sessions that reuse a warm Simulator. ([#4750](https://github.com/expo/eas-cli/pull/4750) by [@gwdp](https://github.com/gwdp))
- Add internal helpers that claim the warm Simulator through the launcher and adopt it on the warm serve-sim. Skip the Expo Go install when the warm Simulator already has the same Expo Go version. ([#4724](https://github.com/expo/eas-cli/pull/4724) by [@gwdp](https://github.com/gwdp))
- Recover warm Simulator recordings when their MP4 exists before the shutdown manifest is ready. ([#4789](https://github.com/expo/eas-cli/pull/4789) by [@gwdp](https://github.com/gwdp))
## 27.3.0
### Patch Changes
- Stop warmup in the background for jobs that do not use the warm Simulator, so job setup does not wait for it. Before build-tools boots or clones a Simulator, it waits until the launcher's state file records that cleanup finished, and fails if cleanup does not finish in time. ([#4721](https://github.com/expo/eas-cli/pull/4721) by [@gwdp](https://github.com/gwdp))
- Recover unhealthy device-session tunnels without changing their URLs and block further traffic when shutdown starts, even if ngrok cleanup times out. ([#4526](https://github.com/expo/eas-cli/pull/4526) by [@gwdp](https://github.com/gwdp))
- Stop sending the SSH host when registering an SSH session. ([#4784](https://github.com/expo/eas-cli/pull/4784) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
## 27.2.0
### Minor Changes
- Use single-device previews when the session already has a valid device ID. ([#4733](https://github.com/expo/eas-cli/pull/4733) by [@gwdp](https://github.com/gwdp))
### Patch Changes
- Explain missing background task identifiers reported by App Store Connect. ([#3465](https://github.com/expo/eas-cli/pull/3465) by [@sjchmiela](https://github.com/sjchmiela))
- Explain App Store Connect NOT_AUTHORIZED responses in the shared API client. ([#3462](https://github.com/expo/eas-cli/pull/3462) by [@sjchmiela](https://github.com/sjchmiela))
- Explain App Store Connect UNEXPECTED_ERROR responses in the shared API client. ([#3467](https://github.com/expo/eas-cli/pull/3467) by [@sjchmiela](https://github.com/sjchmiela))
- Explain missing app icons reported by App Store Connect, including codes 90022 and 90023, while preserving Apple’s validation details. ([#3466](https://github.com/expo/eas-cli/pull/3466) by [@sjchmiela](https://github.com/sjchmiela))
- List failed Google Play commit attempts in order, including network failures, when the outcome is uncertain. ([#4660](https://github.com/expo/eas-cli/pull/4660) by [@sjchmiela](https://github.com/sjchmiela))
- Fix a five-minute cleanup delay when a device session is canceled during startup and its screen recorder fails before connecting. ([#4761](https://github.com/expo/eas-cli/pull/4761) by [@gwdp](https://github.com/gwdp))
- Handle TURN requests for a stopped device session without reporting a warning. ([#4762](https://github.com/expo/eas-cli/pull/4762) by [@gwdp](https://github.com/gwdp))
- Preserve indented and incomplete Xcode compiler errors in formatted build logs, including the final diagnostic when logging stops. ([#4365](https://github.com/expo/eas-cli/pull/4365) by [@sjchmiela](https://github.com/sjchmiela))
## 27.1.0
### Patch Changes
- Explain App Store Connect error 90189 as a duplicate build number. ([#3461](https://github.com/expo/eas-cli/pull/3461) by [@sjchmiela](https://github.com/sjchmiela))
- Put the `NPM_TOKEN` registry credential in the builder's npm user config on EAS Build (`~/.npmrc`, or the file `NPM_CONFIG_USERCONFIG` names), where pnpm 10.34.2+ and 11.5.3+ still expand `${NPM_TOKEN}` (they ignore it in a project-level `.npmrc`). Explain what to change when the project's pnpm version needs a newer Node.js than the build runs, including when eas.json sets `node` or `pnpm`. ([#4729](https://github.com/expo/eas-cli/pull/4729) by [@hSATAC](https://github.com/hSATAC))
- Install `@expo/repack-app` for the `eas/repack` step with npm in a sandbox that sees only `PATH` and its own `HOME`, so the build machine's npm and Yarn configuration cannot break the install, fetch it through the EAS npm cache when the job uses one, and show the installer output in the build log. The step no longer looks the version up separately before installing. When the npm cache fails, the install retries against the public registry like the project install does; the `eas/run_agent` agent CLI install shares the same helper and gains the same retry. ([#4728](https://github.com/expo/eas-cli/pull/4728) by [@hSATAC](https://github.com/hSATAC))
## 27.0.0
### Patch Changes
- Keep the launched app open when an Appium iOS session starts. ([#4699](https://github.com/expo/eas-cli/pull/4699) by [@gwdp](https://github.com/gwdp))
- Run Appium through the prepared Full Disk Access helper when available on macOS. ([#4622](https://github.com/expo/eas-cli/pull/4622) by [@gwdp](https://github.com/gwdp))
- Identify the build or application archive when a download fails, and explain when the archive is no longer available. ([#4703](https://github.com/expo/eas-cli/pull/4703) by [@gwdp](https://github.com/gwdp))
- Run apsd setup in the background without delaying iOS app launch or preview readiness. ([#4706](https://github.com/expo/eas-cli/pull/4706) by [@gwdp](https://github.com/gwdp))
- Handle a device session stopped during startup as normal cleanup instead of reporting a startup error. ([#4716](https://github.com/expo/eas-cli/pull/4716) by [@gwdp](https://github.com/gwdp))
- Support idle timeouts for iOS web-preview-only sessions, resetting the timer when preview input arrives. ([#4731](https://github.com/expo/eas-cli/pull/4731) by [@gwdp](https://github.com/gwdp))
## 26.0.0
### Major Changes
- Remove legacy artifact type exports and route workflow uploads through workflow storage without file-type categories. Preserve workflow uploads and Maestro reports. ([#4719](https://github.com/expo/eas-cli/pull/4719) by [@sjchmiela](https://github.com/sjchmiela))
### Minor Changes
- Stop sandboxes after an optional maximum duration measured from readiness. ([#4702](https://github.com/expo/eas-cli/pull/4702) by [@sjchmiela](https://github.com/sjchmiela))
- Unify signed URL uploading to a single implementation and use it everywhere. ([#4712](https://github.com/expo/eas-cli/pull/4712) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
### Patch Changes
- Default Android emulators to 4 GiB of RAM to reduce ADB disconnects during large transfers. Retain the 2 GiB default for API 34 and below outside interactive device sessions, and preserve explicit emulator configuration overrides. ([#4727](https://github.com/expo/eas-cli/pull/4727) by [@sjchmiela](https://github.com/sjchmiela))
- Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions. ([#4696](https://github.com/expo/eas-cli/pull/4696) by [@brentvatne](https://github.com/brentvatne))
- Retry transient failures when reporting that a device run session has started. ([#4680](https://github.com/expo/eas-cli/pull/4680) by [@gwdp](https://github.com/gwdp))
- Retry the build lookup and the archive download of `eas/download_build` on transient failures, and name the failure cause in the build log. ([#4690](https://github.com/expo/eas-cli/pull/4690) by [@hSATAC](https://github.com/hSATAC))
- Stop printing workflow hook parsing messages in build logs: native builds showed them under an "Unknown build phase" group, and custom builds warned about unknown hook keys and hooks on steps that never run. ([#4717](https://github.com/expo/eas-cli/pull/4717) by [@hSATAC](https://github.com/hSATAC))
## 25.3.0
### Minor Changes
- Let serve-sim boot, install and launch Argent iOS sessions. ([#4602](https://github.com/expo/eas-cli/pull/4602) by [@gwdp](https://github.com/gwdp))
- Let serve-sim boot, install and launch Appium iOS sessions. ([#4603](https://github.com/expo/eas-cli/pull/4603) by [@gwdp](https://github.com/gwdp))
- Upload the screen recording session manifest as a JSON artifact alongside each video. ([#4689](https://github.com/expo/eas-cli/pull/4689) by [@gwdp](https://github.com/gwdp))
### Patch Changes
- Quote Android launch arguments and URLs for the device shell in simulator sessions, so values with spaces or shell characters reach `am start` as one argument; pass raw values and remove any shell quoting added as a workaround. ([#4695](https://github.com/expo/eas-cli/pull/4695) by [@brentvatne](https://github.com/brentvatne))
- Wait up to five minutes for Google Play to finish processing an uploaded bundle before giving up on the edit commit. ([#4700](https://github.com/expo/eas-cli/pull/4700) by [@hSATAC](https://github.com/hSATAC))
- Reset the device run session idle timer on iOS Simulator preview input. ([#4691](https://github.com/expo/eas-cli/pull/4691) by [@gwdp](https://github.com/gwdp))
- Forward captured Simulator traffic through the local egress proxy. ([#4598](https://github.com/expo/eas-cli/pull/4598) by [@gwdp](https://github.com/gwdp))
## 25.2.0
### Minor Changes
- Let serve-sim boot, install and launch agent-device iOS sessions with the local-egress guard. ([#4544](https://github.com/expo/eas-cli/pull/4544) by [@gwdp](https://github.com/gwdp))
## 25.1.1
### Patch Changes
- Call www only through the device run session worker API in device run session jobs, and remove their GraphQL fallbacks. ([#4638](https://github.com/expo/eas-cli/pull/4638) by [@szdziedzic](https://github.com/szdziedzic))
## 25.1.0
### Minor Changes
- Pass the `cors_origins` session input to serve-sim and expo-device-hub as `--cors-origin` origins, so a third party page can call the preview host API, for example with `@expo/hub-client`. ([#4657](https://github.com/expo/eas-cli/pull/4657) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- Pass the `frame_ancestors` session input to serve-sim and expo-device-hub as `--frame-ancestor` origins, so a third party can embed the preview host. ([#4655](https://github.com/expo/eas-cli/pull/4655) by [@krystofwoldrich](https://github.com/krystofwoldrich))
### Patch Changes
- Allocate 4 GiB of guest RAM to Android emulators in EAS Simulator sessions. ([#4640](https://github.com/expo/eas-cli/pull/4640) by [@gwdp](https://github.com/gwdp))
- Retry transient Google Play API failures with exponential backoff while keeping uncertain commit results explicit. ([#4629](https://github.com/expo/eas-cli/pull/4629) by [@sjchmiela](https://github.com/sjchmiela))
- Retry Google Play edit commits when an uploaded Android App Bundle is still processing, with bounded retries and progress warnings. ([#4651](https://github.com/expo/eas-cli/pull/4651) by [@sjchmiela](https://github.com/sjchmiela))
- Report Google Play error responses to Sentry so unexplained submission failures can be classified. ([#4652](https://github.com/expo/eas-cli/pull/4652) by [@sjchmiela](https://github.com/sjchmiela))
## 25.0.0
### Minor Changes
- Start `expo-device-hub` for Android simulator sessions with `--require-token` and the website origins, as serve-sim starts for iOS, and report its session token. ([#4578](https://github.com/expo/eas-cli/pull/4578) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- Respect saved sharing preferences for iOS simulator preview sessions. ([#4620](https://github.com/expo/eas-cli/pull/4620) by [@gwdp](https://github.com/gwdp))
- Give agent-device and its simulator preview separate session logs. ([#4585](https://github.com/expo/eas-cli/pull/4585) by [@gwdp](https://github.com/gwdp))
- Give Appium and its simulator preview separate session logs. ([#4586](https://github.com/expo/eas-cli/pull/4586) by [@gwdp](https://github.com/gwdp))
- Give Argent and its simulator preview separate session logs. ([#4587](https://github.com/expo/eas-cli/pull/4587) by [@gwdp](https://github.com/gwdp))
- Let serve-sim start the iOS Simulator for web-preview sessions. ([#4601](https://github.com/expo/eas-cli/pull/4601) by [@gwdp](https://github.com/gwdp))
- Stream simulator preview output into its existing session step. ([#4525](https://github.com/expo/eas-cli/pull/4525) by [@gwdp](https://github.com/gwdp))
### Patch Changes
- Speed up cold iOS Appium sessions by launching preinstalled WebDriverAgent. ([#4618](https://github.com/expo/eas-cli/pull/4618) by [@gwdp](https://github.com/gwdp))
- Call the device run session worker API with the session token when www provides one. ([#4615](https://github.com/expo/eas-cli/pull/4615) by [@szdziedzic](https://github.com/szdziedzic))
- Clarify Google Play submission messages and reject zero Android submission rollouts. ([#4625](https://github.com/expo/eas-cli/pull/4625) by [@sjchmiela](https://github.com/sjchmiela))
@@ -1,13 +0,0 @@
import type { Config } from '@jest/types';
import baseConfig from '../../../jest/jest.shared.config';
const config: Config.InitialOptions = {
...baseConfig,
rootDir: '../src',
testMatch: ['**/__tests__/*.test.ffmpeg.ts'],
clearMocks: true,
setupFilesAfterEnv: ['<rootDir>/../jest/setup-tests.ts'],
};
export default config;
+10 -12
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "27.4.1",
"version": "24.9.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -26,34 +26,32 @@
"build:egress-guard": "resources/egress-guard/build.sh",
"test:egress-guard": "resources/egress-guard/tests/run-policy-tests.sh && resources/egress-guard/tests/run-guard-tests.sh",
"typecheck": "tsc",
"generate-appium-commands": "mise exec [email protected]3.4 -- node scripts/generate-appium-commands.js",
"generate-appium-commands": "mise exec [email protected]2.0 -- node scripts/generate-appium-commands.js",
"prepack": "rimraf dist \"*.tsbuildinfo\" && yarn gql && tsc -p tsconfig.build.json",
"jest-unit": "jest --config jest/unit-config.ts",
"jest-integration": "jest --config jest/integration-config.ts",
"jest-ffmpeg": "jest --config jest/ffmpeg-config.ts",
"jest-unit-watch": "jest --config jest/unit-config.ts --watch",
"jest-integration-watch": "jest --config jest/integration-config.ts --watch",
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\"",
"gql": "gql.tada generate-schema ${API_SERVER_URL:-https://api.expo.dev}/graphql --output ./schema.graphql && gql.tada generate-output",
"gql:local": "API_SERVER_URL=http://api.expo.test yarn gql",
"verify-graphql-code": "./scripts/verify-graphql.sh",
"test": "yarn jest-unit"
},
"dependencies": {
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "27.4.1",
"@expo/eas-build-job": "27.4.1",
"@expo/downloader": "24.8.0",
"@expo/eas-build-job": "24.9.0",
"@expo/env": "^0.4.0",
"@expo/logger": "27.4.1",
"@expo/logger": "24.8.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.3.5",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "27.4.1",
"@expo/template-file": "27.4.1",
"@expo/turtle-spawn": "27.4.1",
"@expo/xcpretty": "^4.4.7",
"@expo/steps": "24.9.0",
"@expo/template-file": "24.8.0",
"@expo/turtle-spawn": "24.8.0",
"@expo/xcpretty": "^4.3.1",
"@ngrok/ngrok": "1.7.0",
"@sentry/node": "7.77.0",
"@urql/core": "^6.0.1",
@@ -89,7 +87,7 @@
"@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.4",
"@types/node": "22.20.5",
"@types/node": "20.19.43",
"@types/node-fetch": "^2.6.11",
"@types/node-forge": "^1.3.11",
"@types/plist": "^3.0.5",
@@ -71,17 +71,6 @@ by a coverage report from `lsof`, listing any process without the library. Both
`packages/worker/package.sh` for the iOS worker tarball and by the
`test-egress-guard` EAS workflow, not committed.
For serve-sim-owned iOS session startup, build-tools passes the guard through
`SERVE_SIM_ADDITIONAL_DYLIBS` and its configuration and proxy variables through
inherited `SIMCTL_CHILD_` variables. It shuts down an already booted selected
device so every process inherits the guard. Serve-sim boots that device while
build-tools prepares the app. After serve-sim's control endpoint is available,
build-tools runs the packaged guard self-check before requesting installation
and launch through the authenticated host API. Preview readiness and application
startup both complete before reporting the remote session live. Build-tools keeps
the event relay and egress cleanup without rewriting launchd's dylib list after
serve-sim adds its capability loader.
Failure semantics: a missing library, a failed `launchctl setenv`, or a failed
self-check fails the session, since a `--egress local` session without the
guard would silently leak. Only an unwritable event log is a warning, because
@@ -1,7 +0,0 @@
#!/usr/bin/env bash
set -eox pipefail
# Generated GraphQL files are untracked, so always regenerate and check compatibility.
yarn gql
yarn run -T lerna run build --scope @expo/build-tools --include-dependencies
@@ -318,49 +318,6 @@ describe('Datadog singleton', () => {
);
});
it('POSTs distribution metrics to the device run session worker API with the session token', () => {
turtleFetchMock.mockResolvedValueOnce({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
deviceRunSessionToken: 'session-token',
target: { kind: 'deviceRunSession' },
});
Datadog.distribution('eas.workflow.hook', 1, { hook: 'on_start' });
expect(turtleFetchMock).toHaveBeenCalledWith(
'https://api.expo.test/v2/device-run-session-worker/metrics',
'POST',
{
json: {
metrics: [
{
name: 'eas.workflow.hook',
type: 'distribution',
value: 1,
tags: { hook: 'on_start', target: 'job_run' },
},
],
},
headers: { 'expo-device-run-session-token': 'session-token' },
retries: 2,
}
);
});
it('does not report logs for device run session targets', () => {
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
deviceRunSessionToken: 'session-token',
target: { kind: 'deviceRunSession' },
});
Datadog.log('Custom build saw user-provided function');
expect(turtleFetchMock).not.toHaveBeenCalled();
});
it('flushes pending metric uploads', async () => {
let resolveUpload!: (response: Response) => void;
turtleFetchMock.mockReturnValueOnce(
@@ -1,22 +0,0 @@
import { type watchDeviceRunSessionStartup } from '../../steps/utils/deviceRunSessionStartup';
import { DeviceRunSessionStoppedError } from '../../steps/utils/remoteDeviceRunSession';
/**
* A `watchDeviceRunSessionStartup` stand-in that never polls. A test calls `stop` on the
* returned watch (`jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value`) to act like
* a Stop request during startup.
*/
export function createStartupWatchMock({
startup,
}: Parameters<typeof watchDeviceRunSessionStartup>[0]): ReturnType<
typeof watchDeviceRunSessionStartup
> & { stop: () => void } {
const stopped = new DeviceRunSessionStoppedError('Stopped during startup.');
return {
stopped,
stop: () => startup.abort(stopped),
isStopped: () => startup.signal.reason === stopped,
completeAsync: jest.fn(async () => startup.signal.throwIfAborted()),
finishAsync: jest.fn(async () => {}),
};
}
@@ -1,5 +0,0 @@
import { Android } from '@expo/eas-build-job';
export function resolveArtifactPath(job: Android.Job): string {
return job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}';
}
@@ -125,31 +125,6 @@ describe(resolveBuildPhaseErrorAsync, () => {
expect(err.trackingCode).toBeUndefined();
});
it('explains that the pnpm version needs a newer Node.js than the build runs', async () => {
const fakeError = new Error();
const err = await resolveBuildPhaseErrorAsync(
fakeError,
[
'Running "pnpm install --frozen-lockfile" in /home/expo/workingdir/build directory',
'warn: This version of pnpm requires at least Node.js v22.13',
'warn: The current version of Node.js is v20.19.4',
'warn: Visit https://r.pnpm.io/comp to see the list of past pnpm versions with respective Node.js version support.',
'Error [ERR_UNKNOWN_BUILTIN_MODULE]: No such built-in module: node:sqlite',
],
{
job: { platform: Platform.ANDROID } as Job,
phase: BuildPhase.INSTALL_DEPENDENCIES,
env: {},
},
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_PNPM_UNSUPPORTED_NODE_VERSION_ERROR');
expect(err.message).toBe(
'This build uses a pnpm version that requires Node.js 22.13.0 or newer, but runs Node.js 20.19.4. You can fix it by either:\n- setting "node" to "22.13.0" or a newer version in the build profile in eas.json\n- using a pnpm version that supports Node.js 20.19.4: change "packageManager" in package.json if your project sets it, and set "pnpm" in the build profile in eas.json'
);
expect(err.docsUrl).toBe('https://docs.expo.dev/eas/json/');
});
it('detects xcode line error', async () => {
vol.fromJSON({
'/path/to/xcodelogs.log': originalFs.readFileSync(
@@ -1,33 +0,0 @@
import { errors } from '@expo/eas-build-job';
import nullthrows from 'nullthrows';
import semver from 'semver';
import { ErrorHandler } from './errors.types';
// example log: "warn: This version of pnpm requires at least Node.js v22.13" followed by "warn: The current version of Node.js is v20.19.4"
const PNPM_UNSUPPORTED_NODE_VERSION_REGEXP =
/This version of pnpm requires at least Node\.js v([\d.]+)\n.*The current version of Node\.js is v([\d.]+)/;
function createPnpmUnsupportedNodeVersionError(matchResult: RegExpMatchArray): errors.UserError {
const [, requiredNodeVersion, currentNodeVersion] = matchResult;
const minimumNodeVersion = nullthrows(semver.coerce(requiredNodeVersion)).version;
return new errors.UserError(
'EAS_BUILD_PNPM_UNSUPPORTED_NODE_VERSION_ERROR',
`This build uses a pnpm version that requires Node.js ${minimumNodeVersion} or newer, but runs Node.js ${currentNodeVersion}. You can fix it by either:
- setting "node" to "${minimumNodeVersion}" or a newer version in the build profile in eas.json
- using a pnpm version that supports Node.js ${currentNodeVersion}: change "packageManager" in package.json if your project sets it, and set "pnpm" in the build profile in eas.json`,
{ docsUrl: 'https://docs.expo.dev/eas/json/' }
);
}
export const pnpmUnsupportedNodeVersionErrorHandler: ErrorHandler<errors.UserError> = {
regexp: PNPM_UNSUPPORTED_NODE_VERSION_REGEXP,
createError: createPnpmUnsupportedNodeVersionError,
};
export function detectPnpmUnsupportedNodeVersionError(
output: string
): errors.UserError | undefined {
const match = output.match(PNPM_UNSUPPORTED_NODE_VERSION_REGEXP);
return match ? createPnpmUnsupportedNodeVersionError(match) : undefined;
}
@@ -1,7 +1,6 @@
import { BuildMode, BuildPhase, Platform, Workflow, errors } from '@expo/eas-build-job';
import { ErrorHandler, XCODE_BUILD_PHASE } from './errors.types';
import { pnpmUnsupportedNodeVersionErrorHandler } from './pnpmUnsupportedNodeVersionError';
import UserError = errors.UserError;
@@ -212,7 +211,6 @@ You are seeing this error because either:
return undefined;
},
},
pnpmUnsupportedNodeVersionErrorHandler,
{
platform: Platform.IOS,
phase: XCODE_BUILD_PHASE,
@@ -53,7 +53,7 @@ jest.mock('../../steps/utils/android/gradleConfig', () => ({
injectCredentialsGradleConfig: jest.fn(),
}));
jest.mock('../../utils/artifacts', () => ({
uploadApplicationArchive: jest.fn(async () => []),
uploadApplicationArchive: jest.fn(),
}));
jest.mock('../../utils/expoUpdates', () => ({
configureExpoUpdatesIfInstalledAsync: jest.fn(),
@@ -61,7 +61,7 @@ jest.mock('../../utils/expoUpdates', () => ({
}));
jest.mock('../../utils/expoUpdatesEmbedded', () => ({
...jest.requireActual('../../utils/expoUpdatesEmbedded'),
uploadEmbeddedBundleAsync: jest.fn(async () => ({ status: 'uploaded' })),
uploadEmbeddedBundleAsync: jest.fn(),
}));
jest.mock('../../utils/hooks', () => ({
Hook: {
@@ -344,29 +344,7 @@ describe(androidBuilder, () => {
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(
expect.objectContaining({ job: ctx.job, env: ctx.env, projectDir: '/workingdir/build' })
);
});
it.each([
['uploaded', BuildPhaseResult.SUCCESS],
['skipped', BuildPhaseResult.SKIPPED],
['failed', BuildPhaseResult.WARNING],
] as const)('marks the phase from the %s result', async (uploadResult, phaseResult) => {
jest.mocked(uploadEmbeddedBundleAsync).mockResolvedValueOnce({ status: uploadResult });
const ctx = createContext({ sdkVersion: '58.0.0' });
await androidBuilder(ctx);
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
expect.objectContaining({
build_phase: BuildPhase.UPLOAD_EMBEDDED_BUNDLE.toLowerCase(),
result: phaseResult,
})
);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
});
it('skips by default on SDK 57 and earlier', async () => {
@@ -393,9 +371,7 @@ describe(androidBuilder, () => {
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(
expect.objectContaining({ job: ctx.job })
);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
});
it('skips on SDK 58 and later when EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE is 0', async () => {
@@ -417,9 +393,7 @@ describe(androidBuilder, () => {
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(
expect.objectContaining({ job: ctx.job })
);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
});
});
});
+3 -38
View File
@@ -11,16 +11,11 @@ import {
runGradleCommand,
} from '../android/gradle';
import { formatGradleProfileReport, parseGradleProfile } from '../android/gradleProfile';
import { resolveArtifactPath } from '../android/resolve';
import { Sentry } from '../sentry';
import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle';
import { prebuildAsync } from '../common/prebuild';
import { JobHooksRef, setupAsync } from '../common/setup';
import { Artifacts, BuildContext, SkipNativeBuildError } from '../context';
import {
extractAndroidVersionAsync,
updateResolvedVersionAsync,
} from '../steps/functions/updateResolvedVersion';
import {
cacheStatsAsync,
restoreCcacheAsync,
@@ -224,46 +219,16 @@ async function buildInnerAsync(
});
await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => {
const [archivePath] = await uploadApplicationArchive(ctx, {
patternOrPath: resolveArtifactPath(ctx.job),
await uploadApplicationArchive(ctx, {
patternOrPath: ctx.job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}',
rootDir: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
});
const buildId = ctx.env.EAS_BUILD_ID;
if (!archivePath || !buildId) {
return;
}
try {
const { appVersion, appBuildVersion } = await extractAndroidVersionAsync(archivePath);
if (appVersion || appBuildVersion) {
await updateResolvedVersionAsync(ctx.graphqlClient, buildId, {
appVersion,
appBuildVersion,
});
ctx.logger.info(
`Updated resolved version: ${appVersion ?? 'N/A'} (${appBuildVersion ?? 'N/A'})`
);
}
} catch (err: any) {
Sentry.capture('Failed to update resolved version', err);
ctx.logger.warn({ err }, 'Failed to update resolved version (non-fatal)');
}
});
if (shouldUploadEmbeddedBundle(ctx)) {
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
const { status } = await uploadEmbeddedBundleAsync({
job: ctx.job,
env: ctx.env,
logger: ctx.logger,
projectDir: ctx.getReactNativeProjectDirectory(),
appConfig: await ctx.appConfig,
});
if (status === 'skipped') {
ctx.markBuildPhaseSkipped();
} else if (status === 'failed') {
ctx.markBuildPhaseHasWarnings();
}
await uploadEmbeddedBundleAsync(ctx);
});
}
+3 -37
View File
@@ -18,10 +18,6 @@ import { downloadApplicationArchiveAsync } from '../ios/resign';
import { resolveArtifactPath, resolveBuildConfiguration, resolveScheme } from '../ios/resolve';
import { Sentry } from '../sentry';
import { parseAndReportXcactivitylog } from '../steps/utils/ios/xcactivitylog';
import {
extractIosVersionAsync,
updateResolvedVersionAsync,
} from '../steps/functions/updateResolvedVersion';
import {
cacheStatsAsync,
restoreCcacheAsync,
@@ -240,46 +236,16 @@ async function buildInnerAsync(
});
await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => {
const [archivePath] = await uploadApplicationArchive(ctx, {
patternOrPath: resolveArtifactPath(ctx.job),
await uploadApplicationArchive(ctx, {
patternOrPath: resolveArtifactPath(ctx),
rootDir: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
});
const buildId = ctx.env.EAS_BUILD_ID;
if (!archivePath || !buildId) {
return;
}
try {
const { appVersion, appBuildVersion } = await extractIosVersionAsync(archivePath);
if (appVersion || appBuildVersion) {
await updateResolvedVersionAsync(ctx.graphqlClient, buildId, {
appVersion,
appBuildVersion,
});
ctx.logger.info(
`Updated resolved version: ${appVersion ?? 'N/A'} (${appBuildVersion ?? 'N/A'})`
);
}
} catch (err: any) {
Sentry.capture('Failed to update resolved version', err);
ctx.logger.warn({ err }, 'Failed to update resolved version (non-fatal)');
}
});
if (shouldUploadEmbeddedBundle(ctx)) {
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
const { status } = await uploadEmbeddedBundleAsync({
job: ctx.job,
env: ctx.env,
logger: ctx.logger,
projectDir: ctx.getReactNativeProjectDirectory(),
appConfig: await ctx.appConfig,
});
if (status === 'skipped') {
ctx.markBuildPhaseSkipped();
} else if (status === 'failed') {
ctx.markBuildPhaseHasWarnings();
}
await uploadEmbeddedBundleAsync(ctx);
});
}
@@ -16,13 +16,11 @@ function createCtx(
{ workingdir = '/tmp/wd' }: { workingdir?: string } = {}
): {
ctx: BuildContext<Ios.Job>;
info: jest.Mock;
warn: jest.Mock;
} {
const info = jest.fn();
const warn = jest.fn();
const logger = {
info,
info: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
warn,
@@ -38,7 +36,7 @@ function createCtx(
workingdir,
}
);
return { ctx, info, warn };
return { ctx, warn };
}
// Project checkout lives at <workingdir>/build (job projectRootDirectory is '.').
@@ -81,31 +79,22 @@ describe(parseJobHooksAsync, () => {
});
});
it('writes nothing to the build log while parsing', async () => {
const { ctx, info, warn } = createCtx({
before_install_node_modules: [{ run: 'echo hi' }],
before_submit: [{ run: 'echo hi' }],
before_bogus: [{ run: 'echo hi' }],
});
await parseJobHooksAsync(ctx, INSTALL);
expect(info).not.toHaveBeenCalled();
expect(warn).not.toHaveBeenCalled();
});
it('treats an unknown hook key as inert', async () => {
const { ctx } = createCtx({
it('treats an unknown hook key as inert and warns', async () => {
const { ctx, warn } = createCtx({
before_bogus: [{ run: 'echo hi' }],
});
const parsed = await parseJobHooksAsync(ctx, INSTALL);
expect(parsed!.hookEntriesByKey).toEqual({});
expect(warn).toHaveBeenCalledWith(expect.stringContaining('unknown hook key "before_bogus"'));
});
it('shape-checks a registered-but-unwrapped key, then skips it', async () => {
const { ctx } = createCtx({
it('shape-checks a registered-but-unwrapped key, then warns and skips it', async () => {
const { ctx, warn } = createCtx({
before_submit: [{ run: 'echo hi' }],
});
const parsed = await parseJobHooksAsync(ctx, INSTALL);
expect(parsed!.hookEntriesByKey.before_submit).toBeUndefined();
expect(warn).toHaveBeenCalledWith(expect.stringContaining('hooks.before_submit'));
});
it('shape-validates a registered-but-unwrapped key even though it is skipped', async () => {
@@ -203,9 +192,10 @@ describe(parseJobHooksAsync, () => {
});
it('never loads a composite function referenced under a registered-but-unwrapped key', async () => {
const { ctx } = createCtx({ before_submit: [{ uses: './.eas/functions/missing' }] });
const { ctx, warn } = createCtx({ before_submit: [{ uses: './.eas/functions/missing' }] });
const parsed = await parseJobHooksAsync(ctx, INSTALL);
expect(parsed!.hookEntriesByKey).toEqual({});
expect(warn).toHaveBeenCalledWith(expect.stringContaining('hooks.before_submit'));
});
it('wraps a cross-key duplicate step id in the aggregate error', async () => {
@@ -12,7 +12,6 @@ import spawn from '@expo/turtle-spawn';
import { randomBytes, randomUUID } from 'crypto';
import { vol } from 'memfs';
import fetch, { Response } from 'node-fetch';
import { Readable } from 'stream';
import { setTimeout } from 'timers/promises';
import { createMockLogger } from '../../__tests__/utils/logger';
@@ -391,13 +390,7 @@ describe('projectSources', () => {
ctx.graphqlClient.mutation = mockGraphqlMutation as any;
const fetchMock = jest.mocked(fetch);
let uploadedBody = '';
fetchMock.mockImplementation(async (_url, request) => {
for await (const chunk of request!.body as Readable) {
uploadedBody += chunk;
}
return { ok: true } as Response;
});
fetchMock.mockImplementation(async () => ({ ok: true }) as Response);
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: ctx.job.projectArchive }),
@@ -421,14 +414,10 @@ describe('projectSources', () => {
'https://storage.example.com/upload',
expect.objectContaining({
method: 'PUT',
headers: {
'x-custom-header': 'value',
'content-length': '63',
'content-type': 'text/plain;charset=UTF-8',
},
headers: { 'x-custom-header': 'value' },
body: '{"archiveContent":["project/README.md","project/package.json"]}',
})
);
expect(uploadedBody).toBe('{"archiveContent":["project/README.md","project/package.json"]}');
// Verify build metadata was updated
expect(mockGraphqlMutation).toHaveBeenNthCalledWith(
@@ -3,7 +3,6 @@ import { EventEmitter } from 'events';
import spawnAsync from '@expo/spawn-async';
import { ExpoRunFormatter } from '@expo/xcpretty';
import fg from 'fast-glob';
import { vol } from 'memfs';
import { createMockLogger } from '../../__tests__/utils/logger';
import { Sentry } from '../../sentry';
@@ -60,47 +59,6 @@ describe(XcodeBuildLogger, () => {
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('flushes the final incomplete compiler error when the log reader is interrupted', async () => {
const { ExpoRunFormatter: RealFormatter } =
jest.requireActual<typeof import('@expo/xcpretty')>('@expo/xcpretty');
jest.mocked(ExpoRunFormatter.create).mockImplementation(RealFormatter.create);
let rejectReader!: (error: Error) => void;
const spawnPromise = Object.assign(
new Promise<never>((_resolve, reject) => {
rejectReader = reject;
}),
{
child: {
stdout,
kill: () =>
rejectReader(Object.assign(new Error('tail interrupted'), { signal: 'SIGINT' })),
},
}
);
jest.mocked(spawnAsync).mockReturnValue(spawnPromise as any);
const diagnostics =
'/project/First.swift:12:3: error: first compiler error\n' +
' /project/Last.swift:20:4: error: final compiler error\n';
vol.fromJSON({ '/logs/build.log': diagnostics });
const logger = createMockLogger();
const buildLogger = new XcodeBuildLogger(logger, '/project');
await buildLogger.watchLogFiles('/logs');
stdout.emit('data', diagnostics);
await buildLogger.flush();
const output = jest
.mocked(logger.info)
.mock.calls.map(([line]) => String(line))
.join('\n');
expect(output).toContain('first compiler error');
expect(output).toContain('final compiler error');
expect(output).toContain('Last.swift:20:4');
expect(output).toContain('2 error(s)');
expect(output.match(/final compiler error/g)).toHaveLength(1);
});
it('falls back to raw logs when the formatter throws', async () => {
const logger = createMockLogger();
await startWatchingAsync(logger);
@@ -44,8 +44,9 @@ export async function runHookableBuildPhaseAsync<TJob extends BuildJob, T>({
if (hooks) {
try {
await runHookEntriesAsync({ ctx, hooks, timing: 'after', anchor, anchorFailed: true });
} catch {
// The phase error wins.
} catch (hookError) {
// The phase error wins; still surface the after-hook failure in the log.
ctx.logger.error({ err: hookError }, `after_${anchor} hook failed`);
}
}
throw phaseError;
@@ -4,7 +4,6 @@ import path from 'path';
import { BuildContext } from '../context';
import { Sentry } from '../sentry';
import { NpmCacheRegistryInstallError, isNpmCacheInstallFailure } from '../utils/npmCacheRegistry';
import { PackageManager, findPackagerRootDir } from '../utils/packageManager';
import { isUsingModernYarnVersion } from '../utils/project';
@@ -197,6 +196,10 @@ export function resolvePackagerDir(ctx: BuildContext<Job>): string {
return packagerRunDir;
}
function isNpmCacheInstallFailure(err: unknown, npmCacheUrl: string): boolean {
return getErrorOutput(err).includes(npmCacheUrl);
}
function isNpmCacheRegistryErrorLine(line: string, npmCacheUrl: string): boolean {
return (
line.includes(npmCacheUrl) &&
@@ -204,9 +207,24 @@ function isNpmCacheRegistryErrorLine(line: string, npmCacheUrl: string): boolean
);
}
function getErrorOutput(err: unknown): string {
if (!(err instanceof Error)) {
return '';
}
const { stdout, stderr } = err as Error & { stdout?: string; stderr?: string };
return [stdout, stderr].filter(Boolean).join('\n');
}
class NpmCacheRegistryNonFatalError extends SystemError {
override name = 'NpmCacheRegistryNonFatalError';
constructor() {
super('Non-fatal npm cache registry error during dependency install');
}
}
class NpmCacheRegistryInstallError extends SystemError {
override name = 'NpmCacheRegistryInstallError';
constructor(cause: unknown) {
super('Failed to install dependencies using npm cache registry', { cause });
}
}
+16 -5
View File
@@ -53,17 +53,19 @@ export async function parseJobHooksAsync<TJob extends BuildJob>(
const globalContext = new BuildStepGlobalContext(customBuildContext, false);
// Hook steps run in the checked-out project directory, not the pre-checkout
// target directory the global context defaults to.
globalContext.markAsCheckedOut();
globalContext.markAsCheckedOut(ctx.logger);
const externalFunctions = getEasFunctions(customBuildContext);
const externalFunctionGroups = getEasFunctionGroups(customBuildContext);
// Shape-validate every REGISTERED key, reachable or not (steps-world parity:
// a malformed `before_submit` fails a steps build, so it fails here too). An
// unknown key is inert — a key newer than this worker must be ignored, never
// fail the job.
// a malformed `before_submit` fails a steps build, so it fails here too), and
// warn on keys this build won't run. An unknown key is inert — a key newer
// than this worker must be ignored, never fail the job.
for (const [key, steps] of Object.entries(hooks)) {
if (parseHookKey(key) === null) {
const parsed = parseHookKey(key);
if (parsed === null) {
ctx.logger.warn(`Ignoring unknown hook key "${key}".`);
continue;
}
if (Array.isArray(steps) && steps.length === 0) {
@@ -79,6 +81,15 @@ export async function parseJobHooksAsync<TJob extends BuildJob>(
{ cause: err }
);
}
// Registered but not wrapped by this build: shape-checked above, then
// skipped. Function existence is deliberately NOT checked (that happens only
// during construction) — an unknown `uses:` under an unwrapped key warns
// rather than failing the build.
if (!wrappedAnchors.includes(parsed.anchorId)) {
ctx.logger.warn(
`Ignoring "hooks.${key}": this build does not run the "${parsed.anchorId}" step.`
);
}
}
// Construct entries for the wrapped anchors in EXECUTION order (per anchor,
@@ -5,13 +5,13 @@ import { asyncResult } from '@expo/results';
import spawn from '@expo/turtle-spawn';
import fs from 'fs/promises';
import { graphql } from 'gql.tada';
import fetch from 'node-fetch';
import nullthrows from 'nullthrows';
import path from 'path';
import { z } from 'zod';
import { shallowCloneRepositoryAsync } from './git';
import { BuildContext } from '../context';
import { uploadContentWithSignedUrl } from '../storage/uploadWithSignedUrl';
import { turtleFetch } from '../utils/turtleFetch';
export async function prepareProjectSourcesAsync<TJob extends Job>(
@@ -208,9 +208,10 @@ async function uploadProjectMetadataAsync(
headers: Record<string, string>;
};
await uploadContentWithSignedUrl({
signedUrl: uploadSession,
content: JSON.stringify({ archiveContent: sortedFiles }),
await fetch(uploadSession.url, {
method: 'PUT',
body: JSON.stringify({ archiveContent: sortedFiles }),
headers: uploadSession.headers,
});
const updateMetadataResult = await ctx.graphqlClient
+1 -6
View File
@@ -71,12 +71,7 @@ export async function setupAsync<TJob extends BuildJob>(
}
);
await setUpNpmrcAsync({
projectTargetDirectory: ctx.buildDirectory,
workingDirectory: ctx.getReactNativeProjectDirectory(),
env: ctx.env,
logger: ctx.logger,
});
await setUpNpmrcAsync(ctx, ctx.logger);
if (ctx.job.platform === Platform.IOS && ctx.env.EAS_BUILD_RUNNER === 'eas-build') {
await deleteXcodeEnvLocalIfExistsAsync(ctx as BuildContext<Ios.Job>);
}
+3 -6
View File
@@ -80,12 +80,9 @@ export class XcodeBuildLogger {
this.logger.info(data.toString());
}
});
try {
await this.logReaderPromise;
} finally {
// flush() interrupts tail, but the formatter may still hold a final compiler diagnostic.
this.logger.info(formatter.getBuildSummary());
}
await this.logReaderPromise;
this.logger.info(formatter.getBuildSummary());
} catch (err: any) {
if (!this.flushing) {
this.loggerError = err;
+4 -5
View File
@@ -4,11 +4,13 @@ import {
BuildPhaseResult,
Env,
EnvironmentSecretType,
GenericArtifactType,
Job,
LogMarker,
ManagedArtifactType,
Metadata,
errors,
isGenericArtifact,
} from '@expo/eas-build-job';
import { BuildTrigger } from '@expo/eas-build-job/dist/common';
import { bunyan } from '@expo/logger';
@@ -40,7 +42,7 @@ export type ArtifactToUpload =
paths: string[];
}
| {
type: null;
type: GenericArtifactType;
name: string;
paths: string[];
metadata?: Record<string, unknown>;
@@ -67,7 +69,6 @@ export interface BuildContextOptions {
metadata?: Metadata;
expoApiV2BaseUrl?: string;
mcpServerUrl?: string;
addBuildLogMask?: (secret: string) => void;
}
export class SkipNativeBuildError extends Error {}
@@ -85,7 +86,6 @@ export class BuildContext<TJob extends Job = Job> {
public readonly skipNativeBuild?: boolean;
public readonly expoApiV2BaseUrl?: string;
public readonly mcpServerUrl?: string;
public readonly addBuildLogMask?: (secret: string) => void;
public artifacts: Artifacts = {};
private readonly _isLocal: boolean;
@@ -115,7 +115,6 @@ export class BuildContext<TJob extends Job = Job> {
this.skipNativeBuild = options.skipNativeBuild;
this.expoApiV2BaseUrl = options.expoApiV2BaseUrl;
this.mcpServerUrl = options.mcpServerUrl;
this.addBuildLogMask = options.addBuildLogMask;
const environmentSecrets = this.getEnvironmentSecrets(job);
this._env = {
@@ -239,7 +238,7 @@ export class BuildContext<TJob extends Job = Job> {
logger: bunyan;
}): Promise<{ artifactId: string | null }> {
const result = await this._uploadArtifact({ artifact, logger });
if (result.filename && artifact.type !== null) {
if (result.filename && !isGenericArtifact(artifact)) {
this.artifacts[artifact.type] = result.filename;
}
return { artifactId: result.artifactId ?? null };
@@ -32,7 +32,6 @@ export interface BuilderRuntimeApi {
uploadArtifact: (spec: { artifact: ArtifactToUpload; logger: bunyan }) => Promise<{
artifactId: string | null;
}>;
addBuildLogMask?: (secret: string) => void;
}
export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuildContextProvider {
@@ -64,12 +63,12 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
public readonly logger: bunyan;
public readonly graphqlClient: Client;
public readonly runtimeApi: BuilderRuntimeApi;
public readonly expoApiV2BaseUrl?: string;
public readonly mcpServerUrl?: string;
public job: TJob;
public metadata?: Metadata;
private _env: Env;
private readonly expoApiV2BaseUrl?: string;
private readonly pendingMetricUploads: Promise<void>[] = [];
constructor(buildCtx: BuildContext<TJob>) {
@@ -85,7 +84,6 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
this.buildLogsDirectory = path.join(buildCtx.workingdir, 'logs');
this.runtimeApi = {
uploadArtifact: (...args) => buildCtx['uploadArtifact'](...args),
addBuildLogMask: buildCtx.addBuildLogMask,
};
this.mcpServerUrl = buildCtx.mcpServerUrl;
this.expoApiV2BaseUrl = buildCtx.expoApiV2BaseUrl;
+15 -42
View File
@@ -1,24 +1,15 @@
import { Sentry } from './sentry';
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from './steps/utils/deviceRunSessionWorkerApi';
import { turtleFetch } from './utils/turtleFetch';
type MetricsTarget =
| { kind: 'build'; turtleBuildId: string }
| { kind: 'jobRun'; turtleJobRunId: string }
// A device run session job with a session token. The token names the session and its job run.
| { kind: 'deviceRunSession' };
| { kind: 'jobRun'; turtleJobRunId: string };
type DatadogSetupOptions =
| {
expoApiV2BaseUrl: string;
robotAccessToken: string;
target: Exclude<MetricsTarget, { kind: 'deviceRunSession' }>;
}
| {
expoApiV2BaseUrl: string;
deviceRunSessionToken: string;
target: Extract<MetricsTarget, { kind: 'deviceRunSession' }>;
};
type DatadogSetupOptions = {
expoApiV2BaseUrl: string;
robotAccessToken: string;
target: MetricsTarget;
};
let setupOptions: DatadogSetupOptions | null = null;
let pendingUploads: Promise<unknown>[] = [];
@@ -27,30 +18,9 @@ function withTargetTag(
tags: Record<string, string>,
target: MetricsTarget
): Record<string, string> {
// A device run session job is a job run, so its metrics keep the job run tag.
return { ...tags, target: target.kind === 'build' ? 'build' : 'job_run' };
}
function getMetricsRequest(options: DatadogSetupOptions): {
path: string;
headers: Record<string, string>;
} {
if ('deviceRunSessionToken' in options) {
return {
path: 'device-run-session-worker/metrics',
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: options.deviceRunSessionToken },
};
}
const { target, robotAccessToken } = options;
return {
path:
target.kind === 'build'
? `turtle-builds/${target.turtleBuildId}/metrics`
: `turtle-job-runs/${target.turtleJobRunId}/metrics`,
headers: { Authorization: `Bearer ${robotAccessToken}` },
};
}
export const Datadog = {
setup(opts: DatadogSetupOptions | null): void {
setupOptions = opts;
@@ -60,7 +30,7 @@ export const Datadog = {
if (!setupOptions) {
return;
}
const { expoApiV2BaseUrl, target } = setupOptions;
const { expoApiV2BaseUrl, robotAccessToken, target } = setupOptions;
const metrics = [
{
name,
@@ -70,11 +40,16 @@ export const Datadog = {
},
];
const { path: metricsPath, headers } = getMetricsRequest(setupOptions);
const metricsPath =
target.kind === 'build'
? `turtle-builds/${target.turtleBuildId}/metrics`
: `turtle-job-runs/${target.turtleJobRunId}/metrics`;
const uploadPromise = turtleFetch(new URL(metricsPath, expoApiV2BaseUrl).toString(), 'POST', {
json: { metrics },
headers,
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
retries: 2,
}).catch(err => {
Sentry.capture(`Failed to report turtle ${target.kind} metric`, err, {
@@ -86,9 +61,7 @@ export const Datadog = {
},
log(message: string, tags: Record<string, string> = {}): void {
// The device run session worker API has no logs route, and the session token is not a bearer
// credential, so a device run session job does not report these logs.
if (!setupOptions || !('robotAccessToken' in setupOptions)) {
if (!setupOptions) {
return;
}
const { expoApiV2BaseUrl, robotAccessToken, target } = setupOptions;
+2 -12
View File
@@ -3,16 +3,8 @@ import HttpLogStream from './logging/HttpLogStream';
import RemoteLoggerStream from './logging/RemoteLoggerStream';
export { Builders, HttpLogStream, RemoteLoggerStream };
export {
SignedUrlUploadApiError,
SignedUrlUploadNetworkError,
uploadWithSignedUrl,
} from './storage/uploadWithSignedUrl';
export type {
SignedUrl,
UploadWithSignedUrlOptions,
UploadWithSignedUrlParams,
} from './storage/uploadWithSignedUrl';
export { uploadWithSignedUrl } from './storage/uploadWithSignedUrl';
export type { SignedUrl, UploadWithSignedUrlParams } from './storage/uploadWithSignedUrl';
export { downloadCacheAsync } from './steps/functions/restoreCache';
export { uploadCacheAsync } from './steps/functions/saveCache';
@@ -34,7 +26,6 @@ export { formatSecondsForLog } from './utils/formatDuration';
export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs';
export { Hook, runHookIfPresent } from './utils/hooks';
export { detectPnpmUnsupportedNodeVersionError } from './buildErrors/pnpmUnsupportedNodeVersionError';
export { parseGradleProfile, formatGradleProfileReport } from './android/gradleProfile';
export type { GradleProfileTask } from './android/gradleProfile';
@@ -42,6 +33,5 @@ export type { GradleProfileTask } from './android/gradleProfile';
export * from './generic';
export { Datadog } from './datadog';
export { getDeviceRunSessionTokenFromEnvironmentSecrets } from './steps/utils/deviceRunSessionWorkerApi';
export { Sentry } from './sentry';
export * from './runtimeSettings';
+4 -4
View File
@@ -15,10 +15,10 @@ export function resolveScheme(ctx: BuildContext<Ios.Job>): string {
return schemes[0];
}
export function resolveArtifactPath(job: Ios.Job): string {
if (job.applicationArchivePath) {
return job.applicationArchivePath;
} else if (job.simulator) {
export function resolveArtifactPath(ctx: BuildContext<Ios.Job>): string {
if (ctx.job.applicationArchivePath) {
return ctx.job.applicationArchivePath;
} else if (ctx.job.simulator) {
return 'ios/build/Build/Products/*simulator/*.app';
} else {
return 'ios/build/*.ipa';
@@ -56,10 +56,10 @@ class RemoteLoggerStream extends Writable {
return null;
}
public async init(): Promise<void> {
public async init(): Promise<string> {
this.fileHandle = await fs.open(this.temporaryLogsPath, 'w+', 0o660);
this.flushInterval = setInterval(() => this.flush(), this.options.uploadIntervalMs);
await this.flush(true);
return (await this.flush(true)) as string;
}
public async cleanUp(): Promise<void> {
@@ -121,18 +121,17 @@ class RemoteLoggerStream extends Writable {
}
}
private async flush(force = false): Promise<void> {
private async flush(force = false): Promise<string | void> {
if (force || this.hasChangesToUpload) {
if (this.uploadingPromise) {
await this.uploadingPromise;
await this.flush(force);
return;
return await this.flush(force);
}
await this.flushInternal();
return await this.flushInternal();
}
}
private async flushInternal(): Promise<void> {
private async flushInternal(): Promise<string | void> {
await this.safeWriteToFile();
let resolveFn: PromiseResolveFn;
@@ -140,17 +139,21 @@ class RemoteLoggerStream extends Writable {
this.uploadingPromise = new Promise(res => {
resolveFn = res;
});
await this.upload()
return await this.upload()
.then(result => {
return result;
})
.catch(err => {
this.logger.error({ err }, 'Failed to upload logs file');
})
.then(() => {
.then(result => {
this.uploadingPromise = undefined;
resolveFn();
return result;
});
}
private async upload(): Promise<void> {
private async upload(): Promise<string | void> {
if (!this.uploadMethod) {
return;
}
@@ -162,7 +165,7 @@ class RemoteLoggerStream extends Writable {
);
};
await uploadWithSignedUrl({
return await uploadWithSignedUrl({
signedUrl: this.uploadMethod.signedUrl,
srcGeneratorAsync,
});
@@ -11,7 +11,7 @@ import { getEasFunctions } from '../easFunctions';
jest.mock('fs');
describe('hook anchor self-declarations', () => {
it('declares the expected hook anchors', () => {
it('exactly the four v0 functions declare anchors, with these bindings', () => {
const declarations: Record<string, HookAnchorId> = {};
for (const buildFunction of getEasFunctions(createCustomBuildContextMock())) {
if (buildFunction.__hookId !== undefined) {
@@ -24,7 +24,6 @@ describe('hook anchor self-declarations', () => {
'eas/checkout': 'checkout',
'eas/install_node_modules': 'install_node_modules',
'eas/upload_to_asc': 'submit',
'eas/submit_to_google_play': 'submit',
'eas/maestro_tests': 'maestro_tests',
});
});
@@ -88,17 +87,6 @@ describe('hook anchor self-declarations', () => {
bundle_short_version: '1.0.0',
},
],
[
'eas/submit_to_google_play',
'before_submit',
'after_submit',
{
artifact_path: 'app.aab',
artifact_type: 'aab',
package_name: 'com.example.app',
service_account_key_path: 'key.json',
},
],
[
'eas/maestro_tests',
'before_maestro_tests',
@@ -1,18 +0,0 @@
import { type CustomBuildContext } from '../../customBuildContext';
import { getEasFunctions } from '../easFunctions';
it('registers opt-in session functions with the existing inputs', () => {
const functions = getEasFunctions({ hasBuildJob: () => false } as CustomBuildContext);
for (const name of ['serve_sim_remote', 'agent_device', 'argent_remote', 'appium_remote']) {
const existing = functions.find(fn => fn.id === `start_${name}_session`)!;
const warm = functions.find(fn => fn.id === `start_warm_${name}_session`)!;
expect(warm.getFullId()).toBe(`eas/start_warm_${name}_session`);
const warmInputs = warm.inputProviders!.map(input => input(undefined as never, 'warm').id);
const existingInputs = existing.inputProviders!.map(
input => input(undefined as never, 'existing').id
);
expect(warmInputs).toEqual(existingInputs);
}
const ids = functions.map(fn => fn.getFullId());
expect(new Set(ids).size).toBe(ids.length);
});
+2 -18
View File
@@ -10,7 +10,6 @@ import { configureIosVersionFunction } from './functions/configureIosVersion';
import { createSubmissionEntityFunction } from './functions/createSubmissionEntity';
import { createDownloadArtifactFunction } from './functions/downloadArtifact';
import { createDownloadBuildFunction } from './functions/downloadBuild';
import { createDownloadSubmissionArchiveFunction } from './functions/downloadSubmissionArchive';
import { createEasDeployBuildFunction } from './functions/deploy';
import { createEasExportBuildFunction } from './functions/export';
import { eagerBundleBuildFunction } from './functions/eagerBundle';
@@ -28,12 +27,9 @@ import { createLaunchApplicationFunction } from './functions/launchApplication';
import { createPrebuildBuildFunction } from './functions/prebuild';
import { createReadAppConfigBuildFunction } from './functions/readAppConfig';
import { createReadIpaInfoBuildFunction } from './functions/readIpaInfo';
import { createReadAndroidAppInfoBuildFunction } from './functions/readAndroidAppInfo';
import { createSubmitToGooglePlayBuildFunction } from './functions/submitToGooglePlay';
import { createReadPackageJsonBuildFunction } from './functions/readPackageJson';
import { createRepackBuildFunction } from './functions/repack';
import { createReportMaestroTestResultsFunction } from './functions/reportMaestroTestResults';
import { createUpdateResolvedVersionBuildFunction } from './functions/updateResolvedVersion';
import { resolveAppleTeamIdFromCredentialsFunction } from './functions/resolveAppleTeamIdFromCredentials';
import { createResolveBuildConfigBuildFunction } from './functions/resolveBuildConfig';
import {
@@ -47,9 +43,9 @@ import { runGradleFunction } from './functions/runGradle';
import { createMaestroTestsBuildFunction } from './functions/maestroTests';
import { createSaveBuildCacheFunction } from './functions/saveBuildCache';
import { createRolloutPosthogFlagFunction } from './functions/rolloutPosthogFlag';
import { createRunAgentBuildFunction } from './functions/runAgent';
import { createSaveCacheFunction } from './functions/saveCache';
import { createSendSlackMessageFunction } from './functions/sendSlackMessage';
import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/startAgentDeviceRemoteSession';
import { createStartAgentDeviceSessionBuildFunction } from './functions/startAgentDeviceSession';
import { createStartAndroidEmulatorBuildFunction } from './functions/startAndroidEmulator';
import { createStartArgentRemoteSessionBuildFunction } from './functions/startArgentRemoteSession';
@@ -62,12 +58,10 @@ import { createStartWebPreviewRemoteSessionBuildFunction } from './functions/sta
import { createStartServeSimMetricsBuildFunction } from './functions/startServeSimMetrics';
import { createStartServeSimLogsBuildFunction } from './functions/startServeSimLogs';
import { createCollectServeSimLogsBuildFunction } from './functions/collectServeSimLogs';
import { createCollectAndroidEmulatorLogsBuildFunction } from './functions/collectAndroidEmulatorLogs';
import { createStartSandboxBuildFunction } from './functions/startSandbox';
import { createCollectServeSimMetricsBuildFunction } from './functions/collectServeSimMetrics';
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
import { createUploadDeviceRunSessionScreenRecordingsBuildFunction } from './functions/uploadDeviceRunSessionScreenRecordings';
import { createUploadEmbeddedBundleBuildFunction } from './functions/uploadEmbeddedBundle';
import { createUploadToAscBuildFunction } from './functions/uploadToAsc';
import { createUpdateTestFlightMetadataBuildFunction } from './functions/updateTestFlightMetadata';
import { createSetUpNpmrcBuildFunction } from './functions/useNpmToken';
@@ -88,9 +82,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createInstallNodeModulesBuildFunction(),
createPrebuildBuildFunction(),
createReadIpaInfoBuildFunction(),
createReadAndroidAppInfoBuildFunction(),
createDownloadBuildFunction(ctx),
createDownloadSubmissionArchiveFunction(ctx),
createInstallBuildFunction(),
createLaunchApplicationFunction(),
createEasExportBuildFunction(),
@@ -112,12 +104,10 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
generateGymfileFromTemplateFunction(),
runFastlaneFunction(),
parseXcactivitylogFunction(),
createStartAgentDeviceRemoteSessionBuildFunction(ctx),
createStartAgentDeviceSessionBuildFunction(ctx),
createStartAgentDeviceSessionBuildFunction(ctx, { warm: true }),
createStartArgentRemoteSessionBuildFunction(ctx),
createStartArgentRemoteSessionBuildFunction(ctx, { warm: true }),
createStartAppiumRemoteSessionBuildFunction(ctx),
createStartAppiumRemoteSessionBuildFunction(ctx, { warm: true }),
createStartAndroidEmulatorBuildFunction(),
createStartCuttlefishDeviceBuildFunction(),
createStartIosSimulatorBuildFunction(),
@@ -126,13 +116,10 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createFinishIosSimulatorRecordingsBuildFunction(),
createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx),
createStartWebPreviewRemoteSessionBuildFunction(ctx),
createStartWebPreviewRemoteSessionBuildFunction(ctx, { warm: true }),
createStartServeSimMetricsBuildFunction(),
createStartServeSimLogsBuildFunction(),
createCollectServeSimLogsBuildFunction(ctx),
createCollectAndroidEmulatorLogsBuildFunction(ctx),
createStartSandboxBuildFunction(ctx),
createRunAgentBuildFunction(ctx),
createCollectServeSimMetricsBuildFunction(ctx),
createInstallMaestroBuildFunction(),
createInstallMitmproxyBuildFunction(),
@@ -149,7 +136,6 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
calculateEASUpdateRuntimeVersionFunction(),
createSubmissionEntityFunction(),
createSubmitToGooglePlayBuildFunction(),
createUploadToAscBuildFunction(),
createUpdateTestFlightMetadataBuildFunction(),
@@ -161,8 +147,6 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
functions.push(
...[
createFindAndUploadBuildArtifactsBuildFunction(ctx),
createUpdateResolvedVersionBuildFunction(ctx),
createUploadEmbeddedBundleBuildFunction(ctx),
createResolveBuildConfigBuildFunction(ctx),
createGetCredentialsForBuildTriggeredByGithubIntegration(ctx),
]
@@ -130,35 +130,6 @@ describe(createEasBuildBuildFunctionGroup, () => {
);
});
it.each([
['Android without credentials', { platform: Platform.ANDROID }],
['Android with credentials', { platform: Platform.ANDROID, buildCredentials: { test: {} } }],
['iOS simulator', { platform: Platform.IOS, simulator: true }],
['iOS with credentials', { platform: Platform.IOS, buildCredentials: { test: {} } }],
])('wires the uploaded archive path into the update step (%s)', (_label, overrides) => {
const buildToolsContext = createMockBuildToolsContext(overrides);
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx);
const updateStep = steps.find(s => s.displayName === 'Update resolved version');
const archivePathInput = updateStep?.inputs?.find(
input => input.id === 'application_archive_path'
);
expect(updateStep).toBeDefined();
expect(archivePathInput?.rawValue).toBe(
'${ steps.find_and_upload_build_artifacts.application_archive_path }'
);
// The step ID must be assigned explicitly: an auto-generated ID makes the input
// interpolation throw "Step ... does not exist" before the function body runs.
const uploadStepIndex = steps.findIndex(s => s.id === 'find_and_upload_build_artifacts');
expect(uploadStepIndex).toBeGreaterThan(-1);
expect(steps[uploadStepIndex].displayName).toBe('Find and upload build artifacts');
expect(uploadStepIndex).toBeLessThan(steps.indexOf(updateStep!));
});
it('composes working directory with installPods step-level ./ios dir (iOS)', () => {
const buildToolsContext = createMockBuildToolsContext({
platform: Platform.IOS,
@@ -248,71 +219,4 @@ describe(createEasBuildBuildFunctionGroup, () => {
'Build function group is not supported in generic jobs.'
);
});
describe('embedded bundle upload', () => {
function getStepNames(
options: Parameters<typeof createMockBuildToolsContext>[0],
{ sdkVersion, env = {} }: { sdkVersion?: string; env?: Record<string, string> }
): string[] {
const functionGroup = createEasBuildBuildFunctionGroup(createMockBuildToolsContext(options));
const globalCtx = createGlobalContextMock({
logger: createMockLogger(),
staticContextContent: { metadata: sdkVersion ? { sdkVersion } : null },
});
globalCtx.updateEnv(env);
return functionGroup
.createBuildStepsFromFunctionGroupCall(globalCtx)
.map(step => step.displayName);
}
it.each([
['Android', { platform: Platform.ANDROID }],
['Android with credentials', { platform: Platform.ANDROID, buildCredentials: { test: {} } }],
['iOS with credentials', { platform: Platform.IOS, buildCredentials: { test: {} } }],
])('uploads the embedded bundle after the build artifacts on SDK 58 (%s)', (_, options) => {
const stepNames = getStepNames(options, { sdkVersion: '58.0.0' });
expect(stepNames.indexOf('Upload embedded bundle')).toBe(
stepNames.indexOf('Find and upload build artifacts') + 1
);
});
it.each([
['Android', { platform: Platform.ANDROID }],
['Android with credentials', { platform: Platform.ANDROID, buildCredentials: { test: {} } }],
['iOS with credentials', { platform: Platform.IOS, buildCredentials: { test: {} } }],
])('passes ignore_error to the embedded bundle step (%s)', (_, options) => {
const functionGroup = createEasBuildBuildFunctionGroup(createMockBuildToolsContext(options));
const globalCtx = createGlobalContextMock({
logger: createMockLogger(),
staticContextContent: { metadata: { sdkVersion: '58.0.0' } },
});
const uploadStep = functionGroup
.createBuildStepsFromFunctionGroupCall(globalCtx)
.find(step => step.displayName === 'Upload embedded bundle');
expect(uploadStep).toBeDefined();
expect(uploadStep!.inputs?.find(input => input.id === 'ignore_error')?.rawValue).toBe(true);
});
it('does not upload the embedded bundle on SDK 57 without the opt-in', () => {
expect(getStepNames({}, { sdkVersion: '57.0.0' })).not.toContain('Upload embedded bundle');
});
it('uploads the embedded bundle on SDK 57 with the opt-in', () => {
expect(
getStepNames(
{},
{ sdkVersion: '57.0.0', env: { EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE: '1' } }
)
).toContain('Upload embedded bundle');
});
it('does not upload the embedded bundle for iOS simulator builds', () => {
expect(
getStepNames({ platform: Platform.IOS, simulator: true }, { sdkVersion: '58.0.0' })
).not.toContain('Upload embedded bundle');
});
});
});
@@ -10,7 +10,6 @@ import path from 'path';
import { shouldUseEagerBundle } from '../../common/eagerBundle';
import { CustomBuildContext } from '../../customBuildContext';
import { shouldUploadEmbeddedBundle } from '../../utils/expoUpdatesEmbedded';
import { calculateEASUpdateRuntimeVersionFunction } from '../functions/calculateEASUpdateRuntimeVersion';
import { createCheckoutBuildFunction } from '../functions/checkout';
import { configureAndroidVersionFunction } from '../functions/configureAndroidVersion';
@@ -19,7 +18,6 @@ import { configureIosCredentialsFunction } from '../functions/configureIosCreden
import { configureIosVersionFunction } from '../functions/configureIosVersion';
import { eagerBundleBuildFunction } from '../functions/eagerBundle';
import { createFindAndUploadBuildArtifactsBuildFunction } from '../functions/findAndUploadBuildArtifacts';
import { createUpdateResolvedVersionBuildFunction } from '../functions/updateResolvedVersion';
import { generateGymfileFromTemplateFunction } from '../functions/generateGymfileFromTemplate';
import { injectAndroidCredentialsFunction } from '../functions/injectAndroidCredentials';
import { createInstallNodeModulesBuildFunction } from '../functions/installNodeModules';
@@ -34,7 +32,6 @@ import {
import { runFastlaneFunction } from '../functions/runFastlane';
import { runGradleFunction } from '../functions/runGradle';
import { createSaveBuildCacheFunction } from '../functions/saveBuildCache';
import { createUploadEmbeddedBundleBuildFunction } from '../functions/uploadEmbeddedBundle';
import { createSetUpNpmrcBuildFunction } from '../functions/useNpmToken';
interface HelperFunctionsInput {
@@ -184,20 +181,7 @@ function createStepsForIosSimulatorBuild({
runFastlane,
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, {
id: 'find_and_upload_build_artifacts',
workingDirectory,
}),
createUpdateResolvedVersionBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
application_archive_path:
'${ steps.find_and_upload_build_artifacts.application_archive_path }',
},
}
),
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
@@ -318,33 +302,7 @@ function createStepsForIosBuildWithCredentials({
runFastlane,
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, {
id: 'find_and_upload_build_artifacts',
workingDirectory,
}),
...(shouldUploadEmbeddedBundle({
env: globalCtx.env,
metadata: globalCtx.staticContext.metadata,
})
? [
createUploadEmbeddedBundleBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: { ignore_error: true },
}),
]
: []),
createUpdateResolvedVersionBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
application_archive_path:
'${ steps.find_and_upload_build_artifacts.application_archive_path }',
},
}
),
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
@@ -431,33 +389,7 @@ function createStepsForAndroidBuildWithoutCredentials({
runGradle,
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, {
id: 'find_and_upload_build_artifacts',
workingDirectory,
}),
...(shouldUploadEmbeddedBundle({
env: globalCtx.env,
metadata: globalCtx.staticContext.metadata,
})
? [
createUploadEmbeddedBundleBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: { ignore_error: true },
}),
]
: []),
createUpdateResolvedVersionBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
application_archive_path:
'${ steps.find_and_upload_build_artifacts.application_archive_path }',
},
}
),
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
@@ -550,33 +482,7 @@ function createStepsForAndroidBuildWithCredentials({
: []),
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, {
id: 'find_and_upload_build_artifacts',
workingDirectory,
}),
...(shouldUploadEmbeddedBundle({
env: globalCtx.env,
metadata: globalCtx.staticContext.metadata,
})
? [
createUploadEmbeddedBundleBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: { ignore_error: true },
}),
]
: []),
createUpdateResolvedVersionBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
application_archive_path:
'${ steps.find_and_upload_build_artifacts.application_archive_path }',
},
}
),
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
@@ -1,47 +0,0 @@
import { UserError } from '@expo/eas-build-job';
import path from 'node:path';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { readAndroidArtifactInfoAsync } from '../../utils/android/appArtifact';
import { createReadAndroidAppInfoBuildFunction } from '../readAndroidAppInfo';
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
jest.unmock('@expo/spawn-async');
const fixturesDirectory = path.join(__dirname, 'fixtures/android-app-info');
it.each(['apk', 'aab'])('reads a real %s and publishes build step outputs', async type => {
const step = createReadAndroidAppInfoBuildFunction().createBuildStepFromFunctionCall(
createGlobalContextMock({
projectTargetDirectory: fixturesDirectory,
staticContextContent: { job: {} },
}),
{ callInputs: { artifact_path: `app.${type}` } }
);
await step.executeAsync();
expect(step.outputById.artifact_type.value).toBe(type);
expect(step.outputById.package_name.value).toBe('com.example.app');
});
it('reports an invalid ZIP as a user error', async () => {
await expect(readAndroidArtifactInfoAsync(__filename)).rejects.toMatchObject({
constructor: UserError,
errorCode: 'EAS_READ_ANDROID_APP_INFO_INVALID_ARTIFACT',
cause: expect.any(Error),
});
});
it.each(['apk', 'aab'])('retains the native tool error for an invalid %s manifest', async type => {
await expect(
readAndroidArtifactInfoAsync(path.join(fixturesDirectory, `invalid-manifest-${type}.zip`))
).rejects.toMatchObject({
constructor: UserError,
errorCode: 'EAS_READ_ANDROID_APP_INFO_INVALID_MANIFEST',
cause: expect.any(Error),
});
});
@@ -48,10 +48,7 @@ describe(createCheckoutBuildFunction, () => {
});
it('moves project sources to the target directory when no inputs are given', async () => {
const logger = createMockLogger();
jest.mocked(logger.child).mockReturnValue(logger);
const { globalCtx, executeAsync } = createCheckoutStep({
logger,
projectArchive: GIT_PROJECT_ARCHIVE,
});
@@ -59,9 +56,6 @@ describe(createCheckoutBuildFunction, () => {
expect(await vol.promises.readFile(`${projectTargetDirectory}/app.json`, 'utf8')).toBe('{}');
expect(globalCtx.wasCheckedOut()).toBe(true);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Changing default working directory to')
);
expect(fetchAndCheckoutRefAsync).not.toHaveBeenCalled();
});
@@ -102,11 +96,14 @@ describe(createCheckoutBuildFunction, () => {
});
it('rejects the ref input when the project is already checked out', async () => {
const logger = createMockLogger();
jest.mocked(logger.child).mockReturnValue(logger);
const { globalCtx, executeAsync } = createCheckoutStep({
logger,
projectArchive: GIT_PROJECT_ARCHIVE,
callInputs: { ref: 'feature/add-icon' },
});
globalCtx.markAsCheckedOut();
globalCtx.markAsCheckedOut(logger);
await expect(executeAsync()).rejects.toMatchObject({
errorCode: 'EAS_CHECKOUT_REF_AFTER_CHECKOUT',
@@ -1,48 +0,0 @@
import { BuildRuntimePlatform } from '@expo/steps';
import {
createCustomBuildContextMock,
createGlobalContextMock,
} from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { Sentry } from '../../../sentry';
import { collectAndroidEmulatorLogsAsync } from '../../utils/androidEmulatorLogs';
import { createCollectAndroidEmulatorLogsBuildFunction } from '../collectAndroidEmulatorLogs';
jest.mock('../../../sentry');
jest.mock('../../utils/androidEmulatorLogs');
function createStep(callInputs: Record<string, unknown> = {}) {
const logger = createMockLogger();
const ctx = createCustomBuildContextMock();
const global = createGlobalContextMock({ logger });
const fn = createCollectAndroidEmulatorLogsBuildFunction(ctx);
const step = fn.createBuildStepFromFunctionCall(global, {
callInputs,
env: { DEVICE_RUN_SESSION_ID: 'session' },
});
return { ctx, global, logger, fn, step };
}
it('collects captured logs without a second source selection', async () => {
const { ctx, global, fn, step } = createStep();
expect(fn.supportedRuntimePlatforms).toEqual([BuildRuntimePlatform.LINUX]);
await step.executeAsync();
expect(collectAndroidEmulatorLogsAsync).toHaveBeenCalledWith(ctx, {
global,
deviceRunSessionId: 'session',
logger: expect.anything(),
});
});
it('reports collection failure without changing the session result', async () => {
jest
.mocked(collectAndroidEmulatorLogsAsync)
.mockRejectedValueOnce(new Error('collection failed'));
const { step } = createStep();
await expect(step.executeAsync()).resolves.toBeUndefined();
expect(Sentry.capture).toHaveBeenCalledWith(
'Could not collect Android emulator logs',
expect.any(Error)
);
});
@@ -2,12 +2,10 @@ import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import type { CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
import { turtleFetch } from '../../../utils/turtleFetch';
import { pollAgentDeviceArtifactsForUploadAsync } from '../../utils/agentDeviceArtifacts';
import { startAgentDeviceEventCollectionAsync } from '../../utils/agentDeviceEvents';
import { startAppiumEventCollectionAsync } from '../../utils/appiumEvents';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
@@ -18,7 +16,7 @@ import {
waitForDeviceRunSessionStoppedAsync,
waitForFileAsync,
} from '../../utils/remoteDeviceRunSession';
import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceSession';
import { createStartAgentDeviceRemoteSessionBuildFunction } from '../startAgentDeviceRemoteSession';
import { createStartAppiumRemoteSessionBuildFunction } from '../startAppiumRemoteSession';
jest.mock('@expo/turtle-spawn');
@@ -29,14 +27,6 @@ jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/agentDeviceEvents');
jest.mock('../../utils/appiumEvents');
jest.mock('../../utils/agentDeviceArtifacts');
// The agent-device session step boots the emulator itself; this test covers the session host.
jest.mock('../startAndroidEmulator', () => ({
startAndroidEmulatorAsync: jest.fn().mockResolvedValue({
serialId: 'emulator-5554',
emulatorPromise: Promise.resolve(),
shouldAdjustAnimationScale: true,
}),
}));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
spawnDetached: jest.fn(),
@@ -46,26 +36,6 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
waitForFileAsync: jest.fn(),
}));
// Agent-device also polls for Stop while it starts up. That watch sets neither an idle timeout nor
// a duration; keep it pending until startup retires it, and give each lifetime wait its own mock.
const lifetimeWait = jest.fn<
ReturnType<typeof waitForDeviceRunSessionStoppedAsync>,
Parameters<typeof waitForDeviceRunSessionStoppedAsync>
>();
function routeStartupWatch(): void {
lifetimeWait.mockReset();
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async args => {
if ('idleTimeout' in args || 'maxDurationSeconds' in args) {
return await lifetimeWait(args);
}
if (!args.signal?.aborted) {
await new Promise<void>(resolve =>
args.signal?.addEventListener('abort', () => resolve(), { once: true })
);
}
});
}
const finishHost = jest.fn();
const closePreview = jest.fn();
const stopTool = jest.fn();
@@ -76,19 +46,8 @@ const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnTh
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(pollAgentDeviceArtifactsForUploadAsync).mockResolvedValue();
routeStartupWatch();
// The daemon package is external. No installation or daemon process runs in these tests.
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
// Only the emulator logcat folder: the Appium step writes into its own temp folder.
const mkdtemp = fs.promises.mkdtemp.bind(fs.promises);
jest
.spyOn(fs.promises, 'mkdtemp')
.mockImplementation(async prefix =>
String(prefix).includes('eas-android-emulator-logcat-')
? '/tmp/eas-android-emulator-logcat-test'
: mkdtemp(prefix)
);
jest.mocked(spawn).mockResolvedValue({ stdout: '{}' } as never);
jest
.mocked(AndroidEmulatorUtils.getAttachedDevicesAsync)
@@ -103,11 +62,9 @@ beforeEach(() => {
apiUrl: 'https://preview.example.test',
closeAsync: closePreview,
});
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: openPreview,
getLastPreviewActivityAt: jest.fn(),
finishAsync: finishHost,
});
jest
.mocked(startDeviceSessionHostAsync)
.mockResolvedValue({ openPreviewAsync: openPreview, finishAsync: finishHost });
jest.mocked(spawnDetached).mockReturnValue({
pid: undefined,
getOutput: () => '',
@@ -122,8 +79,8 @@ beforeEach(() => {
const events = { stopAsync: stopEvents, getLastEventObservedAt: () => undefined };
jest.mocked(startAgentDeviceEventCollectionAsync).mockResolvedValue(events);
jest.mocked(startAppiumEventCollectionAsync).mockResolvedValue(events);
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
lifetimeWait.mockResolvedValue(undefined);
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
});
afterEach(() => {
@@ -132,38 +89,20 @@ afterEach(() => {
describe.each([
['Appium', createStartAppiumRemoteSessionBuildFunction],
['Agent Device', createStartAgentDeviceSessionBuildFunction],
['Agent Device', createStartAgentDeviceRemoteSessionBuildFunction],
] as const)('%s host ownership', (name, createFunction) => {
async function runAsync(
publicPreview?: boolean,
frameAncestors?: string[],
corsOrigins?: string[]
) {
async function runAsync() {
const fn = createFunction({} as CustomBuildContext);
const global = createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX });
const defaults = new Map(
fn.inputProviders?.map(provider => {
const input = provider(global, 'Test session');
return [input.id, input.defaultValue];
})
);
await fn.fn!(
{
logger,
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
// Match step execution: omitted inputs use their declared defaults.
inputs: new Proxy(
{
public_preview: { value: publicPreview },
frame_ancestors: { value: frameAncestors },
cors_origins: { value: corsOrigins },
} as Record<string, { value: unknown }>,
{
get: (target, id: string) => target[id] ?? { value: defaults.get(id) },
}
),
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {
DEVICE_RUN_SESSION_ID: 'session-id',
@@ -176,11 +115,7 @@ describe.each([
it('holds the host until session completion', async () => {
await runAsync();
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ preview: { baseDomain: 'example.test', authtoken: 'token' } })
);
expect(openPreview).toHaveBeenCalledWith();
expect(openPreview).toHaveBeenCalledWith({ baseDomain: 'example.test' });
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: expect.objectContaining({
@@ -192,66 +127,13 @@ describe.each([
expect(finishHost).toHaveBeenCalledTimes(1);
expect(closePreview).not.toHaveBeenCalled();
expect(finishHost.mock.invocationCallOrder[0]).toBeGreaterThan(
lifetimeWait.mock.invocationCallOrder[0]
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
);
expect(stopTool).toHaveBeenCalledTimes(1);
expect(stopEvents).toHaveBeenCalledTimes(1);
expect(stopTunnel).toHaveBeenCalledTimes(1);
});
it('opens tunnels with the ngrok token but keeps it out of spawned processes', async () => {
await runAsync();
expect(startNgrokTunnelAsync).toHaveBeenCalledWith(
expect.objectContaining({ authtoken: 'token' })
);
const spawnedEnvs = [
...jest.mocked(spawn).mock.calls.map(([, , options]) => options?.env),
...jest.mocked(spawnDetached).mock.calls.map(([options]) => options.env),
...jest.mocked(startDeviceSessionHostAsync).mock.calls.map(([, options]) => options.env),
];
expect(spawnedEnvs.length).toBeGreaterThan(0);
for (const env of spawnedEnvs) {
expect(env).toEqual(expect.objectContaining({ DEVICE_RUN_SESSION_ID: 'session-id' }));
expect(env).not.toHaveProperty('NGROK_AUTHTOKEN');
}
});
it.each([undefined, true, false])('maps public_preview=%s to the host', async publicPreview => {
await runAsync(publicPreview);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ disableShare: publicPreview === false })
);
});
it('maps frame_ancestors to the host and defaults to none', async () => {
await runAsync(undefined, ['https://embed.example.test']);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ frameAncestors: ['https://embed.example.test'] })
);
jest.mocked(startDeviceSessionHostAsync).mockClear();
await runAsync();
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ frameAncestors: [] })
);
});
it('maps cors_origins to the host and defaults to none', async () => {
await runAsync(undefined, undefined, ['https://app.example.test']);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ corsOrigins: ['https://app.example.test'] })
);
jest.mocked(startDeviceSessionHostAsync).mockClear();
await runAsync();
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ corsOrigins: [] })
);
});
it('stops automation while recording finalization or upload is pending', async () => {
let release!: () => void;
let stopped!: () => void;
@@ -337,7 +219,7 @@ describe.each([
} else if (phase === 'config') {
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValueOnce(error);
} else {
lifetimeWait.mockRejectedValueOnce(error);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValueOnce(error);
}
await expect(runAsync()).rejects.toBe(error);
expect(finishHost).toHaveBeenCalledTimes(1);
@@ -361,81 +243,3 @@ describe.each([
);
});
});
describe('Appium ffmpeg setup', () => {
async function runAppiumAsync(): Promise<void> {
const fn = createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext);
await fn.fn!(
{
logger,
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: new Proxy({} as Record<string, { value: unknown }>, {
get: (target, id: string) => target[id] ?? { value: undefined },
}),
outputs: {},
env: {
DEVICE_RUN_SESSION_ID: 'session-id',
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'example.test',
NGROK_AUTHTOKEN: 'token',
},
} as never
);
}
function holdFfmpegInstall({ appiumError }: { appiumError?: Error } = {}) {
let markStarted!: () => void;
const started = new Promise<void>(resolve => {
markStarted = resolve;
});
let finish!: (outcome: 'installed' | 'failed') => void;
const install = new Promise((resolve, reject) => {
finish = outcome =>
outcome === 'installed' ? resolve({}) : reject(new Error('apt-get failed'));
});
jest.mocked(spawn).mockImplementation(((command: string, args: string[]) => {
if (command === 'ffmpeg') {
return Promise.reject(new Error('ffmpeg missing'));
}
if (args.includes('install') && args.includes('ffmpeg')) {
markStarted();
return install;
}
return command === 'npm' && appiumError
? Promise.reject(appiumError)
: Promise.resolve({ stdout: '{}' });
}) as never);
return { finish, started };
}
it.each(['installed', 'failed'] as const)(
'starts Appium after the ffmpeg install settles (%s)',
async outcome => {
const { finish, started } = holdFfmpegInstall();
const run = runAppiumAsync();
await started;
expect(spawnDetached).not.toHaveBeenCalled();
finish(outcome);
await run;
expect(spawnDetached).toHaveBeenCalledTimes(1);
}
);
it('waits for the ffmpeg install before failing on an Appium install error', async () => {
const appiumError = new Error('npm failed');
const { finish, started } = holdFfmpegInstall({ appiumError });
let settled = false;
const run = runAppiumAsync().finally(() => {
settled = true;
});
await started;
expect(settled).toBe(false);
finish('installed');
await expect(run).rejects.toBe(appiumError);
});
});
@@ -11,7 +11,6 @@ import * as tar from 'tar';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { Datadog } from '../../../datadog';
import * as files from '../../../utils/files';
import { createDownloadBuildFunction, downloadBuildAsync } from '../downloadBuild';
@@ -31,17 +30,11 @@ const APPLICATION_ARCHIVE_URL = `https://expo.dev/artifacts/eas/${randomUUID()}.
function createMockGraphqlClient({
applicationArchiveUrl,
error,
transientError,
}: {
applicationArchiveUrl?: string | null;
error?: Error;
transientError?: Error;
}): Client {
const toPromise = jest.fn();
if (transientError) {
toPromise.mockResolvedValueOnce({ error: transientError, data: undefined });
}
toPromise.mockResolvedValue(
const toPromise = jest.fn().mockResolvedValue(
error
? { error, data: undefined }
: {
@@ -69,7 +62,7 @@ function createSuccessfulResponse({
url,
headers,
}: {
body: Buffer | Readable;
body: Buffer;
url: string;
headers?: Record<string, string>;
}): Response {
@@ -79,7 +72,7 @@ function createSuccessfulResponse({
return {
ok: true,
body: body instanceof Readable ? body : Readable.from(body),
body: Readable.from(body),
url,
headers: {
get: (name: string): string | null => normalizedHeaders[name.toLowerCase()] ?? null,
@@ -87,47 +80,6 @@ function createSuccessfulResponse({
} as unknown as Response;
}
function createUndiciNetworkError(code: string): CombinedError {
return new CombinedError({
networkError: Object.assign(new TypeError('fetch failed'), {
cause: Object.assign(new Error(`read ${code}`), { code }),
}),
});
}
const { FetchError: ActualFetchError } = jest.requireActual(
'node-fetch'
) as typeof import('node-fetch');
function createSocketFetchError(url: string, code: string): Error {
return new ActualFetchError(`request to ${url} failed, reason: connect ${code}`, 'system', {
code,
} as any);
}
function useFakeBackoffTimers(): void {
// Streams and fs callbacks need the real nextTick and setImmediate; only the backoff timers are faked.
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
}
const MAX_IO_TURNS_BEFORE_BACKOFF = 1000;
async function waitForBackoffTimerAsync(): Promise<void> {
for (let turns = 0; jest.getTimerCount() === 0; turns++) {
if (turns === MAX_IO_TURNS_BEFORE_BACKOFF) {
throw new Error('No backoff timer was scheduled.');
}
await new Promise(resolve => setImmediate(resolve));
}
}
async function fireBackoffTimersAsync(count: number): Promise<void> {
for (let i = 0; i < count; i++) {
await waitForBackoffTimerAsync();
await jest.advanceTimersToNextTimerAsync();
}
}
async function createFlatAppTarGzBufferAsync(): Promise<Buffer> {
const sourceDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'flat-app-source-'));
const archivePath = path.join(os.tmpdir(), `${randomUUID()}.tar.gz`);
@@ -156,65 +108,7 @@ async function createFlatAppTarGzBufferAsync(): Promise<Buffer> {
}
}
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
describe('downloadBuild', () => {
it.each([404, 410])('identifies the unavailable build on HTTP %s', async status => {
const buildId = randomUUID();
const { Response: ActualResponse } =
jest.requireActual<typeof import('node-fetch')>('node-fetch');
jest
.mocked(fetch)
.mockResolvedValue(new ActualResponse('<Error>NoSuchKey</Error>', { status }));
await expect(
downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId,
graphqlClient: createMockGraphqlClient({ applicationArchiveUrl: APPLICATION_ARCHIVE_URL }),
robotAccessToken: null,
extensions: ['app'],
})
).rejects.toMatchObject({
errorCode: 'EAS_DOWNLOAD_BUILD_ARCHIVE_UNAVAILABLE',
message:
`Failed to download the application archive for build ${buildId} (HTTP ${status}). ` +
'The archive is unavailable. Check that the archive still exists and try again.',
});
expect(fetch).toHaveBeenCalledTimes(1);
});
it.each([404, 410, 500])(
'identifies the application URL without credentials on HTTP %s',
async status => {
const { Response: ActualResponse } =
jest.requireActual<typeof import('node-fetch')>('node-fetch');
jest.mocked(fetch).mockResolvedValue(new ActualResponse('download failed', { status }));
const download = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl:
'https://user:[email protected]/app.tar.gz?token=secret#fragment',
graphqlClient: createMockGraphqlClient({}),
robotAccessToken: null,
extensions: ['app'],
});
const prefix = `Failed to download the archive at https://example.test/app.tar.gz (HTTP ${status})`;
await expect(download).rejects.toThrow(
status === 500
? `${prefix}: download failed`
: `${prefix}. The archive is unavailable. ` +
'Check that the URL is correct and the archive still exists, then try again.'
);
await expect(download).rejects.not.toThrow(/password|secret|fragment/);
expect(fetch).toHaveBeenCalledTimes(1);
}
);
it('downloads from applicationArchiveUrl returned by GraphQL', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
@@ -571,13 +465,13 @@ describe('downloadBuild', () => {
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('throws SystemError with the cause code after retrying a failing build lookup', async () => {
useFakeBackoffTimers();
it('throws SystemError when GraphQL request fails with a network error', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
error: createUndiciNetworkError('ECONNRESET'),
error: new CombinedError({
networkError: new Error('Network request failed'),
}),
});
const datadogLog = jest.spyOn(Datadog, 'log');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
@@ -586,81 +480,19 @@ describe('downloadBuild', () => {
robotAccessToken: null,
extensions: ['app'],
});
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
await fireBackoffTimersAsync(5);
await rejection;
await expect(promise).rejects.toBeInstanceOf(SystemError);
await expect(promise).rejects.toMatchObject({
message: `Could not fetch build ${buildId}: [Network] fetch failed (ECONNRESET)`,
});
expect(graphqlClient.query).toHaveBeenCalledTimes(6);
expect(datadogLog).toHaveBeenCalledWith('download_build.build_lookup failed on attempt 6', {
event: 'network_retry',
operation: 'download_build.build_lookup',
outcome: 'failed',
attempts: '6',
code: 'ECONNRESET',
message: `Could not fetch build ${buildId}: [Network] Network request failed`,
});
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it.each([
['a network error', createUndiciNetworkError('ECONNRESET'), 'ECONNRESET'],
[
'a transient GraphQL error',
new CombinedError({
graphQLErrors: [
{
message: 'Too many requests',
extensions: { isTransient: true, errorCode: 'TOO_MANY_REQUESTS' },
},
],
response: { status: 200 },
}),
'TOO_MANY_REQUESTS',
],
])('downloads after a build lookup that failed once with %s', async (_, transientError, code) => {
useFakeBackoffTimers();
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
transientError,
});
jest
.mocked(fetch)
.mockResolvedValue(
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
);
const datadogLog = jest.spyOn(Datadog, 'log');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId,
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
await fireBackoffTimersAsync(1);
const { artifactPath } = await promise;
expect(path.basename(artifactPath)).toBe('app.apk');
expect(graphqlClient.query).toHaveBeenCalledTimes(2);
expect(datadogLog).toHaveBeenCalledWith('download_build.build_lookup recovered on attempt 2', {
event: 'network_retry',
operation: 'download_build.build_lookup',
outcome: 'recovered',
attempts: '2',
code,
});
});
it('throws SystemError when GraphQL request returns 5xx', async () => {
useFakeBackoffTimers();
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
error: new CombinedError({
graphQLErrors: [{ message: 'Internal Server Error' }],
error: Object.assign(new Error('Internal Server Error'), {
response: { status: 500 },
}),
});
@@ -672,14 +504,12 @@ describe('downloadBuild', () => {
robotAccessToken: null,
extensions: ['app'],
});
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
await fireBackoffTimersAsync(5);
await rejection;
await expect(promise).rejects.toBeInstanceOf(SystemError);
await expect(promise).rejects.toMatchObject({
message: `Could not fetch build ${buildId}: [GraphQL] Internal Server Error`,
message: `Could not fetch build ${buildId}: Internal Server Error`,
});
expect(graphqlClient.query).toHaveBeenCalledTimes(6);
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
@@ -704,193 +534,10 @@ describe('downloadBuild', () => {
errorCode: 'EAS_DOWNLOAD_BUILD_FETCH_FAILED',
message: `Could not fetch build ${buildId}: [GraphQL] Build not found`,
});
expect(graphqlClient.query).toHaveBeenCalledTimes(1);
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('downloads after an archive request that failed once with a socket error', async () => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({});
jest
.mocked(fetch)
.mockRejectedValueOnce(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ECONNRESET'))
.mockResolvedValueOnce(
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
);
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
await fireBackoffTimersAsync(1);
const { artifactPath } = await promise;
expect(path.basename(artifactPath)).toBe('app.apk');
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(2);
});
it('throws SystemError with the cause code after retrying a failing build archive request', async () => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
jest
.mocked(fetch)
.mockRejectedValue(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ETIMEDOUT'));
const datadogLog = jest.spyOn(Datadog, 'log');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
await fireBackoffTimersAsync(5);
await rejection;
await expect(promise).rejects.toMatchObject({
message: `Could not download the application archive: request to ${APPLICATION_ARCHIVE_URL} failed, reason: connect ETIMEDOUT`,
});
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(6);
expect(datadogLog).toHaveBeenCalledWith('download_build.archive_download failed on attempt 6', {
event: 'network_retry',
operation: 'download_build.archive_download',
outcome: 'failed',
attempts: '6',
code: 'ETIMEDOUT',
});
});
function createBrokenBody(code: string): Readable {
return Readable.from(
(async function* () {
yield Buffer.from('he');
throw Object.assign(new Error(`body failed with ${code}`), { code });
})()
);
}
it.each(['ERR_STREAM_PREMATURE_CLOSE', 'ECONNRESET'])(
'downloads again when the archive body breaks off with %s after the headers arrived',
async code => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({});
const brokenBody = createBrokenBody(code);
jest
.mocked(fetch)
.mockResolvedValueOnce(
createSuccessfulResponse({ body: brokenBody, url: 'https://cdn.test/app.apk' })
)
.mockResolvedValueOnce(
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
);
const datadogLog = jest.spyOn(Datadog, 'log');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
await fireBackoffTimersAsync(1);
const { artifactPath } = await promise;
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(2);
expect(datadogLog).toHaveBeenCalledWith(
'download_build.archive_download recovered on attempt 2',
expect.objectContaining({ outcome: 'recovered', code })
);
}
);
it('throws SystemError when the build archive body keeps breaking off', async () => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
jest.mocked(fetch).mockImplementation(async () =>
createSuccessfulResponse({
body: createBrokenBody('ECONNRESET'),
url: 'https://cdn.test/app.apk',
})
);
const datadogLog = jest.spyOn(Datadog, 'log');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
await fireBackoffTimersAsync(5);
await rejection;
await expect(promise).rejects.toMatchObject({
message: 'Could not download the application archive: body failed with ECONNRESET',
});
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(6);
expect(datadogLog).toHaveBeenCalledWith(
'download_build.archive_download failed on attempt 6',
expect.objectContaining({ outcome: 'failed', code: 'ECONNRESET' })
);
});
it('does not start another archive attempt after an abort during the backoff', async () => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({});
jest
.mocked(fetch)
.mockRejectedValueOnce(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ECONNRESET'));
const controller = new AbortController();
const reason = new Error('step timed out');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
signal: controller.signal,
});
const rejection = expect(promise).rejects.toBe(reason);
await waitForBackoffTimerAsync();
controller.abort(reason);
await jest.advanceTimersToNextTimerAsync();
await rejection;
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(1);
});
it('rethrows the raw error of a failing user-supplied archive URL after the retries', async () => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({});
jest
.mocked(fetch)
.mockRejectedValue(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ENOTFOUND'));
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
const rejection = expect(promise).rejects.toBeInstanceOf(ActualFetchError);
await fireBackoffTimersAsync(5);
await rejection;
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(6);
});
it('should throw an error if no matching files are found', async () => {
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
@@ -944,131 +591,6 @@ describe('createDownloadBuildFunction', () => {
expect(graphqlClient.query).not.toHaveBeenCalled();
});
describe('with a device run session token in the job env', () => {
const { Response: ActualResponse } = jest.requireActual(
'node-fetch'
) as typeof import('node-fetch');
const workerApiUrl = 'https://api.expo.test/v2/device-run-session-worker/application';
function createSessionCtx(graphqlClient: Client): any {
return {
graphqlClient,
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'session-token' },
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
};
}
function createApplicationResponse(buildId: string): Response {
return new ActualResponse(
JSON.stringify({
data: {
build: {
id: buildId,
gitCommitHash: null,
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
},
},
}),
{ status: 200 }
);
}
function createStep(ctx: any, callInputs: Record<string, unknown>) {
return createDownloadBuildFunction(ctx).createBuildStepFromFunctionCall(
createGlobalContextMock({
logger: createMockLogger(),
staticContextContent: { job: { secrets: { robotAccessToken: 'session-token' } } },
}),
{ callInputs }
);
}
it('looks up build_id through the worker API and downloads without an Authorization header', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({});
jest.mocked(fetch).mockImplementation(async url => {
if (url === workerApiUrl) {
return createApplicationResponse(buildId);
}
return {
ok: false,
text: () => Promise.resolve('Internal Server Error'),
status: 500,
} as unknown as Response;
});
await expect(
createStep(createSessionCtx(graphqlClient), {
build_id: buildId,
extensions: ['app'],
}).executeAsync()
).rejects.toThrow('Internal Server Error');
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
workerApiUrl,
expect.objectContaining({
method: 'GET',
headers: { 'expo-device-run-session-token': 'session-token' },
})
);
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
APPLICATION_ARCHIVE_URL,
expect.objectContaining({ headers: undefined })
);
expect(graphqlClient.query).not.toHaveBeenCalled();
});
it('retries the worker API lookup after a 5xx response', async () => {
useFakeBackoffTimers();
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({});
jest
.mocked(fetch)
.mockResolvedValueOnce(new ActualResponse('Service Unavailable', { status: 503 }))
.mockResolvedValueOnce(createApplicationResponse(buildId))
.mockResolvedValueOnce(
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
);
const execution = createStep(createSessionCtx(graphqlClient), {
build_id: buildId,
extensions: ['apk'],
}).executeAsync();
await fireBackoffTimersAsync(1);
await execution;
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(3);
expect(jest.mocked(fetch).mock.calls.map(([url]) => url)).toEqual([
workerApiUrl,
workerApiUrl,
APPLICATION_ARCHIVE_URL,
]);
});
it('does not call the worker API for an application_archive_url input', async () => {
const graphqlClient = createMockGraphqlClient({});
jest.mocked(fetch).mockResolvedValue({
ok: false,
text: () => Promise.resolve('Internal Server Error'),
status: 500,
} as unknown as Response);
await expect(
createStep(createSessionCtx(graphqlClient), {
application_archive_url: APPLICATION_ARCHIVE_URL,
extensions: ['app'],
}).executeAsync()
).rejects.toThrow('Internal Server Error');
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(1);
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
APPLICATION_ARCHIVE_URL,
expect.objectContaining({ headers: undefined })
);
expect(graphqlClient.query).not.toHaveBeenCalled();
});
});
it('should download a build', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
@@ -1,192 +0,0 @@
import { Client, CombinedError } from '@urql/core';
import fetch, { Response } from 'node-fetch';
import fs from 'node:fs';
import { Readable } from 'node:stream';
import { CustomBuildContext } from '../../../customBuildContext';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { createDownloadSubmissionArchiveFunction } from '../downloadSubmissionArchive';
const submissionId = 'aabbccdd-1234-4321-abcd-123456789012';
const url = 'https://storage.example/app.ipa?signature=secret';
it('reports invalid submission ID and extensions together', async () => {
const mutation = jest.fn();
const step = createDownloadSubmissionArchiveFunction({
graphqlClient: { mutation } as unknown as Client,
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: { submission_id: 'invalid', extensions: [123] },
});
await expect(step.executeAsync()).rejects.toMatchObject({
issues: expect.arrayContaining([
expect.objectContaining({ path: ['submissionId'] }),
expect.objectContaining({ path: ['extensions', 0] }),
]),
});
expect(mutation).not.toHaveBeenCalled();
});
it('requests a fresh URL on each execution, downloads without auth and does not log the URL', async () => {
const mutation = jest.fn(() => ({
toPromise: async () => ({
data: {
submission: { generateSubmissionArchiveDownloadUrl: url },
},
}),
}));
const logger = createMockLogger();
const fn = createDownloadSubmissionArchiveFunction({
graphqlClient: { mutation } as unknown as Client,
} as CustomBuildContext);
for (let i = 0; i < 2; i++) {
jest.mocked(fetch).mockResolvedValue({
ok: true,
url,
body: Readable.from(Buffer.from('IPA bytes')),
headers: { get: () => null },
} as unknown as Response);
const step = fn.createBuildStepFromFunctionCall(createGlobalContextMock({ logger }), {
callInputs: { submission_id: submissionId },
});
await step.executeAsync();
expect(await fs.promises.readFile(step.getOutputValueByName('artifact_path')!, 'utf8')).toBe(
'IPA bytes'
);
}
expect(mutation).toHaveBeenCalledTimes(2);
expect(fetch).toHaveBeenCalledWith(url, { headers: undefined, signal: undefined });
expect(JSON.stringify(jest.mocked(logger.info).mock.calls)).not.toContain(url);
});
it('preserves the API error as the cause without downloading', async () => {
const error = new CombinedError({ networkError: new Error('Connection reset') });
const mutation = jest.fn(() => ({
toPromise: async () => ({
error,
}),
}));
const step = createDownloadSubmissionArchiveFunction({
graphqlClient: { mutation } as unknown as Client,
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: { submission_id: submissionId },
});
await expect(step.executeAsync()).rejects.toMatchObject({
message: 'Could not request the submission archive. Try again later.',
cause: error,
});
expect(fetch).not.toHaveBeenCalled();
expect(mutation).toHaveBeenCalledTimes(4);
});
it('retries a temporary URL request failure and then downloads the archive', async () => {
const mutation = jest
.fn()
.mockReturnValueOnce({
toPromise: async () => ({
error: new CombinedError({ networkError: new Error('Connection reset') }),
}),
})
.mockReturnValue({
toPromise: async () => ({
data: { submission: { generateSubmissionArchiveDownloadUrl: url } },
}),
});
jest.mocked(fetch).mockResolvedValue({
ok: true,
url,
body: Readable.from(Buffer.from('IPA bytes')),
headers: { get: () => null },
} as unknown as Response);
const step = createDownloadSubmissionArchiveFunction({
graphqlClient: { mutation } as unknown as Client,
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: { submission_id: submissionId },
});
await step.executeAsync();
expect(mutation).toHaveBeenCalledTimes(2);
expect(await fs.promises.readFile(step.getOutputValueByName('artifact_path')!, 'utf8')).toBe(
'IPA bytes'
);
});
it('does not retry an access error', async () => {
const error = new CombinedError({ graphQLErrors: [{ message: 'Not authorized' }] });
const mutation = jest.fn(() => ({ toPromise: async () => ({ error }) }));
const step = createDownloadSubmissionArchiveFunction({
graphqlClient: { mutation } as unknown as Client,
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: { submission_id: submissionId },
});
await expect(step.executeAsync()).rejects.toMatchObject({ cause: error });
expect(mutation).toHaveBeenCalledTimes(1);
expect(fetch).not.toHaveBeenCalled();
});
it('does not send another URL request after the step times out during retry backoff', async () => {
const mutation = jest.fn(() => ({
toPromise: async () => ({
error: new CombinedError({ networkError: new Error('Connection reset') }),
}),
}));
const step = createDownloadSubmissionArchiveFunction({
graphqlClient: { mutation } as unknown as Client,
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: { submission_id: submissionId },
timeoutMs: 100,
});
await expect(step.executeAsync()).rejects.toThrow('timed out');
await new Promise(resolve => setTimeout(resolve, 1100));
expect(mutation).toHaveBeenCalledTimes(1);
expect(fetch).not.toHaveBeenCalled();
});
it('uses the requested extension for an archive without a filename extension', async () => {
const mutation = jest.fn(() => ({
toPromise: async () => ({
data: {
submission: { generateSubmissionArchiveDownloadUrl: 'https://storage.example/archive' },
},
}),
}));
jest.mocked(fetch).mockResolvedValue({
ok: true,
url: 'https://storage.example/archive',
body: Readable.from(Buffer.from('Android archive bytes')),
headers: { get: () => null },
} as unknown as Response);
const step = createDownloadSubmissionArchiveFunction({
graphqlClient: { mutation } as unknown as Client,
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: { submission_id: submissionId, extensions: ['aab'] },
});
await step.executeAsync();
const artifactPath = step.getOutputValueByName('artifact_path')!;
expect(artifactPath).toMatch(/\.aab$/);
expect(await fs.promises.readFile(artifactPath, 'utf8')).toBe('Android archive bytes');
});
it('propagates the original download error', async () => {
const error = new Error('Download failed');
const mutation = jest.fn(() => ({
toPromise: async () => ({
data: { submission: { generateSubmissionArchiveDownloadUrl: url } },
}),
}));
jest.mocked(fetch).mockRejectedValue(error);
const step = createDownloadSubmissionArchiveFunction({
graphqlClient: { mutation } as unknown as Client,
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: { submission_id: submissionId },
});
await expect(step.executeAsync()).rejects.toBe(error);
});
@@ -51,24 +51,6 @@ describe(createFindAndUploadBuildArtifactsBuildFunction, () => {
await expect(buildStep.executeAsync()).resolves.not.toThrow();
});
it('sets application_archive_path output to first archive', async () => {
const globalContext = createGlobalContextMock({});
vol.fromJSON(
{
'ios/build/test.ipa': '',
},
globalContext.defaultWorkingDirectory
);
const buildStep = findAndUploadBuildArtifacts.createBuildStepFromFunctionCall(
globalContext,
{}
);
await buildStep.executeAsync();
expect(buildStep.outputById.application_archive_path.value).toMatch(/ios\/build\/test\.ipa$/);
});
it('throws build artifacts error', async () => {
const globalContext = createGlobalContextMock({});
ctx.job.buildArtifactPaths = ['worker.log'];
@@ -463,127 +463,114 @@ describe('createInstallMaestroBuildFunction', () => {
}
});
it.each(['1.1.27', '1.1.28', '1.2.3'])(
'prepares the WDA cache only for legacy Runner versions (%s)',
async version => {
const homeDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'install-maestro-test-')
);
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
if (command === 'maestro-runner') {
return { stdout: `maestro-runner ${version}\n` };
}
if (command === 'xcrun') {
return {
stdout: JSON.stringify({
runtimes: [
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-18-3',
isAvailable: true,
version: '18.3.1',
},
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-0',
isAvailable: true,
version: '26.0',
},
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-17-5',
isAvailable: false,
version: '17.5',
},
],
}),
};
}
if (command === 'xcodebuild') {
return { stdout: 'Xcode 26.0\nBuild version 17A324\n' };
}
if (command === 'tar') {
const maestroRunnerHome = args[args.indexOf('-C') + 1];
const productsDirectory = path.join(
maestroRunnerHome,
'cache',
'wda-builds',
'generic',
'DerivedData',
'Build',
'Products'
);
await fs.promises.mkdir(productsDirectory, { recursive: true });
await fs.promises.writeFile(
path.join(productsDirectory, 'WebDriverAgentRunner.xctestrun'),
'cached'
);
}
return { stdout: '' };
}) as any);
try {
await writeInstalledWdaVersion(homeDirectory, '11.1.3');
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock({
runtimePlatform: BuildRuntimePlatform.DARWIN,
});
globalCtx.updateEnv({
EAS_BUILD_RUNNER: 'eas-build',
EAS_BUILD_COCOAPODS_CACHE_URL: 'https://cache.example.com',
HOME: homeDirectory,
});
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
callInputs: { backend: 'maestro-runner' },
});
await step.executeAsync();
expect(step.getOutputValueByName('maestro_version')).toBe(version);
if (version !== '1.1.27') {
expect(mockedDownloadFile).not.toHaveBeenCalled();
expect(mockedSpawn.mock.calls.map(([command]) => command)).not.toContain('xcrun');
await expect(
fs.promises.access(path.join(homeDirectory, '.maestro-runner', 'cache'))
).rejects.toThrow();
return;
}
expect(mockedDownloadFile).toHaveBeenCalledWith(
'https://cache.example.com/storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.0-wda-11.1.3.tar.gz',
expect.stringMatching(/install_maestro_runner_wda_cache.*\/wda-cache\.tar\.gz$/),
{ retry: 3, timeout: 20_000 }
);
for (const runtimeVersion of ['18.3', '26.0']) {
await expect(
fs.promises.readFile(
path.join(
homeDirectory,
'.maestro-runner',
'cache',
'wda-builds',
`sim-ios${runtimeVersion}-iphone`,
'DerivedData',
'Build',
'Products',
'WebDriverAgentRunner.xctestrun'
),
'utf8'
)
).resolves.toBe('cached');
}
await expect(
fs.promises.access(
path.join(homeDirectory, '.maestro-runner', 'cache', 'wda-builds', 'sim-ios17.5-iphone')
)
).rejects.toThrow();
} finally {
await fs.promises.rm(homeDirectory, { force: true, recursive: true });
it('installs the prebuilt WDA cache for each available iOS runtime', async () => {
const homeDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'install-maestro-test-')
);
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
if (command === 'maestro-runner') {
return { stdout: 'maestro-runner 1.2.3\n' };
}
if (command === 'xcrun') {
return {
stdout: JSON.stringify({
runtimes: [
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-18-3',
isAvailable: true,
version: '18.3.1',
},
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-0',
isAvailable: true,
version: '26.0',
},
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-17-5',
isAvailable: false,
version: '17.5',
},
],
}),
};
}
if (command === 'xcodebuild') {
return { stdout: 'Xcode 26.0\nBuild version 17A324\n' };
}
if (command === 'tar') {
const maestroRunnerHome = args[args.indexOf('-C') + 1];
const productsDirectory = path.join(
maestroRunnerHome,
'cache',
'wda-builds',
'generic',
'DerivedData',
'Build',
'Products'
);
await fs.promises.mkdir(productsDirectory, { recursive: true });
await fs.promises.writeFile(
path.join(productsDirectory, 'WebDriverAgentRunner.xctestrun'),
'cached'
);
}
return { stdout: '' };
}) as any);
try {
await writeInstalledWdaVersion(homeDirectory, '11.1.3');
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock({
runtimePlatform: BuildRuntimePlatform.DARWIN,
});
globalCtx.updateEnv({
EAS_BUILD_RUNNER: 'eas-build',
EAS_BUILD_COCOAPODS_CACHE_URL: 'https://cache.example.com',
HOME: homeDirectory,
});
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
callInputs: { backend: 'maestro-runner' },
});
await step.executeAsync();
expect(mockedDownloadFile).toHaveBeenCalledWith(
'https://cache.example.com/storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.0-wda-11.1.3.tar.gz',
expect.stringMatching(/install_maestro_runner_wda_cache.*\/wda-cache\.tar\.gz$/),
{ retry: 3, timeout: 20_000 }
);
for (const runtimeVersion of ['18.3', '26.0']) {
await expect(
fs.promises.readFile(
path.join(
homeDirectory,
'.maestro-runner',
'cache',
'wda-builds',
`sim-ios${runtimeVersion}-iphone`,
'DerivedData',
'Build',
'Products',
'WebDriverAgentRunner.xctestrun'
),
'utf8'
)
).resolves.toBe('cached');
}
await expect(
fs.promises.access(
path.join(homeDirectory, '.maestro-runner', 'cache', 'wda-builds', 'sim-ios17.5-iphone')
)
).rejects.toThrow();
} finally {
await fs.promises.rm(homeDirectory, { force: true, recursive: true });
}
);
});
it('does not download the WDA cache when the installed WDA version is unknown', async () => {
mockedSpawn.mockImplementation((async (command: string) => ({
stdout:
command === 'xcodebuild' ? 'Xcode 26.5\nBuild version 17F90\n' : 'maestro-runner 1.1.27\n',
command === 'xcodebuild' ? 'Xcode 26.5\nBuild version 17F90\n' : 'maestro-runner 1.2.3\n',
})) as any);
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock({
@@ -606,7 +593,7 @@ describe('createInstallMaestroBuildFunction', () => {
mockedSpawn.mockImplementation((async (command: string) => {
switch (command) {
case 'maestro-runner':
return { stdout: 'maestro-runner 1.1.27\n' };
return { stdout: 'maestro-runner 1.2.3\n' };
case 'xcodebuild':
return { stdout: 'Xcode 26.5\nBuild version 17F90\n' };
case 'xcrun':
@@ -646,7 +633,7 @@ describe('createInstallMaestroBuildFunction', () => {
});
await expect(step.executeAsync()).resolves.toBeUndefined();
expect(step.getOutputValueByName('maestro_version')).toBe('1.1.27');
expect(step.getOutputValueByName('maestro_version')).toBe('1.2.3');
expect(mockedDownloadFile.mock.calls.map(([url]) => url)).toEqual([
'https://cache.example.com/storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.5-wda-11.1.3.tar.gz',
'https://storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.5-wda-11.1.3.tar.gz',
@@ -1,6 +1,5 @@
import { BuildRuntimePlatform, BuildStep } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { fs, vol } from 'memfs';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createInstallMitmproxyBuildFunction } from '../installMitmproxy';
@@ -12,58 +11,6 @@ jest.mock('@expo/turtle-spawn', () => ({
const mockedSpawn = jest.mocked(spawn);
// An x86 Homebrew prefix, to check that the app is found from PATH and not from process.arch.
const APP = '/usr/local/Caskroom/mitmproxy/12.2.3/mitmproxy.app';
const LINK = '/usr/local/bin/mitmdump';
function installCask(): void {
vol.fromJSON({ [`${APP}/Contents/MacOS/mitmdump`]: '', '/usr/local/bin': null });
fs.symlinkSync(`${APP}/Contents/MacOS/mitmdump`, LINK);
}
/**
* `which` finds mitmdump once the cask is installed, `brew` installs it, `xattr -p` reports
* quarantine while `stuck` is set, and each `mitmdump --version` takes the next result.
*/
function mockCommands({
installed = false,
stuck = false,
runs = [],
}: {
installed?: boolean;
stuck?: boolean;
runs?: boolean[];
}): void {
if (installed) {
installCask();
}
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
if (command === 'which' && !installed) {
throw new Error('not found');
}
if (command === 'which') {
return { stdout: `${LINK}\n` };
}
if (command === 'brew' && !installed) {
installed = true;
installCask();
}
if (command === 'xattr' && args[0] === '-p' && !stuck) {
throw new Error('No such xattr: com.apple.quarantine');
}
if (command === 'mitmdump' && !runs.shift()) {
throw new Error('not found');
}
return { stdout: '' };
}) as never);
}
function spawnedCommands(): string[] {
return mockedSpawn.mock.calls.map(([command, args]) =>
command === 'xattr' ? `xattr ${args?.[0]}` : command
);
}
describe('createInstallMitmproxyBuildFunction', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -77,51 +24,28 @@ describe('createInstallMitmproxyBuildFunction', () => {
});
}
it('takes the app behind mitmdump on PATH out of quarantine before running it', async () => {
mockCommands({ installed: true, runs: [true] });
it('does not install when mitmdump is already on PATH', async () => {
mockedSpawn.mockResolvedValueOnce({} as never);
await createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith(
'xattr',
['-dr', 'com.apple.quarantine', APP],
expect.anything()
);
expect(spawnedCommands()).toEqual(['which', 'xattr -dr', 'xattr -p', 'mitmdump']);
expect(mockedSpawn).toHaveBeenCalledWith('mitmdump', ['--version'], expect.anything());
expect(mockedSpawn).toHaveBeenCalledTimes(1);
});
it('kills a mitmdump --version that does not return', async () => {
mockCommands({ installed: true, runs: [true] });
await createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith(
'mitmdump',
['--version'],
expect.objectContaining({ stdio: 'ignore', timeout: 60_000, killSignal: 'SIGKILL' })
);
});
it('does not run a mitmdump that stays quarantined', async () => {
mockCommands({ installed: true, stuck: true });
await expect(
createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync()
).resolves.toBeUndefined();
expect(spawnedCommands()).not.toContain('mitmdump');
});
it('does not change quarantine outside EAS Build VMs', async () => {
mockCommands({ installed: true, stuck: true, runs: [true] });
it('warns instead of installing outside EAS Build VMs', async () => {
mockedSpawn.mockRejectedValueOnce(new Error('not found'));
await createStep({}).executeAsync();
expect(spawnedCommands()).toEqual(['mitmdump']);
expect(mockedSpawn).toHaveBeenCalledTimes(1);
});
it('installs the cask with Homebrew and takes it out of quarantine', async () => {
mockCommands({ runs: [false, true] });
it('installs the cask with Homebrew and leaves auto-update off', async () => {
mockedSpawn
.mockRejectedValueOnce(new Error('not found'))
.mockResolvedValueOnce({} as never)
.mockResolvedValueOnce({} as never);
await createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync();
@@ -130,19 +54,13 @@ describe('createInstallMitmproxyBuildFunction', () => {
['install', '--cask', 'mitmproxy'],
expect.objectContaining({ env: expect.objectContaining({ HOMEBREW_NO_AUTO_UPDATE: '1' }) })
);
expect(spawnedCommands()).toEqual([
'which',
'mitmdump',
'brew',
'which',
'xattr -dr',
'xattr -p',
'mitmdump',
]);
});
it('does not fail the job when mitmdump is still not runnable', async () => {
mockCommands({ runs: [false, false] });
mockedSpawn
.mockRejectedValueOnce(new Error('not found'))
.mockResolvedValueOnce({} as never)
.mockRejectedValueOnce(new Error('not found'));
await expect(
createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync()
@@ -1,6 +1,5 @@
import { BuildRuntimePlatform } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { spawnSync } from 'node:child_process';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
@@ -180,7 +179,17 @@ describe(launchApplicationAsync, () => {
expect(mockedSpawn).toHaveBeenNthCalledWith(
2,
'adb',
['shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', 'exp://example.test'],
[
'shell',
'am',
'start',
'-a',
'android.intent.action.VIEW',
'-d',
'exp://example.test',
'-n',
'host.exp.exponent/host.exp.exponent.MainActivity',
],
{ env: {}, logger }
);
expect(logger.info).toHaveBeenNthCalledWith(
@@ -202,263 +211,6 @@ describe(launchApplicationAsync, () => {
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('quotes Android launch arguments and URLs for the device shell', async () => {
const logger = createMockLogger();
await launchApplicationAsync({
applicationIdentifier: 'dev.example.app',
activityName: 'dev.example.app.MainActivity',
launchArgs: ['-d', 'euxy://tempo', '--es', 'probe', 'hello world', '--es', 'quote', "it's"],
openUrl: 'exp://example.test/path?a=1&b=2',
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger,
});
expect(mockedSpawn).toHaveBeenNthCalledWith(
1,
'adb',
[
'shell',
'am',
'start',
'-d',
'euxy://tempo',
'--es',
'probe',
"'hello world'",
'--es',
'quote',
"'it'\\''s'",
'-n',
'dev.example.app/dev.example.app.MainActivity',
],
{ env: {}, logger }
);
expect(mockedSpawn).toHaveBeenNthCalledWith(
2,
'adb',
[
'shell',
'am',
'start',
'-a',
'android.intent.action.VIEW',
'-d',
"'exp://example.test/path?a=1&b=2'",
],
{ env: {}, logger }
);
expect(logger.info).toHaveBeenNthCalledWith(
1,
`Launching dev.example.app with arguments ${JSON.stringify(['-d', 'euxy://tempo', '--es', 'probe', 'hello world', '--es', 'quote', "it's"])}.`
);
});
it('passes iOS launch arguments with spaces unquoted', async () => {
await launchApplicationAsync({
applicationIdentifier: 'com.example.app',
launchArgs: ['-name', 'hello world'],
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: {},
logger: createMockLogger(),
});
expect(mockedSpawn).toHaveBeenCalledWith(
'xcrun',
['simctl', 'launch', 'booted', 'com.example.app', '-name', 'hello world'],
expect.any(Object)
);
});
it('round-trips quoted Android arguments through a POSIX shell', async () => {
const component = 'dev.example.app/dev.example.app.MainActivity$Inner';
const launchArgs = [
'--es',
'text',
'hello world',
'--es',
'json',
'{"a":1,"b":2}',
'--es',
'shell',
'$(printf injected) `printf injected` $HOME ; & | < >',
'--es',
'glob',
'*',
'--es',
'home',
'~',
'--esa',
'list',
'a\\,b,c',
'--es',
'whitespace',
'one\ntwo\tthree',
'--es',
'unicode',
'héllo 🌍',
'--es',
'empty',
'',
];
const openUrl = 'exp://example.test/path?a=1&b=2#fragment';
await launchApplicationAsync({
applicationIdentifier: 'dev.example.app',
activityName: 'dev.example.app.MainActivity$Inner',
launchArgs,
openUrl,
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
});
// adb joins the words after `shell` with spaces and the device shell parses the result.
const parsedCommands = mockedSpawn.mock.calls.map(([, args]) => {
const { stdout } = spawnSync(
'/bin/sh',
['-c', `am() { printf '%s\\0' "$@"; }; ${(args as string[]).slice(1).join(' ')}`],
{ encoding: 'utf8' }
);
return stdout.split('\0').slice(0, -1);
});
expect(parsedCommands).toEqual([
['start', ...launchArgs, '-n', component],
['start', '-a', 'android.intent.action.VIEW', '-d', openUrl],
]);
});
it.each([[['--es', 'key', '']], [['-e', 'key', '']], [['-d', '']]])(
'passes empty Android option values as empty arguments (%j)',
async launchArgs => {
await launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs,
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
});
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
[
'shell',
'am',
'start',
...launchArgs.map(argument => (argument === '' ? "''" : argument)),
'-n',
'com.example.app/com.example.app.MainActivity',
],
expect.any(Object)
);
}
);
it('explains an Android launch failure when a launch argument is empty', async () => {
const launchError = new Error('adb shell am start exited with non-zero code: 1');
mockedSpawn.mockRejectedValueOnce(launchError);
await expect(
launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs: ['--ez', 'isTest', 'true', ''],
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
})
).rejects.toMatchObject({
errorCode: 'EAS_LAUNCH_APPLICATION_FAILED',
message: expect.stringContaining('empty argument at position 4'),
cause: launchError,
});
});
it('rethrows an Android launch failure unchanged when no launch argument is empty', async () => {
const launchError = new Error('adb shell am start exited with non-zero code: 1');
mockedSpawn.mockRejectedValueOnce(launchError);
await expect(
launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs: ['--ez', 'isTest', 'true'],
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
})
).rejects.toBe(launchError);
});
it.each([
{ launchArgs: ['--es', 'key', 'a\0b'] },
{ launchArgs: [], openUrl: 'exp://example.test/a\0b' },
])('rejects NUL characters on Android (%j)', async ({ launchArgs, openUrl }) => {
await expect(
launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs,
openUrl,
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
})
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
expect(mockedSpawn).not.toHaveBeenCalledWith(
'adb',
expect.arrayContaining([expect.stringContaining('\0')]),
expect.any(Object)
);
});
it('accepts an Android launch command of exactly 65,000 bytes', async () => {
const fixedLength = 'am start --es key -n com.example.app/com.example.app.MainActivity'.length;
await launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs: ['--es', 'key', 'a'.repeat(65_000 - fixedLength)],
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
});
expect(mockedSpawn).toHaveBeenCalledTimes(1);
});
it('rejects an Android URL command of 65,001 bytes', async () => {
const fixedLength = 'am start -a android.intent.action.VIEW -d exp://example.test/'.length;
await expect(
launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
openUrl: `exp://example.test/${'a'.repeat(65_001 - fixedLength)}`,
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
})
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
expect(mockedSpawn).toHaveBeenCalledTimes(1);
});
it('rejects an Android launch command that is too long after quoting', async () => {
await expect(
launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs: ['--es', 'quotes', "'".repeat(20_000)],
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
})
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('launches using application metadata passed to the build step', async () => {
const launchApplication = createLaunchApplicationFunction();
const buildStep = launchApplication.createBuildStepFromFunctionCall(
@@ -488,36 +240,16 @@ describe(launchApplicationAsync, () => {
],
expect.any(Object)
);
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
['shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', 'exp://example.test'],
expect.any(Object)
);
});
it('quotes Android launch arguments passed to the build step', async () => {
const launchApplication = createLaunchApplicationFunction();
const buildStep = launchApplication.createBuildStepFromFunctionCall(
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX }),
{
callInputs: {
application_identifier: 'com.example.app',
activity_name: 'com.example.app.MainActivity',
launch_args: ['--es', 'probe', 'hello world'],
},
}
);
await buildStep.executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
[
'shell',
'am',
'start',
'--es',
'probe',
"'hello world'",
'-a',
'android.intent.action.VIEW',
'-d',
'exp://example.test',
'-n',
'com.example.app/com.example.app.MainActivity',
],
@@ -1,4 +1,4 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import { GenericArtifactType, SystemError, UserError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import fs from 'fs/promises';
import os from 'os';
@@ -305,6 +305,7 @@ describe('createMaestroTestsBuildFunction', () => {
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
type: GenericArtifactType.OTHER,
name:
format === 'html' ? 'Maestro Runner HTML Report' : 'Maestro Runner Allure Results',
paths: [
@@ -1090,6 +1091,7 @@ describe('createMaestroTestsBuildFunction', () => {
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
type: GenericArtifactType.OTHER,
name: 'Maestro HTML Report',
paths: ['/home/expo/.maestro/tests/android-maestro-html.html'],
}),
@@ -1418,6 +1420,7 @@ describe('createMaestroTestsBuildFunction', () => {
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
type: GenericArtifactType.OTHER,
name: shot.displayName,
metadata: shot.metadata,
}),
@@ -7,7 +7,6 @@ import { type CustomBuildContext } from '../../../customBuildContext';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { isProcessDescendantOfAsync } from '../../../utils/processes';
import { turtleFetch } from '../../../utils/turtleFetch';
import { pollAgentDeviceArtifactsForUploadAsync } from '../../utils/agentDeviceArtifacts';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import {
monitorLocalEgressAsync,
@@ -15,17 +14,11 @@ import {
registerLocalEgressResources,
stopLocalEgressResourcesAsync,
} from '../../utils/localEgress';
import {
reportLocalEgressGuardCoverageAsync,
resolveLocalEgressServeSimBootEnvironmentAsync,
startLocalEgressGuardRelayAsync,
verifyLocalEgressGuardAsync,
} from '../../utils/localEgressGuard';
import {
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../../utils/remoteDeviceRunSession';
import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceSession';
import { createStartAgentDeviceRemoteSessionBuildFunction } from '../startAgentDeviceRemoteSession';
import { createStartAppiumRemoteSessionBuildFunction } from '../startAppiumRemoteSession';
import { createStartArgentRemoteSessionBuildFunction } from '../startArgentRemoteSession';
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
@@ -36,12 +29,6 @@ jest.mock('../../../utils/processes');
jest.mock('../../../utils/turtleFetch');
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/agentDeviceArtifacts');
jest.mock('../startIosSimulator', () => ({
...jest.requireActual('../startIosSimulator'),
bootIosSimulatorAsync: jest
.fn()
.mockResolvedValue({ deviceIdentifier: 'sim', udid: 'sim', displayName: 'iPhone' }),
}));
jest.mock('../../utils/argentArtifacts');
jest.mock('../../utils/agentDeviceEvents', () => ({
startAgentDeviceEventCollectionAsync: async () => ({ stopAsync: jest.fn() }),
@@ -58,13 +45,6 @@ jest.mock('../../utils/localEgress', () => ({
readLocalEgressHandoffAsync: jest.fn(),
monitorLocalEgressAsync: jest.fn(),
}));
jest.mock('../../utils/localEgressGuard', () => ({
...jest.requireActual('../../utils/localEgressGuard'),
resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn(),
startLocalEgressGuardRelayAsync: jest.fn(),
reportLocalEgressGuardCoverageAsync: jest.fn(),
verifyLocalEgressGuardAsync: jest.fn(),
}));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
getDeviceRunSessionIdOrThrow: () => 'session-id',
@@ -83,26 +63,6 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
waitForDeviceRunSessionStoppedAsync: jest.fn(),
}));
// Agent-device also polls for Stop while it starts up. That watch sets neither an idle timeout nor
// a duration; keep it pending until startup retires it, and give each lifetime wait its own mock.
const lifetimeWait = jest.fn<
ReturnType<typeof waitForDeviceRunSessionStoppedAsync>,
Parameters<typeof waitForDeviceRunSessionStoppedAsync>
>();
function routeStartupWatch(): void {
lifetimeWait.mockReset();
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async args => {
if ('idleTimeout' in args || 'maxDurationSeconds' in args) {
return await lifetimeWait(args);
}
if (!args.signal?.aborted) {
await new Promise<void>(resolve =>
args.signal?.addEventListener('abort', () => resolve(), { once: true })
);
}
});
}
const logger = {
info: jest.fn(),
warn: jest.fn(),
@@ -118,7 +78,7 @@ const fields = {
egressPort: handoff.port,
};
const controllers = [
['agent-device', createStartAgentDeviceSessionBuildFunction, 'agentDeviceRemoteSessionUrl'],
['agent-device', createStartAgentDeviceRemoteSessionBuildFunction, 'agentDeviceRemoteSessionUrl'],
['Appium', createStartAppiumRemoteSessionBuildFunction, 'appiumUrl'],
['Argent', createStartArgentRemoteSessionBuildFunction, 'toolsUrl'],
['web preview', createStartWebPreviewRemoteSessionBuildFunction, 'previewUrl'],
@@ -133,10 +93,10 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
await createFunction(ctx).fn!(
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as BuildStepContext,
{
// Inputs a step does not get default to undefined, like in a real step call.
inputs: new Proxy({} as Record<string, { value: unknown }>, {
get: (target, id: string) => target[id] ?? { value: undefined },
}),
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env,
signal,
@@ -145,8 +105,6 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
}
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(pollAgentDeviceArtifactsForUploadAsync).mockResolvedValue();
routeStartupWatch();
lifetime = registerLocalEgressResources(stopEgress);
stopEgress.mockResolvedValue(undefined);
stopPreview.mockResolvedValue(undefined);
@@ -162,20 +120,9 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
jest.mocked(isProcessDescendantOfAsync).mockResolvedValue(true);
jest
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
.mockResolvedValue([{ udid: 'sim', name: 'iPhone 17' }] as never);
jest.mocked(IosSimulatorUtils.resolveUdidAsync).mockResolvedValue('sim' as never);
.mockResolvedValue([{ udid: 'sim' }] as never);
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as never);
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(handoff);
jest
.mocked(resolveLocalEgressServeSimBootEnvironmentAsync)
.mockImplementation(async () =>
(await readLocalEgressHandoffAsync())
? { SERVE_SIM_ADDITIONAL_DYLIBS: '/test/egress-guard.dylib' }
: null
);
jest.mocked(startLocalEgressGuardRelayAsync).mockResolvedValue(undefined);
jest.mocked(reportLocalEgressGuardCoverageAsync).mockResolvedValue(null);
jest.mocked(verifyLocalEgressGuardAsync).mockResolvedValue(undefined);
jest.mocked(monitorLocalEgressAsync).mockResolvedValue(undefined);
openPreview.mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
@@ -185,11 +132,10 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
});
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: openPreview,
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopPreview,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
lifetimeWait.mockImplementation(async () => {
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => {
expect(lifetime.aborted).toBe(false);
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(1);
});
@@ -199,30 +145,18 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
jest.restoreAllMocks();
});
it('releases resources without monitoring when the session stopped during startup', async () => {
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValueOnce('stopped');
await run();
expect(lifetimeWait).not.toHaveBeenCalled();
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
expect(stopPreview).toHaveBeenCalledTimes(1);
expect(stopEgress).toHaveBeenCalledTimes(1);
expect(lifetime.aborted).toBe(true);
});
it('reports credentials and starts monitoring before waiting, then releases resources', async () => {
await run();
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
ctx,
deviceRunSessionId: 'session-id',
logger,
remoteConfig: expect.objectContaining({
...fields,
[controllerField]: expect.any(String),
[controllerField === 'previewUrl' ? 'previewToken' : 'webPreviewToken']: 'preview-secret',
}),
})
);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
ctx,
deviceRunSessionId: 'session-id',
logger,
remoteConfig: expect.objectContaining({
...fields,
[controllerField]: expect.any(String),
[controllerField === 'previewUrl' ? 'previewToken' : 'webPreviewToken']: 'preview-secret',
}),
});
expect(monitorLocalEgressAsync).toHaveBeenCalledWith({
port: handoff.port,
env,
@@ -236,7 +170,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
await stopLocalEgressResourcesAsync(logger);
stopEgress.mockClear();
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(null);
lifetimeWait.mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
await run();
const { remoteConfig } = jest.mocked(uploadRemoteSessionConfigAsync).mock.calls[0][0];
expect(remoteConfig).toHaveProperty(controllerField);
@@ -255,7 +189,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
} else if (phase === 'report') {
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValue(error);
} else if (phase === 'wait') {
lifetimeWait.mockRejectedValue(error);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValue(error);
} else {
stopPreview.mockRejectedValue(error);
}
@@ -269,10 +203,11 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
);
it('passes cancellation to the monitor and releases the lifetime', async () => {
const abort = new AbortController();
lifetimeWait.mockImplementation(async () => abort.abort());
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => abort.abort());
await run(abort.signal);
// Agent-device hands the monitor its startup signal, which follows the step signal.
expect(jest.mocked(monitorLocalEgressAsync).mock.calls[0][0].signal?.aborted).toBe(true);
expect(monitorLocalEgressAsync).toHaveBeenCalledWith(
expect.objectContaining({ signal: abort.signal })
);
expect(lifetime.aborted).toBe(true);
expect(stopEgress).toHaveBeenCalledTimes(1);
});
@@ -1,10 +1,8 @@
import { type bunyan } from '@expo/logger';
import turtleSpawnAsync from '@expo/turtle-spawn';
import assert from 'node:assert';
import fg from 'fast-glob';
import { vol } from 'memfs';
import path from 'node:path';
import resolveFrom from 'resolve-from';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createTestAndroidJob, createTestIosJob } from '../../../__tests__/utils/job';
@@ -19,100 +17,17 @@ import {
} from '../repack';
jest.mock('fs');
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('@expo/spawn-async');
jest.mock('@expo/repack-app');
jest.mock('resolve-from', () => jest.fn(() => '@expo/repack-app'));
jest.mock('resolve-from', () => jest.fn((_, moduleName) => moduleName));
jest.mock('../../utils/ios/credentials/manager');
describe(createRepackBuildFunction, () => {
beforeEach(() => {
jest.mocked(turtleSpawnAsync).mockImplementation((async (_command: string, args: string[]) => {
const sandbox = args[args.indexOf('--prefix') + 1];
const packageName = args[args.length - 1].replace(/@[^@/]*$/, '');
vol.fromJSON({
[path.join(sandbox, 'node_modules', packageName, 'package.json')]: JSON.stringify({
version: '2.0.0-next.1',
}),
});
}) as unknown as typeof turtleSpawnAsync);
});
afterEach(() => {
jest.restoreAllMocks();
vol.reset();
});
it('installs the repack package with npm in a sandbox whose env is only PATH and HOME, through the npm cache registry', async () => {
const globalCtx = createGlobalContextMock({
staticContextContent: {
job: {},
},
});
globalCtx.updateEnv({
PATH: '/usr/local/bin:/usr/bin',
EAS_BUILD_NPM_CACHE_URL: 'https://npm-cache.expo.test',
NPM_TOKEN: 'secret',
});
const repack = createRepackBuildFunction();
const repackStep = repack.createBuildStepFromFunctionCall(globalCtx, {
callInputs: {
platform: 'ios',
source_app_path: '/path/to/source_app.ipa',
repack_package: '@scope/fork',
repack_version: 'next',
},
});
const logInfo = jest.spyOn(repackStep.ctx.logger, 'info');
await repackStep.executeAsync();
const [installCall] = jest.mocked(turtleSpawnAsync).mock.calls;
const npmEnv = installCall[2]?.env;
assert(npmEnv);
expect(Object.keys(npmEnv).sort()).toEqual(['HOME', 'PATH']);
expect(npmEnv.PATH).toMatch(/\/usr\/local\/bin:\/usr\/bin$/);
expect(installCall).toEqual([
'npm',
[
'install',
'--prefix',
npmEnv.HOME,
'--no-save',
'--package-lock=false',
'--no-audit',
'--no-fund',
'--no-update-notifier',
'--registry',
'https://npm-cache.expo.test',
'@scope/fork@next',
],
{ env: npmEnv, logger: repackStep.ctx.logger },
]);
expect(logInfo).toHaveBeenCalledWith('Using repack from: @scope/fork@next (2.0.0-next.1)');
expect(resolveFrom).toHaveBeenCalledWith(npmEnv.HOME, '@scope/fork');
});
it('installs the repack package from the default registry when the job has no npm cache', async () => {
const globalCtx = createGlobalContextMock({
staticContextContent: {
job: {},
},
});
globalCtx.updateEnv({ PATH: '/usr/local/bin:/usr/bin' });
const repack = createRepackBuildFunction();
const repackStep = repack.createBuildStepFromFunctionCall(globalCtx, {
callInputs: {
platform: 'ios',
source_app_path: '/path/to/source_app.ipa',
},
});
await repackStep.executeAsync();
const [installCall] = jest.mocked(turtleSpawnAsync).mock.calls;
expect(installCall[1]).not.toContain('--registry');
});
it('should set generated output path for successful repack', async () => {
const repack = createRepackBuildFunction();
const repackStep = repack.createBuildStepFromFunctionCall(
@@ -1,202 +0,0 @@
import { SystemError } from '@expo/eas-build-job';
import turtleSpawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import { spawn } from 'node:child_process';
import { EventEmitter, once } from 'node:events';
import path from 'node:path';
import { PassThrough } from 'node:stream';
import { createMockLogger } from '../../../__tests__/utils/logger';
import * as agentRun from '../../utils/agentRun';
import { leaseAgentRunProviderCredentialsAsync } from '../../utils/agentRunLease';
import { createRunAgentBuildFunction } from '../runAgent';
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('node:child_process', () => ({
...jest.requireActual('node:child_process'),
spawn: jest.fn(),
}));
jest.mock('../../utils/agentRunLease');
const installMock = jest.mocked(turtleSpawn);
const spawnMock = jest.mocked(spawn);
const leaseMock = jest.mocked(leaseAgentRunProviderCredentialsAsync);
const ctx = {
expoApiV2BaseUrl: 'https://staging-api.expo.test/v2/',
mcpServerUrl: 'wss://staging-mcp.expo.test',
runtimeApi: { addBuildLogMask: jest.fn() },
} as any;
const inputs = {
agent_kind: { value: 'claude-code' },
agent_run_id: { value: '0199b0a0-1111-7222-8333-444455556666' },
max_duration_seconds: { value: 1800 },
prompt: { value: 'Fix the failing test.' },
};
const env = {
PATH: '/usr/local/bin:/usr/bin',
HOME: '/home/expo',
EXPO_TOKEN: 'expo-token',
__API_SERVER_URL: 'https://staging-api.expo.test',
EAS_BUILD_ID: 'build-id',
NODE_OPTIONS: '--max-old-space-size=3072',
USER_DEFINED_SECRET: 'secret',
};
async function runAsync(
overrides: {
ctx?: any;
inputs?: Record<string, { value: unknown }>;
env?: Record<string, string>;
} = {}
): Promise<void> {
const fn = createRunAgentBuildFunction(overrides.ctx ?? ctx);
await fn.fn!({ logger: createMockLogger() } as any, {
inputs: overrides.inputs ?? inputs,
outputs: {},
env: overrides.env ?? env,
});
}
function mockSuccessfulRun(): { mcpConfig?: any } {
const captured: { mcpConfig?: any } = {};
leaseMock.mockResolvedValue({ provider: 'anthropic', accessToken: 'anthropic-access-token' });
installMock.mockResolvedValue({} as any);
spawnMock.mockImplementation(((_command: string, _args: string[], spawnOptions: any) => {
const child = Object.assign(new EventEmitter(), {
stdin: { on: jest.fn(), end: jest.fn() },
stdout: new PassThrough(),
stderr: new PassThrough(),
});
captured.mcpConfig = JSON.parse(
vol.readFileSync(path.join(spawnOptions.env.HOME, 'mcp.json'), 'utf8') as string
);
process.nextTick(() => {
child.emit('exit', 0, null);
child.stdout.end();
child.stderr.end();
void Promise.all([once(child.stdout, 'end'), once(child.stderr, 'end')]).then(() =>
child.emit('close')
);
});
return child;
}) as any);
return captured;
}
describe(createRunAgentBuildFunction, () => {
it('is the eas/run_agent function', () => {
expect(createRunAgentBuildFunction(ctx).getFullId()).toBe('eas/run_agent');
});
it('passes none of the job environment to npm or the agent', async () => {
mockSuccessfulRun();
await runAsync();
expect(leaseMock).toHaveBeenCalledWith(
expect.objectContaining({
expoApiV2BaseUrl: 'https://staging-api.expo.test/v2/',
expoToken: 'expo-token',
agentRunId: '0199b0a0-1111-7222-8333-444455556666',
})
);
const npmEnv = installMock.mock.calls[0][2]!.env!;
const agentEnv = spawnMock.mock.calls[0][2].env!;
expect(Object.keys(npmEnv).sort()).toEqual(['HOME', 'PATH']);
expect(Object.keys(agentEnv).sort()).toEqual([
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
'CLAUDE_CODE_OAUTH_TOKEN',
'HOME',
'LANG',
'PATH',
'TMPDIR',
]);
expect(agentEnv.PATH).toBe('/usr/local/bin:/usr/bin');
expect(agentEnv.HOME).not.toBe('/home/expo');
});
it('hands the build log secrets filter to the agent run', async () => {
const runAgentSpy = jest.spyOn(agentRun, 'runAgentAsync').mockResolvedValue();
try {
await runAsync();
expect(runAgentSpy).toHaveBeenCalledWith(
expect.objectContaining({ addBuildLogMask: ctx.runtimeApi.addBuildLogMask })
);
} finally {
runAgentSpy.mockRestore();
}
});
it.each([
['wss://staging-mcp.expo.test', 'https://staging-mcp.expo.test/mcp'],
['ws://localhost:8787', 'http://localhost:8787/mcp'],
])('points the agent at the HTTP endpoint of %s', async (mcpServerUrl, mcpUrl) => {
const captured = mockSuccessfulRun();
await runAsync({ ctx: { ...ctx, mcpServerUrl } });
expect(captured.mcpConfig.mcpServers.expo.url).toBe(mcpUrl);
});
it('stops the run at max_duration_seconds and reports a timeout', async () => {
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
const runAgentSpy = jest.spyOn(agentRun, 'runAgentAsync').mockImplementation(
({ signal }) =>
new Promise((_resolve, reject) => {
signal!.addEventListener('abort', () => reject(signal!.reason));
})
);
try {
const run = runAsync({ inputs: { ...inputs, max_duration_seconds: { value: 60 } } });
const rejection = expect(run).rejects.toMatchObject({
errorCode: 'EAS_RUN_AGENT_TIMEOUT',
message: expect.stringContaining('60 seconds'),
});
await jest.advanceTimersByTimeAsync(59_000);
expect(runAgentSpy.mock.calls[0][0].signal!.aborted).toBe(false);
await jest.advanceTimersByTimeAsync(1_000);
await rejection;
} finally {
runAgentSpy.mockRestore();
jest.useRealTimers();
}
});
it.each([
['an unknown agent_kind', { inputs: { ...inputs, agent_kind: { value: 'gemini' } } }],
['a malformed agent_run_id', { inputs: { ...inputs, agent_run_id: { value: 'not-a-uuid' } } }],
['an empty agent_run_id', { inputs: { ...inputs, agent_run_id: { value: '' } } }],
['a zero duration', { inputs: { ...inputs, max_duration_seconds: { value: 0 } } }],
['a negative duration', { inputs: { ...inputs, max_duration_seconds: { value: -300 } } }],
['a fractional duration', { inputs: { ...inputs, max_duration_seconds: { value: 12.5 } } }],
['an empty prompt', { inputs: { ...inputs, prompt: { value: '' } } }],
['no EXPO_TOKEN', { env: { ...env, EXPO_TOKEN: '' } }],
['no PATH', { env: { ...env, PATH: '' } }],
['no Expo API URL', { ctx: { ...ctx, expoApiV2BaseUrl: undefined } }],
['no MCP server URL', { ctx: { ...ctx, mcpServerUrl: undefined } }],
['no build log secrets filter', { ctx: { ...ctx, runtimeApi: {} } }],
])('throws a system error for %s', async (_name, overrides) => {
await expect(runAsync(overrides)).rejects.toBeInstanceOf(SystemError);
expect(leaseMock).not.toHaveBeenCalled();
expect(installMock).not.toHaveBeenCalled();
expect(spawnMock).not.toHaveBeenCalled();
});
it('reports every invalid input at once', async () => {
await expect(
runAsync({
inputs: {
...inputs,
agent_run_id: { value: 'not-a-uuid' },
max_duration_seconds: { value: 0 },
},
})
).rejects.toThrow(
'The "agent_run_id" input must be the UUID of an agent run, but received "not-a-uuid". ' +
'The "max_duration_seconds" input must be a positive whole number of seconds, but received "0".'
);
});
});
@@ -1,18 +1,15 @@
import { BuildRuntimePlatform } from '@expo/steps';
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fetch from 'node-fetch';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Readable } from 'node:stream';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { pollAgentDeviceArtifactsForUploadAsync } from '../../utils/agentDeviceArtifacts';
import { startAgentDeviceEventCollectionAsync } from '../../utils/agentDeviceEvents';
import { uploadDeviceRunSessionArtifactAsync } from '../../utils/deviceRunSessionArtifacts';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import {
DeviceRunSessionStoppedError,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
@@ -23,11 +20,11 @@ import {
waitForDeviceRunSessionStoppedAsync,
waitForFileAsync,
} from '../../utils/remoteDeviceRunSession';
import { createProcessOutput } from '../../utils/processOutput';
import { type StartupTasks, createStartupTasks } from '../../utils/startupTasks';
import { createWarmDeviceSessionServer } from '../../utils/warmDeviceSessionHost';
import { type WarmIosSession } from '../../utils/warmIosSession';
import { runAgentDeviceRemoteSessionAsync } from '../startAgentDeviceRemoteSession';
import {
createStartAgentDeviceRemoteSessionBuildFunction,
runAgentDeviceRemoteSessionAsync,
} from '../startAgentDeviceRemoteSession';
// The daemon entry path and the state directory are resolved from the home directory when
// the module loads, so point it at a temp home we can populate.
@@ -44,14 +41,10 @@ jest.mock('../../../sentry');
jest.mock('../../utils/agentDeviceArtifacts', () => ({
pollAgentDeviceArtifactsForUploadAsync: jest.fn(),
}));
jest.mock('../../utils/deviceRunSessionArtifacts', () => ({
uploadDeviceRunSessionArtifactAsync: jest.fn(),
}));
jest.mock('../../utils/agentDeviceEvents', () => ({
startAgentDeviceEventCollectionAsync: jest.fn(),
}));
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/warmDeviceSessionHost');
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
getDeviceRunSessionIdOrThrow: jest.fn(),
@@ -77,50 +70,28 @@ const mockTunnelStopAsync = jest.fn();
const mockDaemonStopAsync = jest.fn();
const mockEventCollectionStopAsync = jest.fn();
type CaptureInputs = Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]['capture'];
const NO_CAPTURE: CaptureInputs = { networkCapture: false, networkCaptureFields: [] };
/** Runs an Android session that is ready, or an iOS session with no application preparation. */
async function runAsync(
logger: { info: jest.Mock; warn: jest.Mock; child: jest.Mock },
logger: { info: jest.Mock; warn: jest.Mock },
runtimePlatform: BuildRuntimePlatform,
options: Partial<Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]> = {}
launchInputs: Record<string, { value: unknown }> = {}
): Promise<void> {
await runAgentDeviceRemoteSessionAsync(ctx, {
env: {},
logger: logger as never,
runtimePlatform,
sessionEnv: {
deviceRunSessionId: 'device-run-session-id',
ngrokTunnelDomain: 'tunnel.example.com',
ngrokAuthtoken: 'ngrok-token',
},
packageVersion: undefined,
maxIdleTimeMinutes: undefined,
maxDurationSeconds: undefined,
capture: NO_CAPTURE,
tasks: createStartupTasks(logger as never, options.signal),
device:
runtimePlatform === BuildRuntimePlatform.DARWIN
? { iosSimulatorUdid: 'selected-udid', application: Promise.resolve() }
: { booted: Promise.resolve(), ready: Promise.resolve() },
...options,
});
const buildFunction = createStartAgentDeviceRemoteSessionBuildFunction(ctx);
await buildFunction.fn!(
{ logger, global: { runtimePlatform } } as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
max_duration_seconds: { value: undefined },
...launchInputs,
},
outputs: {},
env: {},
} as never
);
}
describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
it('cleans up a stopped startup without starting live-session polling', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValueOnce('stopped');
await runAsync(logger, BuildRuntimePlatform.DARWIN);
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
expect(pollAgentDeviceArtifactsForUploadAsync).not.toHaveBeenCalled();
expect(startAgentDeviceEventCollectionAsync).not.toHaveBeenCalled();
expect(mockDaemonStopAsync).toHaveBeenCalled();
expect(mockTunnelStopAsync).toHaveBeenCalled();
expect(mockPreviewStopAsync).toHaveBeenCalled();
});
describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () => {
beforeEach(async () => {
jest.clearAllMocks();
@@ -146,26 +117,15 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
subdomainId: 'agent-device-abc',
stopAsync: mockTunnelStopAsync,
});
const host = {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: jest.fn().mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.tunnel.example.com',
closeAsync: jest.fn(),
}),
getLastPreviewActivityAt: jest.fn(),
finishAsync: mockPreviewStopAsync,
};
jest.mocked(startDeviceSessionHostAsync).mockImplementation(async (_ctx, options) => {
try {
await options.application;
options.signal?.throwIfAborted();
} catch (error) {
await mockPreviewStopAsync();
throw error;
}
return host;
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
await fs.promises.mkdir(path.dirname(DAEMON_ENTRY_PATH), { recursive: true });
@@ -181,9 +141,10 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
await runAsync(logger, BuildRuntimePlatform.LINUX);
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX, timeoutMs: 60_000 })
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
@@ -201,254 +162,36 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
});
it('uses the warm daemon after adoption and starts no daemon of its own', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const warmServer = { port: 3200 } as never;
jest.mocked(createWarmDeviceSessionServer).mockReturnValue(warmServer);
const warm = {
agentDeviceStateDir: '/warm/agent',
tools: { pid: 20, port: 4310, token: 'warm-token' },
} as WarmIosSession;
await runAgentDeviceRemoteSessionAsync(ctx, {
env: {},
logger: logger as never,
runtimePlatform: BuildRuntimePlatform.DARWIN,
sessionEnv: {
deviceRunSessionId: 'device-run-session-id',
ngrokTunnelDomain: 'tunnel.example.com',
ngrokAuthtoken: 'ngrok-token',
},
packageVersion: undefined,
maxIdleTimeMinutes: undefined,
maxDurationSeconds: undefined,
capture: NO_CAPTURE,
tasks: createStartupTasks(logger as never),
warm,
device: { iosSimulatorUdid: 'UDID', application: Promise.resolve() },
});
expect(spawnDetached).not.toHaveBeenCalled();
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(ctx, expect.anything(), warmServer);
expect(startNgrokTunnelAsync).toHaveBeenCalledWith(expect.objectContaining({ port: 4310 }));
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: expect.objectContaining({ agentDeviceRemoteSessionToken: 'warm-token' }),
})
);
expect(startAgentDeviceEventCollectionAsync).toHaveBeenCalledWith(
expect.objectContaining({ stateDir: '/warm/agent' })
);
expect(mockEventCollectionStopAsync).toHaveBeenCalledTimes(1);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
});
it.each([false, true])(
'finishes the final artifact upload before stopping its owner (warm=%s)',
async warm => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const { pollAgentDeviceArtifactsForUploadAsync: pollActual } = jest.requireActual<
typeof import('../../utils/agentDeviceArtifacts')
>('../../utils/agentDeviceArtifacts');
jest.mocked(pollAgentDeviceArtifactsForUploadAsync).mockImplementation(pollActual);
const { Response } = jest.requireActual<typeof import('node-fetch')>('node-fetch');
const artifacts: { id: string; filename: string }[] = [];
let releaseUpload = () => {};
const pendingUpload = new Promise<void>(resolve => {
releaseUpload = resolve;
});
let markUploadStarted = () => {};
const uploadStarted = new Promise<void>(resolve => {
markUploadStarted = resolve;
});
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementationOnce(async () => {
markUploadStarted();
await pendingUpload;
});
let inventories = 0;
jest.mocked(fetch).mockImplementation(async url => {
if (String(url).endsWith('/artifacts')) {
inventories += 1;
return new Response(JSON.stringify({ artifacts }));
}
return new Response(Readable.from([Buffer.from('screenshot')]));
});
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => {
while (inventories === 0) {
await new Promise(resolve => setTimeout(resolve, 5));
}
// The next periodic inventory is 5 seconds away, so only the final one can see this file.
artifacts.push({ id: 'late', filename: 'late.png' });
});
const running = runAsync(
logger,
warm ? BuildRuntimePlatform.DARWIN : BuildRuntimePlatform.LINUX,
{
warm: warm
? ({
agentDeviceStateDir: '/warm/agent',
tools: { port: 4310, token: 'warm-token' },
} as WarmIosSession)
: undefined,
}
);
try {
await uploadStarted;
expect(mockDaemonStopAsync).not.toHaveBeenCalled();
if (warm) {
expect(mockPreviewStopAsync).not.toHaveBeenCalled();
}
} finally {
releaseUpload();
await running;
}
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ artifactId: 'late', filename: 'late.png' })
);
expect(inventories).toBe(2);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(warm ? 0 : 1);
}
);
it('stops the daemon when the final artifact drain fails', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const failure = new Error('drain failed');
jest.mocked(pollAgentDeviceArtifactsForUploadAsync).mockRejectedValue(failure);
await expect(runAsync(logger, BuildRuntimePlatform.LINUX)).rejects.toBe(failure);
expect(pollAgentDeviceArtifactsForUploadAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ stopPollingSignal: expect.any(AbortSignal) })
);
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
});
it.each([false, true])(
'stops the warm host after a failed drain (collector startup fails=%s)',
async startupFails => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const drainFailure = new Error('drain failed');
const startupFailure = new Error('event collector failed');
jest.mocked(pollAgentDeviceArtifactsForUploadAsync).mockRejectedValueOnce(drainFailure);
if (startupFails) {
jest.mocked(startAgentDeviceEventCollectionAsync).mockRejectedValueOnce(startupFailure);
}
await expect(
runAsync(logger, BuildRuntimePlatform.DARWIN, {
warm: {
agentDeviceStateDir: '/warm/agent',
tools: { port: 4310, token: 'warm-token' },
} as WarmIosSession,
})
).rejects.toBe(startupFails ? startupFailure : drainFailure);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(mockDaemonStopAsync).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
{ err: drainFailure },
'Could not stop the agent-device daemon during remote session teardown.'
);
}
);
it('cancels a warm upload and drains its poller before stopping the host', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const controller = new AbortController();
const cancellation = new Error('job cancelled');
const { pollAgentDeviceArtifactsForUploadAsync: pollActual } = jest.requireActual<
typeof import('../../utils/agentDeviceArtifacts')
>('../../utils/agentDeviceArtifacts');
const pollingFinished = jest.fn();
jest
.mocked(pollAgentDeviceArtifactsForUploadAsync)
.mockImplementation((...args) => pollActual(...args).finally(pollingFinished));
const { Response } = jest.requireActual<typeof import('node-fetch')>('node-fetch');
jest
.mocked(fetch)
.mockImplementation(async url =>
String(url).endsWith('/artifacts')
? new Response(JSON.stringify({ artifacts: [{ id: 'active', filename: 'active.png' }] }))
: new Response(Readable.from(['png']))
);
let markUploadStarted = () => {};
const uploadStarted = new Promise<void>(resolve => {
markUploadStarted = resolve;
});
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementationOnce(async (_ctx, { signal }) => {
markUploadStarted();
await new Promise<void>((_resolve, reject) => {
signal?.addEventListener('abort', () => reject(signal.reason), { once: true });
});
});
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementationOnce(async () => {
await uploadStarted;
controller.abort(cancellation);
throw cancellation;
});
await expect(
runAsync(logger, BuildRuntimePlatform.DARWIN, {
signal: controller.signal,
warm: {
agentDeviceStateDir: '/warm/agent',
tools: { port: 4310, token: 'warm-token' },
} as WarmIosSession,
})
).rejects.toBe(cancellation);
expect(pollingFinished).toHaveBeenCalledTimes(1);
expect(pollingFinished.mock.invocationCallOrder[0]).toBeLessThan(
mockPreviewStopAsync.mock.invocationCallOrder[0]
);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(mockDaemonStopAsync).not.toHaveBeenCalled();
});
it('redacts learned daemon credentials in arbitrary output and retained diagnostics', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const token = 'daemon secret/+';
jest.mocked(waitForFileAsync).mockResolvedValue({ port: 5678, token });
jest.mocked(startNgrokTunnelAsync).mockImplementation(async () => {
const options = jest.mocked(spawnDetached).mock.calls[0][0];
expect(options.secrets).toContain(token);
const output = createProcessOutput(options.logger, options.secrets);
output.stdout.append('unrecognized value: daemon sec');
output.stdout.append('ret/+\n');
output.stderr.append(`escaped value: ${encodeURIComponent(token)}\n`);
expect(output.getOutput()).toBe(
'unrecognized value: [REDACTED]\nescaped value: [REDACTED]\n'
);
return { url: 'https://daemon.test', subdomainId: 'daemon', stopAsync: mockTunnelStopAsync };
});
await runAsync(logger, BuildRuntimePlatform.DARWIN);
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(token);
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(encodeURIComponent(token));
});
it('resets the idle timer on preview input from the session host', async () => {
const previewInputAt = new Date('2026-10-19T12:00:00Z');
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(ctx, {
application: Promise.resolve(),
} as never);
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(previewInputAt);
await runAsync(logger, BuildRuntimePlatform.LINUX, { maxIdleTimeMinutes: 5 });
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(previewInputAt);
});
it('hands network capture to serve-sim', async () => {
it('hands the launch inputs to serve-sim and announces them on an iOS session', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
await runAsync(logger, BuildRuntimePlatform.DARWIN, {
capture: { networkCapture: true, networkCaptureFields: ['header'] },
launch_app_identifier: { value: 'host.exp.Exponent' },
launch_args: { value: ['-EXDevMenuIsOnboardingFinished', '1'] },
open_url: { value: 'exp://127.0.0.1:8081' },
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
runtimePlatform: BuildRuntimePlatform.DARWIN,
launchAppIdentifier: 'host.exp.Exponent',
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
openUrl: 'exp://127.0.0.1:8081',
})
);
expect(logger.info).toHaveBeenCalledWith(
'serve-sim will launch host.exp.Exponent with arguments ' +
'["-EXDevMenuIsOnboardingFinished","1"], then open exp://127.0.0.1:8081.'
);
});
it('hands network capture to serve-sim on an iOS session', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
await runAsync(logger, BuildRuntimePlatform.DARWIN, {
network_capture: { value: true },
network_capture_fields: { value: ['header'] },
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
@@ -457,19 +200,24 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
);
});
it('hands a private preview choice to the session host', async () => {
it('fails before starting the daemon when network capture is asked for on Android', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
await runAsync(logger, BuildRuntimePlatform.DARWIN, {
disableShare: true,
privatePreviewUrl: 'https://expo.dev/private-preview',
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
disableShare: true,
privatePreviewUrl: 'https://expo.dev/private-preview',
await expect(
runAsync(logger, BuildRuntimePlatform.LINUX, { network_capture: { value: true } })
).rejects.toThrow('records traffic through serve-sim on an iOS simulator');
expect(spawnDetached).not.toHaveBeenCalled();
});
it('fails before starting the daemon when a launch is asked for on Android', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
await expect(
runAsync(logger, BuildRuntimePlatform.LINUX, {
launch_app_identifier: { value: 'host.exp.Exponent' },
})
);
).rejects.toThrow('runs on linux');
expect(spawnDetached).not.toHaveBeenCalled();
});
it('starts the session host without waiting for the agent-device daemon', async () => {
@@ -540,14 +288,14 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
});
describe('runAgentDeviceRemoteSessionAsync with a device that is still starting', () => {
function deferred<T = void>(): {
promise: Promise<T>;
resolve: (value: T) => void;
function deferred(): {
promise: Promise<void>;
resolve: () => void;
reject: (e: Error) => void;
} {
let resolve!: (value: T) => void;
let resolve!: () => void;
let reject!: (e: Error) => void;
const promise = new Promise<T>((res, rej) => {
const promise = new Promise<void>((res, rej) => {
resolve = res;
reject = rej;
});
@@ -555,27 +303,18 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
return { promise, resolve, reject };
}
type Device = Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]['device'];
function startSession(
device: Device | ((tasks: StartupTasks) => Device),
signal?: AbortSignal,
onStarted?: () => Promise<void>
) {
type Device = { booted: Promise<unknown>; ready: Promise<unknown> };
function startSession(device: Device | ((tasks: StartupTasks) => Device)) {
const logger = {
info: jest.fn(),
warn: jest.fn(),
child: jest.fn().mockReturnThis(),
} as never;
const tasks = createStartupTasks(logger, signal);
const sessionDevice = typeof device === 'function' ? device(tasks) : device;
const tasks = createStartupTasks(logger);
return runAgentDeviceRemoteSessionAsync(ctx, {
env: {},
logger,
signal,
runtimePlatform:
'iosSimulatorUdid' in sessionDevice
? BuildRuntimePlatform.DARWIN
: BuildRuntimePlatform.LINUX,
runtimePlatform: BuildRuntimePlatform.DARWIN,
sessionEnv: {
deviceRunSessionId: 'device-run-session-id',
ngrokTunnelDomain: 'tunnel.example.com',
@@ -584,10 +323,10 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
packageVersion: undefined,
maxIdleTimeMinutes: undefined,
maxDurationSeconds: undefined,
capture: NO_CAPTURE,
launch: {},
capture: { networkCapture: false, networkCaptureFields: [] },
tasks,
device: sessionDevice,
onStarted,
device: typeof device === 'function' ? device(tasks) : device,
});
}
@@ -597,31 +336,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
}
}
it('aborts parallel startup before host failure cleanup finishes', async () => {
const failure = new Error('guard refused startup');
const cleanup = deferred();
const notified = deferred();
let startupTasks!: StartupTasks;
jest.mocked(startDeviceSessionHostAsync).mockImplementation(async (_ctx, options) => {
options.onStartupError?.(failure);
notified.resolve();
await cleanup.promise;
throw failure;
});
const session = startSession(tasks => {
startupTasks = tasks;
return { iosSimulatorUdid: 'selected-udid' };
});
const rejected = expect(session).rejects.toBe(failure);
await notified.promise;
expect(startupTasks.signal.aborted).toBe(true);
expect(startupTasks.signal.reason).toBe(failure);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
cleanup.resolve();
await rejected;
});
it('starts the daemon during the Android boot and the session host after it', async () => {
it('starts the daemon during the boot and the session host after it', async () => {
const booted = deferred();
const session = startSession({ booted: booted.promise, ready: booted.promise });
await flushAsync();
@@ -633,334 +348,10 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
booted.resolve();
await session;
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
runtimePlatform: BuildRuntimePlatform.LINUX,
timeoutMs: 60_000,
iosSimulatorUdid: undefined,
})
);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
});
it('preserves a failed download when aborting daemon installation rejects first', async () => {
const failure = new Error('download failed: build not found');
const installStarted = deferred();
jest.mocked(spawn).mockImplementation((async (_command, _args, options) => {
return await new Promise((_resolve, reject) => {
options!.signal!.addEventListener(
'abort',
() => {
const error = new Error('The operation was aborted');
error.name = 'AbortError';
reject(error);
},
{ once: true }
);
installStarted.resolve();
});
}) as typeof spawn);
jest.mocked(startDeviceSessionHostAsync).mockImplementation(async (_ctx, { signal }) => {
return await new Promise((_resolve, reject) => {
signal!.addEventListener(
'abort',
() => {
setTimeout(() => reject(signal!.reason), 20);
},
{ once: true }
);
});
});
const applicationReady = Promise.resolve();
const session = startSession(tasks => {
void tasks.run('build download', async () => {
await installStarted.promise;
throw failure;
});
return { iosSimulatorUdid: 'selected-udid', application: applicationReady };
});
await expect(session).rejects.toBe(failure);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('boots the host during download and releases startup only after preparation', async () => {
const hostReady = deferred();
const application = deferred<{
installAppPath: string;
launchAppIdentifier: string;
launchArgs: string[];
openUrl: string;
}>();
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
ctx,
{} as never
);
const release = jest.fn();
jest
.mocked(startDeviceSessionHostAsync)
.mockClear()
.mockImplementation(async (_ctx, options) => {
release(await options.application);
await hostReady.promise;
return host;
});
const applicationReady = application.promise;
const session = startSession({
iosSimulatorUdid: 'selected-udid',
application: applicationReady,
});
await flushAsync();
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
expect(release).not.toHaveBeenCalled();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
const startup = {
installAppPath: '/tmp/App.app',
launchAppIdentifier: 'dev.example.app',
launchArgs: ['--flag'],
openUrl: 'example://screen',
};
application.resolve(startup);
await flushAsync();
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
application: applicationReady,
iosSimulatorUdid: 'selected-udid',
timeoutMs: 60_000,
signal: expect.any(AbortSignal),
})
);
expect(release).toHaveBeenCalledWith(startup);
expect(host.openPreviewAsync).not.toHaveBeenCalled();
hostReady.resolve();
await session;
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
});
it('passes the guard boot environment with the startup installation and launch', async () => {
const startup = {
installAppPath: '/tmp/App.app',
launchAppIdentifier: 'dev.example.app',
launchArgs: ['--flag'],
openUrl: 'example://screen',
};
const bootEnv = {
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
SIMCTL_CHILD_EAS_EGRESS_GUARD_MODE: 'block',
SIMCTL_CHILD_EAS_EGRESS_GUARD_LOG: '/tmp/egress-guard.log',
SIMCTL_CHILD_HTTP_PROXY: 'http://127.0.0.1:8899',
};
await startSession({
iosSimulatorUdid: 'guarded-udid',
bootEnv,
application: Promise.resolve(startup),
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
application: expect.any(Promise),
bootEnv,
iosSimulatorUdid: 'guarded-udid',
})
);
const hostOptions = jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1];
expect(hostOptions.bootEnv).toBe(bootEnv);
expect(hostOptions.bootEnv).not.toHaveProperty('DYLD_INSERT_LIBRARIES');
expect(hostOptions.bootEnv).not.toHaveProperty('SIMCTL_CHILD_DYLD_INSERT_LIBRARIES');
});
it('cleans up a host returned after cancellation without configuring the device', async () => {
const hostReady = deferred();
const controller = new AbortController();
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
ctx,
{} as never
);
jest.mocked(startDeviceSessionHostAsync).mockImplementation(async () => {
await hostReady.promise;
return host;
});
const applicationReady = Promise.resolve();
const session = startSession(
{ iosSimulatorUdid: 'selected-udid', application: applicationReady },
controller.signal
);
const failure = expect(session).rejects.toThrow('cancelled');
await flushAsync();
controller.abort(new Error('cancelled'));
hostReady.resolve();
await failure;
expect(host.openPreviewAsync).not.toHaveBeenCalled();
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('drains in-flight application preparation and stops the waiting host after cancellation', async () => {
const controller = new AbortController();
const appReady = deferred();
const session = startSession(
{ iosSimulatorUdid: 'selected-udid', application: appReady.promise },
controller.signal
);
const failure = expect(session).rejects.toThrow('cancelled');
await flushAsync();
controller.abort(new Error('cancelled'));
await flushAsync();
expect(mockPreviewStopAsync).not.toHaveBeenCalled();
appReady.resolve();
await failure;
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('does not report readiness when cancellation arrives during preview startup', async () => {
const controller = new AbortController();
const previewReady = deferred();
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
ctx,
{} as never
);
const preview = await host.openPreviewAsync();
jest.mocked(host.openPreviewAsync).mockImplementation(async () => {
await previewReady.promise;
return preview;
});
const session = startSession(
{ iosSimulatorUdid: 'selected-udid', application: Promise.resolve() },
controller.signal
);
const failure = expect(session).rejects.toThrow('cancelled');
await flushAsync();
controller.abort(new Error('cancelled'));
previewReady.resolve();
await failure;
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('does not start resources for an already cancelled session', async () => {
const controller = new AbortController();
controller.abort(new Error('cancelled'));
const applicationReady = Promise.resolve();
await expect(
startSession(
{ iosSimulatorUdid: 'selected-udid', application: applicationReady },
controller.signal
)
).rejects.toThrow('cancelled');
expect(spawn).not.toHaveBeenCalled();
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
});
it('reports late host cleanup failure after startup Stop', async () => {
const stopped = new DeviceRunSessionStoppedError('stopped during startup');
const failed = new AggregateError([stopped, new Error('host cleanup failed')]);
let entered!: () => void;
const starting = new Promise<void>(resolve => {
entered = resolve;
});
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => {
entered();
return await new Promise((_resolve, reject) => {
options.signal!.addEventListener('abort', () => setImmediate(() => reject(failed)), {
once: true,
});
});
});
let tasks!: StartupTasks;
const running = startSession(startup => {
tasks = startup;
return { booted: Promise.resolve(), ready: Promise.resolve() };
});
running.catch(() => {});
await starting;
tasks.abort(stopped);
await expect(running).rejects.toBe(failed);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('drains resources and preserves the explicit startup Stop reason', async () => {
const stopped = new DeviceRunSessionStoppedError('stopped during startup');
let tasks!: StartupTasks;
const session = startSession(startup => {
tasks = startup;
return { booted: Promise.resolve(), ready: tasks.untilAborted(new Promise(() => {})) };
});
session.catch(() => {});
await flushAsync();
tasks.abort(stopped);
await expect(session).rejects.toBe(stopped);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
});
it('does not hide a teardown failure after an explicit startup Stop', async () => {
const stopped = new DeviceRunSessionStoppedError('stopped during startup');
const failed = new Error('daemon teardown failed');
let tasks!: StartupTasks;
const session = startSession(startup => {
tasks = startup;
return { booted: Promise.resolve(), ready: tasks.untilAborted(new Promise(() => {})) };
});
session.catch(() => {});
await flushAsync();
mockDaemonStopAsync.mockRejectedValueOnce(failed);
tasks.abort(stopped);
await expect(session).rejects.toBe(failed);
});
it('retires startup monitoring after reporting and before lifetime waiting', async () => {
const retired = deferred();
const retiring = jest.fn(() => retired.promise);
const session = startSession(
{ booted: Promise.resolve(), ready: Promise.resolve() },
undefined,
retiring
);
await flushAsync();
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
expect(retiring).toHaveBeenCalledTimes(1);
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
retired.resolve();
await session;
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledTimes(1);
});
it('aborts a pending ready report when startup is stopped', async () => {
const stopped = new DeviceRunSessionStoppedError('stopped during startup');
let tasks!: StartupTasks;
jest.mocked(uploadRemoteSessionConfigAsync).mockImplementation(
async ({ signal }) =>
new Promise((_resolve, reject) => {
signal!.addEventListener('abort', () => reject(new Error('request aborted')), {
once: true,
});
})
);
const session = startSession(startup => {
tasks = startup;
return { booted: Promise.resolve(), ready: Promise.resolve() };
});
session.catch(() => {});
await flushAsync();
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({ signal: tasks.signal })
);
tasks.abort(stopped);
await expect(session).rejects.toBe(stopped);
expect(startAgentDeviceEventCollectionAsync).not.toHaveBeenCalled();
});
it('starts the Android host before application readiness and waits before reporting ready', async () => {
it('reports the session as ready only after the app is launched', async () => {
const ready = deferred();
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
await flushAsync();
@@ -970,11 +361,10 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
ready.resolve();
await session;
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
});
it('stops waiting for the Android boot when the daemon fails', async () => {
it('stops waiting for the boot when the daemon fails', async () => {
jest.mocked(waitForFileAsync).mockRejectedValue(new Error('no daemon credentials'));
const neverBooted = new Promise<void>(() => {});
@@ -988,7 +378,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('cancels daemon installation and stops the Android host when the download fails', async () => {
it('stops the session host when the app fails while the daemon install hangs', async () => {
jest.mocked(spawn).mockImplementation(((
command: string,
args: string[],
@@ -1004,26 +394,34 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
await flushAsync();
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
ready.reject(new Error('download failed'));
ready.reject(new Error('simctl install failed'));
await expect(session).rejects.toThrow('download failed');
await expect(session).rejects.toThrow('simctl install failed');
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(spawnDetached).not.toHaveBeenCalled();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('stops the Android host, daemon and tunnel when the download fails', async () => {
it('stops everything it started when the app install fails', async () => {
const ready = deferred();
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
await flushAsync();
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
ready.reject(new Error('download failed'));
ready.reject(new Error('simctl install failed'));
await expect(session).rejects.toThrow('download failed');
await expect(session).rejects.toThrow('simctl install failed');
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
});
});
it('declares the launch inputs', () => {
const buildFunction = createStartAgentDeviceRemoteSessionBuildFunction(ctx);
const globalCtx = createGlobalContextMock();
expect(
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
});
});
@@ -93,28 +93,6 @@ describe(startAgentDeviceDaemonAsync, () => {
}) as never);
});
it.each([false, true])(
'shares the daemon secret list through git fallback=%s',
async fallback => {
if (fallback) {
jest.mocked(spawn).mockResolvedValue({ stdout: '' } as never);
}
const secrets: string[] = [];
const handle = await startAgentDeviceDaemonAsync({
packageVersion: '1.2.3',
env: {},
logger,
secrets,
});
const options = jest.mocked(spawnDetached).mock.calls[0][0];
expect(options.command).toBe(fallback ? 'bun' : 'node');
expect(options.secrets).toBe(secrets);
secrets.push('learned-daemon-token');
expect(options.secrets).toContain('learned-daemon-token');
await handle.stopAsync();
}
);
it('installs with bun add by default and launches the published daemon', async () => {
const handle = await startAgentDeviceDaemonAsync({
packageVersion: '1.2.3',
@@ -130,8 +108,6 @@ describe(startAgentDeviceDaemonAsync, () => {
const addCwd = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
expect(spawnDetached).toHaveBeenCalledWith({
command: 'node',
logger,
secrets: undefined,
args: [path.join(addCwd, 'node_modules/agent-device/dist/src/internal/daemon.js')],
env: expect.objectContaining({
AGENT_DEVICE_DAEMON_SERVER_MODE: 'http',
@@ -145,20 +121,6 @@ describe(startAgentDeviceDaemonAsync, () => {
await expect(fs.promises.access(addCwd)).rejects.toThrow();
});
it('removes the install directory when the daemon cannot be stopped', async () => {
const stopError = new Error('drain timed out');
stopAsync.mockRejectedValueOnce(stopError);
const handle = await startAgentDeviceDaemonAsync({
packageVersion: '1.2.3',
env: {},
logger,
});
const addCwd = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
await expect(handle.stopAsync()).rejects.toBe(stopError);
await expect(fs.promises.access(addCwd)).rejects.toThrow();
});
it('installs with npm when EAS_OVERRIDE_PACKAGE_MANAGER is npm', async () => {
await startAgentDeviceDaemonAsync({
packageVersion: undefined,
@@ -174,15 +136,10 @@ describe(startAgentDeviceDaemonAsync, () => {
});
it('kills the install and does not fall back to git when aborted', async () => {
let installStarted!: () => void;
const started = new Promise<void>(resolve => {
installStarted = resolve;
});
jest.mocked(spawn).mockImplementation(
((_command: string, _args: string[], options?: { signal?: AbortSignal }) =>
new Promise((_resolve, reject) => {
options!.signal!.addEventListener('abort', () => reject(options!.signal!.reason));
installStarted();
})) as never
);
const controller = new AbortController();
@@ -194,7 +151,7 @@ describe(startAgentDeviceDaemonAsync, () => {
logger,
signal: controller.signal,
});
await started;
await new Promise(resolve => setImmediate(resolve));
controller.abort(failure);
await expect(daemon).rejects.toBe(failure);
@@ -242,8 +199,6 @@ describe(startAgentDeviceDaemonAsync, () => {
);
expect(spawnDetached).toHaveBeenCalledWith({
command: 'bun',
logger,
secrets: undefined,
args: ['run', 'src/daemon.ts'],
cwd: '/tmp/agent-device-src',
env: expect.objectContaining({
File diff suppressed because it is too large. Load diff
@@ -9,8 +9,6 @@ import {
} from '../../../utils/AndroidEmulatorUtils';
import { retryAsync } from '../../../utils/retry';
import { resolveAndroidEmulatorLocalEgressAsync } from '../../utils/androidLocalEgress';
import { AndroidEmulatorLogCapture } from '../../../utils/androidEmulatorLogCapture';
import { getAndroidEmulatorLogCapture } from '../../utils/androidEmulatorLogs';
import { createStartAndroidEmulatorBuildFunction } from '../startAndroidEmulator';
jest.mock('@expo/turtle-spawn', () => ({
@@ -26,8 +24,6 @@ jest.mock('../../utils/androidLocalEgress', () => ({
resolveAndroidEmulatorLocalEgressAsync: jest.fn(),
}));
jest.mock('../../utils/androidEmulatorLogs');
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
AndroidEmulatorUtils: {
defaultSystemImagePackage: 'system-images;android-30;default;x86_64',
@@ -66,11 +62,6 @@ function createStartResult(serialId: string) {
describe(createStartAndroidEmulatorBuildFunction, () => {
beforeEach(() => {
jest
.mocked(getAndroidEmulatorLogCapture)
.mockImplementation(
jest.requireActual('../../utils/androidEmulatorLogs').getAndroidEmulatorLogCapture
);
mockedMkdtemp.mockResolvedValue('/tmp/logcat-directory');
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
mockedAndroidUtils.getAvailableDevicesAsync.mockResolvedValue([]);
@@ -99,30 +90,6 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
mockedMkdtemp.mockRestore();
});
it('preserves an explicit empty source list and one capture through boot retries and clones', async () => {
mockedAndroidUtils.waitForReadyAsync.mockRejectedValueOnce(new Error('retry boot'));
const capture = new AndroidEmulatorLogCapture();
jest.mocked(getAndroidEmulatorLogCapture).mockReturnValue(capture);
await createStep(
{ android_log_sources: [], count: 2 },
{ DEVICE_RUN_SESSION_ID: 'session' }
).executeAsync();
expect(getAndroidEmulatorLogCapture).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ DEVICE_RUN_SESSION_ID: 'session' })
);
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledTimes(4);
for (const [options] of mockedAndroidUtils.startAsync.mock.calls) {
expect(options.logSources).toEqual([]);
expect(options.logCapture).toBe(capture);
}
});
it('rejects invalid sources before starting the emulator', async () => {
await expect(createStep({ android_log_sources: ['unknown'] }).executeAsync()).rejects.toThrow();
expect(mockedAndroidUtils.startAsync).not.toHaveBeenCalled();
});
describe('local egress', () => {
function createLocalEgress() {
return {
@@ -144,8 +111,6 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
deviceName: 'EasAndroidDevice01',
env: expect.any(Object),
logcatDirectory: '/tmp/logcat-directory',
logSources: ['emulator-output', 'device-logcat'],
logCapture: expect.any(AndroidEmulatorLogCapture),
});
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenCalledWith({
env: expect.any(Object),
File diff suppressed because it is too large. Load diff
@@ -1,3 +1,4 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import fs from 'node:fs';
import os from 'node:os';
@@ -5,12 +6,79 @@ import path from 'node:path';
import { Sentry } from '../../../sentry';
import {
MIN_ARGENT_REMOTE_SESSION_VERSION,
stopArgentEventCollectionSafelyAsync,
waitForArgentToolServerStateAsync,
warnIfArgentPackageVersionCannotBeVerified,
} from '../startArgentRemoteSession';
jest.mock('../../../sentry');
describe(warnIfArgentPackageVersionCannotBeVerified, () => {
const warn = jest.fn();
const logger = { warn } as unknown as bunyan;
const supportedVersion = '999.0.0';
const oldVersion = '0.0.0';
beforeEach(() => {
warn.mockClear();
});
it('allows the default latest package version', () => {
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: undefined, logger })
).not.toThrow();
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: 'latest', logger })
).not.toThrow();
expect(warn).not.toHaveBeenCalled();
});
it('allows exact supported semver versions', () => {
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({
packageVersion: MIN_ARGENT_REMOTE_SESSION_VERSION,
logger,
})
).not.toThrow();
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: supportedVersion, logger })
).not.toThrow();
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({
packageVersion: `v${supportedVersion}`,
logger,
})
).not.toThrow();
expect(warn).not.toHaveBeenCalled();
});
it('rejects exact semver versions that are too old', () => {
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: oldVersion, logger })
).toThrow(SystemError);
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: oldVersion, logger })
).toThrow(`Use "latest" or pass an exact version >= ${MIN_ARGENT_REMOTE_SESSION_VERSION}`);
});
it('warns for package tags or ranges that cannot be verified', () => {
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: 'next', logger })
).not.toThrow();
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({
packageVersion: `^${MIN_ARGENT_REMOTE_SESSION_VERSION}`,
logger,
})
).not.toThrow();
expect(warn).toHaveBeenCalledTimes(2);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('Continuing and letting the package manager resolve it.')
);
});
});
describe(waitForArgentToolServerStateAsync, () => {
let stateDir: string;
@@ -32,7 +32,6 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
startNgrokTunnelAsync: jest.fn(),
getNgrokAuthtokenOrThrow: () => 'ngrok-token',
getNgrokTunnelDomainOrThrow: () => 'example.com',
omitNgrokAuthtoken: jest.requireActual('../../utils/remoteDeviceRunSession').omitNgrokAuthtoken,
}));
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
@@ -1,4 +1,4 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import { SystemError } from '@expo/eas-build-job';
import { Client, fetchExchange } from '@urql/core';
import * as sandboxDaemon from '../../utils/sandboxDaemon';
@@ -6,90 +6,6 @@ import * as sandboxDaemon from '../../utils/sandboxDaemon';
import { createStartSandboxBuildFunction, markSandboxReadyAsync } from '../startSandbox';
describe('sandbox build functions', () => {
it.each([
{ completion: 'duration', duration: 0.02 },
{ completion: 'cancellation', duration: 60 },
{ completion: 'duration', duration: 0.0001 },
])(
'stops after $completion with $duration seconds only after readiness',
async ({ completion, duration }) => {
const controller = new AbortController();
let ready!: () => void;
const stopAsync = jest.fn(async () => {});
const start = jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync').mockResolvedValue({
ready: new Promise<void>(resolve => {
ready = resolve;
}),
stopAsync,
});
const mutation = jest.fn(() => ({ toPromise: async () => ({ data: {} }) }));
const logger = { info: jest.fn(), child: jest.fn() };
logger.child.mockReturnValue(logger);
const fn = createStartSandboxBuildFunction({
mcpServerUrl: 'ws://localhost:8787',
graphqlClient: { mutation },
} as any);
try {
const pending = fn.fn!({ logger } as any, {
inputs: {
sandbox_id: { value: 'sandbox-id' },
max_duration_seconds: { value: duration },
},
outputs: {},
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
signal: controller.signal,
});
await new Promise(resolve => setTimeout(resolve, 40));
expect(mutation).not.toHaveBeenCalled();
expect(stopAsync).not.toHaveBeenCalled();
ready();
if (completion === 'cancellation') {
await new Promise(resolve => setImmediate(resolve));
expect(stopAsync).not.toHaveBeenCalled();
controller.abort();
}
await pending;
expect(mutation).toHaveBeenCalledTimes(1);
expect(stopAsync).toHaveBeenCalledTimes(1);
const timeoutMessage = `Sandbox reached its maximum runtime of ${duration} seconds. Stopping.`;
if (completion === 'duration') {
expect(logger.info).toHaveBeenCalledWith(timeoutMessage);
} else {
expect(logger.info).not.toHaveBeenCalledWith(timeoutMessage);
}
} finally {
start.mockRestore();
}
}
);
it.each([0, -1, Infinity, NaN])(
'rejects invalid maximum duration %s before starting the daemon',
async duration => {
const start = jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync');
const fn = createStartSandboxBuildFunction({
mcpServerUrl: 'ws://localhost:8787',
} as any);
try {
const result = fn.fn!({} as any, {
inputs: {
sandbox_id: { value: 'sandbox-id' },
max_duration_seconds: { value: duration },
},
outputs: {},
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
});
await expect(result).rejects.toBeInstanceOf(UserError);
await expect(result).rejects.toThrow(
`Invalid sandbox maximum duration: ${duration}. Provide a finite number greater than zero, or omit max_duration_seconds.`
);
expect(start).not.toHaveBeenCalled();
} finally {
start.mockRestore();
}
}
);
it('aborts an active ready request and keeps its authentication headers', async () => {
const controller = new AbortController();
let requestStarted!: () => void;
@@ -3,7 +3,6 @@ import { BuildRuntimePlatform, type BuildStepContext, type BuildStepEnv } from '
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import {
getDeviceRunSessionIdOrThrow,
@@ -12,33 +11,9 @@ import {
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../../utils/remoteDeviceRunSession';
import {
createWarmDeviceSessionServer,
runOnWarmIosSimulatorAsync,
} from '../../utils/warmDeviceSessionHost';
import { type WarmIosSession } from '../../utils/warmIosSession';
import { watchDeviceRunSessionStartup } from '../../utils/deviceRunSessionStartup';
import { createStartupWatchMock } from '../../../__tests__/utils/startupWatch';
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/warmDeviceSessionHost');
jest.mock('../../../utils/IosSimulatorUtils', () => ({
IosSimulatorUtils: {
resolveUdidAsync: jest.fn().mockResolvedValue('selected-ios-udid'),
getAvailableDevicesAsync: jest
.fn()
.mockResolvedValue([{ name: 'iPhone 17', udid: 'selected-ios-udid' }]),
},
}));
jest.mock('../../utils/iosAppArtifact', () => ({
...jest.requireActual('../../utils/iosAppArtifact'),
readIosApplicationIdentifierAsync: jest.fn().mockResolvedValue('dev.example.app'),
}));
jest.mock('../../utils/localEgressGuard', () => ({
resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn().mockResolvedValue(null),
}));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
getDeviceRunSessionIdOrThrow: jest.fn(),
@@ -47,20 +22,15 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
}));
jest.mock('../../utils/deviceRunSessionStartup', () => ({
watchDeviceRunSessionStartup: jest.fn(),
}));
const ctx = {} as CustomBuildContext;
const env = { NGROK_AUTHTOKEN: 'ngrok-token', PATH: '/usr/bin' } as BuildStepEnv;
const childEnv = { PATH: '/usr/bin' } as BuildStepEnv;
const env = {} as BuildStepEnv;
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const stopAsync = jest.fn();
async function runAsync(
runtimePlatform: BuildRuntimePlatform,
launchInputs: Record<string, { value: unknown }> = {},
signal?: AbortSignal
launchInputs: Record<string, { value: unknown }> = {}
): Promise<void> {
const buildFunction = createStartWebPreviewRemoteSessionBuildFunction(ctx);
await buildFunction.fn!(
@@ -75,7 +45,6 @@ async function runAsync(
...launchInputs,
},
outputs: {},
signal,
env,
} as never
);
@@ -90,293 +59,24 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: jest.fn().mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
sharePageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.example.test',
closeAsync: jest.fn(),
}),
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
jest.mocked(watchDeviceRunSessionStartup).mockImplementation(createStartupWatchMock);
stopAsync.mockResolvedValue(undefined);
});
it('does not start a host when the parent aborts during Xcode selection', async () => {
const controller = new AbortController();
const failure = new Error('parent step cancelled');
jest
.mocked(selectXcodeDeveloperDirectoryAsync)
.mockImplementationOnce(async () => controller.abort(failure));
await expect(runAsync(BuildRuntimePlatform.DARWIN, {}, controller.signal)).rejects.toBe(
failure
);
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
expect(watchDeviceRunSessionStartup).toHaveBeenCalledTimes(1);
expect(
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.finishAsync
).toHaveBeenCalled();
});
it('keeps watching startup when warm adoption retries cold', async () => {
const adoption = new Error('warm adoption failed');
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation(async (_claim, run) => {
try {
return await run({
device: { udid: 'selected-ios-udid', name: 'iPhone 17' },
} as WarmIosSession);
} catch (error) {
if (error !== adoption) {
throw error;
}
return await run(undefined);
}
});
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => {
options.onStartupError?.(adoption);
throw adoption;
});
await expect(
Promise.resolve(
createStartWebPreviewRemoteSessionBuildFunction(ctx, { warm: true }).fn!(
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never,
{
inputs: {
boot_simulator: { value: true },
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env,
} as never
)
)
).resolves.toBeUndefined();
expect(watchDeviceRunSessionStartup).toHaveBeenCalledTimes(1);
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(2);
});
it.each([false, true])(
'observes Stop during warm claim and preserves cleanup failure=%s',
async failCleanup => {
let entered!: () => void;
const claiming = new Promise<void>(resolve => {
entered = resolve;
});
const cleanup = new Error('warm cleanup failed');
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation(async claim => {
entered();
await new Promise<void>((_resolve, reject) => {
claim.signal!.addEventListener(
'abort',
() =>
reject(
failCleanup
? new AggregateError([claim.signal!.reason, cleanup])
: claim.signal!.reason
),
{ once: true }
);
});
});
const running = createStartWebPreviewRemoteSessionBuildFunction(ctx, { warm: true }).fn!(
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env,
} as never
) as Promise<void>;
running.catch(() => {});
await claiming;
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
if (failCleanup) {
await expect(running).rejects.toMatchObject({ errors: expect.arrayContaining([cleanup]) });
} else {
await expect(running).resolves.toBeUndefined();
}
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
}
);
it('adopts the claimed warm Simulator when the warm step runs', async () => {
const warmServer = { port: 3200 } as never;
const warm = { device: { udid: 'SELECTED-IOS-UDID', name: 'iPhone 17' } } as WarmIosSession;
jest.mocked(createWarmDeviceSessionServer).mockReturnValue(warmServer);
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation((_claim, run) => run(warm));
const buildFunction = createStartWebPreviewRemoteSessionBuildFunction(ctx, { warm: true });
expect(buildFunction.id).toBe('start_warm_serve_sim_remote_session');
await buildFunction.fn!(
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never,
{ inputs: { device_identifier: { value: 'iPhone 17' } }, outputs: {}, env } as never
);
expect(runOnWarmIosSimulatorAsync).toHaveBeenCalledWith(
expect.objectContaining({ engine: 'serve-sim', requestedDevice: 'iPhone 17' }),
expect.any(Function)
);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ iosSimulatorUdid: 'SELECTED-IOS-UDID' }),
warmServer
);
});
it('uses preview activity to reset the configured idle timeout', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, { max_idle_time_minutes: { value: 5 } });
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
expect(idleTimeout?.getLastEventObservedAt()).toBeUndefined();
const host = await jest.mocked(startDeviceSessionHostAsync).mock.results[0].value;
const observedAt = new Date();
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(observedAt);
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(observedAt);
expect(stopAsync).toHaveBeenCalledTimes(1);
});
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
'leaves the idle timer disabled when omitted on %s',
async runtimePlatform => {
await runAsync(runtimePlatform);
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
expect.objectContaining({ idleTimeout: undefined, maxDurationSeconds: 120 })
);
}
);
it('leaves the idle timer disabled for zero', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, {
max_idle_time_minutes: { value: 0 },
});
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
expect.objectContaining({ idleTimeout: undefined, maxDurationSeconds: 120 })
);
});
it('rejects an Android idle timeout before starting the host', async () => {
await expect(
runAsync(BuildRuntimePlatform.LINUX, {
max_idle_time_minutes: { value: 5 },
})
).rejects.toThrow('Omit max_idle_time_minutes for Android sessions.');
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
});
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
'handles Stop while the %s host starts',
async runtimePlatform => {
let hostEntered!: () => void;
const entered = new Promise<void>(resolve => {
hostEntered = resolve;
});
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce((_ctx, { signal }) => {
hostEntered();
return new Promise((_resolve, reject) =>
signal!.addEventListener('abort', () => reject(new Error('host aborted')), { once: true })
);
});
const running = runAsync(runtimePlatform);
running.catch(() => {});
await entered;
const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value;
watch.stop();
await expect(running).resolves.toBeUndefined();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
expect(watch.finishAsync).toHaveBeenCalled();
}
);
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
'preserves an earlier %s host failure when Stop arrives during cleanup',
async runtimePlatform => {
const failure = new Error('Host became ready without a session token');
let cleanupEntered!: () => void;
const entered = new Promise<void>(resolve => {
cleanupEntered = resolve;
});
let finishCleanup!: () => void;
const cleanup = new Promise<void>(resolve => {
finishCleanup = resolve;
});
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => {
options.onStartupError?.(failure);
cleanupEntered();
await cleanup;
throw failure;
});
const running = runAsync(runtimePlatform);
running.catch(() => {});
try {
await entered;
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
} finally {
finishCleanup();
}
await expect(running).rejects.toBe(failure);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
}
);
it('keeps a teardown failure visible after Stop during preview setup', async () => {
let previewEntered!: () => void;
let finishPreview!: (value: {
previewPageUrl: string;
apiUrl: string;
closeAsync: () => Promise<void>;
}) => void;
const entered = new Promise<void>(resolve => {
previewEntered = resolve;
});
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
openPreviewAsync: () => {
previewEntered();
return new Promise(resolve => {
finishPreview = resolve;
});
},
finishAsync: stopAsync,
getLastPreviewActivityAt: jest.fn(),
});
const failed = new Error('host teardown failed');
stopAsync.mockRejectedValueOnce(failed);
const running = runAsync(BuildRuntimePlatform.LINUX);
running.catch(() => {});
await entered;
const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value;
watch.stop();
finishPreview({
previewPageUrl: 'https://preview.test',
apiUrl: 'https://server.test',
closeAsync: async () => {},
});
await expect(running).rejects.toBe(failed);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(stopAsync).toHaveBeenCalledTimes(1);
});
it('retires the startup watcher before lifetime waiting', async () => {
await runAsync(BuildRuntimePlatform.LINUX);
const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value;
expect(watch.completeAsync.mock.invocationCallOrder[0]).toBeLessThan(
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
);
});
it('reports the session token when serve-sim minted one', async () => {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: jest.fn().mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
sharePageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.example.test',
previewToken: 'tok-1',
closeAsync: jest.fn(),
}),
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopAsync,
});
@@ -385,72 +85,19 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: {
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
previewUrl: 'https://expo.dev/simulator-preview/preview-id',
previewApiUrl: 'https://web-preview.example.test',
webPreviewToken: 'tok-1',
previewToken: 'tok-1',
},
})
);
});
it('logs the authenticated link while publishing the public frame URL', async () => {
const privatePreviewUrl = 'https://expo.dev/private-preview';
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
openPreviewAsync: jest.fn().mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
sharePageUrl: privatePreviewUrl,
apiUrl: 'https://web-preview.example.test',
closeAsync: jest.fn(),
}),
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopAsync,
});
await runAsync(BuildRuntimePlatform.DARWIN, {
public_preview: { value: false },
private_preview_url: { value: privatePreviewUrl },
});
expect(logger.info).toHaveBeenCalledWith(
`Preview URL: ${privatePreviewUrl} (server: https://web-preview.example.test).`
);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: expect.objectContaining({
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
}),
})
);
});
it('does not log the public link when a private job lacks its authenticated URL', async () => {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
openPreviewAsync: jest.fn().mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.example.test',
closeAsync: jest.fn(),
}),
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopAsync,
});
await runAsync(BuildRuntimePlatform.DARWIN, { public_preview: { value: false } });
expect(logger.info).toHaveBeenCalledWith('Private preview URL unavailable for this session.');
expect(jest.mocked(logger.info).mock.calls).not.toEqual(
expect.arrayContaining([
expect.arrayContaining([expect.stringContaining('/simulator-preview/preview-id')]),
])
);
});
it.each(['preview', 'config', 'wait'])('finishes the session after %s fails', async phase => {
const error = new Error(`${phase} failed`);
if (phase === 'preview') {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
openPreviewAsync: jest.fn().mockRejectedValue(error),
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopAsync,
});
} else if (phase === 'config') {
@@ -466,55 +113,29 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
[BuildRuntimePlatform.DARWIN, true],
[BuildRuntimePlatform.LINUX, false],
])('starts the web preview for %s', async (runtimePlatform, selectsXcode) => {
await runAsync(runtimePlatform, selectsXcode ? { boot_simulator: { value: true } } : {});
await runAsync(runtimePlatform);
expect(
jest
.mocked(logger.info)
.mock.calls.filter(
([message]) =>
message ===
'Preview URL: https://expo.dev/simulator-preview/preview-id (server: https://web-preview.example.test).'
)
).toHaveLength(1);
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledTimes(selectsXcode ? 1 : 0);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(ctx, {
runtimePlatform,
env: childEnv,
env,
logger,
timeoutMs: 60_000,
preview: { baseDomain: 'tunnel.example.com', authtoken: 'ngrok-token' },
signal: expect.any(AbortSignal),
onStartupError: expect.any(Function),
...(selectsXcode
? {
iosSimulatorUdid: 'SELECTED-IOS-UDID',
installAppPath: undefined,
bootEnv: undefined,
}
: {}),
packageVersion: '1.2.3',
disableShare: false,
frameAncestors: [],
corsOrigins: [],
launchAppIdentifier: undefined,
launchArgs: [],
openUrl: undefined,
networkCapture: false,
networkCaptureFields: [],
});
const host = await jest.mocked(startDeviceSessionHostAsync).mock.results[0].value;
expect(host.openPreviewAsync).toHaveBeenCalledWith();
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
ctx,
deviceRunSessionId: 'device-run-session-id',
remoteConfig: {
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
previewUrl: 'https://expo.dev/simulator-preview/preview-id',
previewApiUrl: 'https://web-preview.example.test',
},
logger,
signal: expect.any(AbortSignal),
});
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith({
ctx,
@@ -536,17 +157,9 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
'launch_app_identifier',
'launch_args',
'open_url',
'boot_simulator',
'device_identifier',
'install_app_path',
'network_capture',
'network_capture_fields',
'public_preview',
'private_preview_url',
'frame_ancestors',
'cors_origins',
'package_version',
'max_idle_time_minutes',
'max_duration_seconds',
]);
});
@@ -572,64 +185,6 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
);
});
it('hands the downloaded app and selected device to serve-sim with initial launch options', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, {
device_identifier: { value: 'chosen-device' },
install_app_path: { value: '/tmp/App.app' },
launch_args: { value: ['--literal', 'value with spaces'] },
open_url: { value: 'example://home' },
});
expect(
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
).toBeLessThan(jest.mocked(IosSimulatorUtils.resolveUdidAsync).mock.invocationCallOrder[0]);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
iosSimulatorUdid: 'SELECTED-IOS-UDID',
installAppPath: '/tmp/App.app',
launchAppIdentifier: 'dev.example.app',
launchArgs: ['--literal', 'value with spaces'],
openUrl: 'example://home',
})
);
});
it.each([true, false])(
'maps public_preview=%s to the native Share option',
async publicPreview => {
await runAsync(BuildRuntimePlatform.DARWIN, {
public_preview: { value: publicPreview },
private_preview_url: { value: 'https://expo.dev/private-preview' },
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
disableShare: !publicPreview,
privatePreviewUrl: 'https://expo.dev/private-preview',
})
);
}
);
it('hands frame_ancestors to the session host', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, {
frame_ancestors: { value: ['https://embed.example.test'] },
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ frameAncestors: ['https://embed.example.test'] })
);
});
it('hands cors_origins to the session host', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, {
cors_origins: { value: ['https://app.example.test'] },
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ corsOrigins: ['https://app.example.test'] })
);
});
it('hands network capture to the session host', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, {
network_capture: { value: true },
@@ -1,260 +0,0 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import nock from 'nock';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import {
api,
editPath,
mockToken,
packageName,
serviceAccount,
} from '../../utils/android/__tests__/googlePlayTestUtils';
import { createSubmitToGooglePlayBuildFunction } from '../submitToGooglePlay';
jest.unmock('node-fetch');
jest.mock('promise-retry', () => {
const actual = jest.requireActual('promise-retry');
return (fn: Parameters<typeof actual>[0], options: object) =>
actual(fn, { ...options, minTimeout: 1, maxTimeout: 1, randomize: false });
});
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
let directory: string;
let keyPath: string;
let artifactPath: string;
const session = '/upload/test-session';
const createPath = `/androidpublisher/v3/applications/${packageName}/edits`;
beforeEach(async () => {
nock.disableNetConnect();
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'play-submit-test-'));
keyPath = path.join(directory, 'key.json');
artifactPath = path.join(directory, 'app.aab');
await fs.writeFile(artifactPath, 'binary');
await fs.writeFile(
keyPath,
JSON.stringify({
...serviceAccount,
type: 'service_account',
private_key: serviceAccount.private_key.export({ format: 'pem', type: 'pkcs8' }),
})
);
});
afterEach(async () => {
const pending = nock.pendingMocks();
nock.cleanAll();
nock.enableNetConnect();
await fs.rm(directory, { recursive: true, force: true });
expect(pending).toEqual([]);
});
function submission(extra: Record<string, unknown> = {}, signal?: AbortSignal) {
const fn = createSubmitToGooglePlayBuildFunction();
const step = fn.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs: {
artifact_path: artifactPath,
artifact_type: 'aab',
package_name: packageName,
service_account_key_path: keyPath,
...extra,
},
});
const setters = step.outputs.map(output => jest.spyOn(output, 'set'));
return {
setters,
step,
run: async () =>
await fn.fn!(step.ctx, {
inputs: Object.fromEntries(
step.inputs!.map(input => [input.id, { value: input.rawValue }])
),
outputs: Object.fromEntries(step.outputs.map(output => [output.id, output])),
env: {},
signal,
}),
};
}
function upload() {
mockToken();
api().post(createPath).reply(200, { id: 'edit' });
api()
.post(`/upload${editPath}/bundles`)
.query({ uploadType: 'resumable' })
.reply(200, '', { Location: `https://androidpublisher.googleapis.com${session}` });
nock('https://androidpublisher.googleapis.com').put(session).reply(200, { versionCode: 145 });
}
it.each([
['completed', undefined, { status: 'completed' }],
['completed', 0, 'invalid'],
['completed', 0.25, { status: 'inProgress', userFraction: 0.25 }],
['completed', 1, { status: 'completed' }],
['draft', undefined, { status: 'draft' }],
['draft', 0, 'invalid'],
['draft', 0.25, 'invalid'],
['draft', 1, 'invalid'],
['halted', undefined, 'invalid'],
['halted', 0, 'invalid'],
['halted', 0.25, 'invalid'],
['halted', 1, 'invalid'],
['inProgress', undefined, 'invalid'],
['inProgress', 0, 'invalid'],
['inProgress', 0.25, { status: 'inProgress', userFraction: 0.25 }],
['inProgress', 1, { status: 'completed' }],
] as const)('validates and submits %s with rollout %s', async (status, rollout, expected) => {
const task = submission({
release_status: status,
...(rollout !== undefined ? { rollout } : {}),
});
if (expected === 'invalid') {
await expect(task.run()).rejects.toThrow(status === 'halted' ? /release_status/ : /rollout/);
expect(task.setters.every(set => set.mock.calls.length === 0)).toBe(true);
return;
}
upload();
api()
.put(`${editPath}/tracks/internal`, {
track: 'internal',
releases: [{ ...expected, versionCodes: ['145'] }],
})
.reply(200, { track: 'internal' });
api()
.post(`${editPath}:commit`)
.query({})
.reply(() => {
expect(task.setters.every(set => set.mock.calls.length === 0)).toBe(true);
return [200, { id: 'edit' }];
});
await task.run();
expect(task.setters.map(set => set.mock.calls)).toEqual([[['145']]]);
});
it('runs the step with a string rollout and validates its required output', async () => {
upload();
api()
.put(`${editPath}/tracks/internal`, {
track: 'internal',
releases: [{ status: 'inProgress', userFraction: 0.25, versionCodes: ['145'] }],
})
.reply(200, { track: 'internal' });
api().post(`${editPath}:commit`).query({}).reply(200, { id: 'edit' });
const { step } = submission({ rollout: '0.25' });
expect(step.__hookId).toBe('submit');
await step.executeAsync();
expect(step.outputs[0].value).toBe('145');
});
it.each(['upload', 'track', 'abort'] as const)(
'deletes the edit after %s failure and leaves outputs unset',
async failure => {
const controller = new AbortController();
const task = submission({}, controller.signal);
mockToken();
api().post(createPath).reply(200, { id: 'edit' });
api()
.post(`/upload${editPath}/bundles`)
.query({ uploadType: 'resumable' })
.reply(() => {
if (failure === 'abort') {
controller.abort(new Error('step timed out'));
}
return failure === 'upload'
? [400, { error: { message: 'rejected binary' } }]
: [200, '', { Location: `https://androidpublisher.googleapis.com${session}` }];
});
if (failure === 'track') {
nock('https://androidpublisher.googleapis.com').put(session).reply(200, { versionCode: 145 });
api()
.put(`${editPath}/tracks/internal`)
.reply(400, { error: { message: 'unknown track' } });
}
api().delete(editPath).reply(204);
await expect(task.run()).rejects.toThrow();
expect(task.setters.every(set => set.mock.calls.length === 0)).toBe(true);
}
);
it('preserves the original failure when cleanup fails', async () => {
upload();
api()
.put(`${editPath}/tracks/internal`)
.reply(400, { error: { message: 'unknown track' } });
api().delete(editPath).times(6).reply(500);
await expect(submission().run()).rejects.toMatchObject({
cause: expect.objectContaining({ apiMessage: 'unknown track' }),
});
});
it('does not delete the edit or publish outputs when commit outcome is unknown', async () => {
upload();
api().put(`${editPath}/tracks/internal`).reply(200, { track: 'internal' });
api().post(`${editPath}:commit`).query(true).reply(503);
api().post(`${editPath}:commit`).query(true).reply(404);
let deleted = false;
api()
.delete(editPath)
.optionally()
.reply(() => {
deleted = true;
return [204];
});
const task = submission();
const error = await task.run().catch(error => error);
expect(error).toBeInstanceOf(SystemError);
expect((error as Error).message).toContain('HTTP 404');
expect((error as Error).message).toContain('Could not confirm whether Google Play saved');
expect(deleted).toBe(false);
expect(task.setters.every(set => set.mock.calls.length === 0)).toBe(true);
});
it('reports a missing service-account file with its path', async () => {
const missing = path.join(directory, 'missing.json');
await expect(submission({ service_account_key_path: missing }).run()).rejects.toThrow(
`Service-account key file not found at ${missing}`
);
});
it('does not expose invalid credential contents', async () => {
await fs.writeFile(keyPath, 'PRIVATE_SECRET_INVALID_JSON');
const error = await submission()
.run()
.catch(error => error);
expect(error).toBeInstanceOf(UserError);
expect(JSON.stringify(error) + String(error)).not.toContain('PRIVATE_SECRET');
});
it('preserves the cause when the service-account file cannot be read', async () => {
const cause = Object.assign(new Error('permission denied'), { code: 'EACCES' });
const read = jest.spyOn(fs, 'readFile').mockRejectedValueOnce(cause);
try {
await expect(submission().run()).rejects.toMatchObject({ cause });
} finally {
read.mockRestore();
}
});
it('logs a formatted rollout and confirms the track update before committing', async () => {
upload();
const task = submission({ rollout: 0.29 });
const info = jest.spyOn(task.step.ctx.logger, 'info');
const updatedMessage = 'Updated Google Play track "internal" in the edit.';
let loggedBeforeUpdate = false;
let messagesBeforeCommit: unknown[] = [];
api()
.put(`${editPath}/tracks/internal`)
.reply(() => {
loggedBeforeUpdate = info.mock.calls.some(([message]) => message === updatedMessage);
return [200, { track: 'internal' }];
});
api()
.post(`${editPath}:commit`)
.query({})
.reply(() => {
messagesBeforeCommit = info.mock.calls.map(([message]) => message);
return [200, { id: 'edit' }];
});
await task.run();
expect(info).toHaveBeenCalledWith('Release status: inProgress. Rollout: 29%.');
expect(loggedBeforeUpdate).toBe(false);
expect(messagesBeforeCommit.slice(-2)).toEqual([
updatedMessage,
'Committing the Google Play edit...',
]);
});
@@ -1,407 +0,0 @@
import { BuildStep, spawnAsync } from '@expo/steps';
import fs from 'node:fs';
import path from 'node:path';
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
jest.mock('@expo/steps', () => ({
...jest.requireActual('@expo/steps'),
spawnAsync: jest.fn(),
}));
const mockedSpawnAsync = jest.mocked(spawnAsync);
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createTestAndroidJob } from '../../../__tests__/utils/job';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { BuildContext } from '../../../context';
import { CustomBuildContext } from '../../../customBuildContext';
import { parseInfoPlistBuffer } from '../readIpaInfo';
import {
createUpdateResolvedVersionBuildFunction,
extractAndroidVersionAsync,
extractIosVersionAsync,
parseAaptOutput,
parseManifestXml,
} from '../updateResolvedVersion';
describe(parseAaptOutput, () => {
it('extracts versionName and versionCode from aapt2 output', () => {
const output = `package: name='com.example.app' versionCode='42' versionName='2.5.0' platformBuildVersionName='14' platformBuildVersionCode='34' compileSdkVersion='34' compileSdkVersionCodename='14'
sdkVersion:'21'
targetSdkVersion:'34'
uses-permission: name='android.permission.INTERNET'`;
expect(parseAaptOutput(output)).toEqual({
appVersion: '2.5.0',
appBuildVersion: '42',
});
});
it('handles output with no version info', () => {
expect(parseAaptOutput('some unrelated output')).toEqual({
appVersion: undefined,
appBuildVersion: undefined,
});
});
it('handles versionName with prerelease suffix', () => {
const output = `package: name='com.example' versionCode='1' versionName='1.0.0-beta.1'`;
expect(parseAaptOutput(output)).toEqual({
appVersion: '1.0.0-beta.1',
appBuildVersion: '1',
});
});
it('keeps an unescaped apostrophe in versionName', () => {
const output = `package: name='com.example' versionCode='1' versionName='1.0-O'Brien' platformBuildVersionName='14' platformBuildVersionCode='34' compileSdkVersion='34'
sdkVersion:'21'`;
expect(parseAaptOutput(output)).toEqual({
appVersion: "1.0-O'Brien",
appBuildVersion: '1',
});
});
it('keeps a versionName that contains a quote and an equals sign', () => {
const output = `package: name='com.example' versionCode='1' versionName='1.0' build=beta'
sdkVersion:'21'`;
expect(parseAaptOutput(output)).toEqual({
appVersion: "1.0' build=beta",
appBuildVersion: '1',
});
});
it('stops versionName at the split field of a split APK', () => {
const output = `package: name='com.example' versionCode='42' versionName='2.5.0' split='feature' platformBuildVersionName='14' platformBuildVersionCode='34' compileSdkVersion='34'
sdkVersion:'21'`;
expect(parseAaptOutput(output)).toEqual({
appVersion: '2.5.0',
appBuildVersion: '42',
});
});
it('treats an empty versionName as missing', () => {
const output = `package: name='com.example' versionCode='42' versionName='' platformBuildVersionName='14' platformBuildVersionCode='34' compileSdkVersion='34'
sdkVersion:'21'`;
expect(parseAaptOutput(output)).toEqual({
appVersion: undefined,
appBuildVersion: '42',
});
});
});
describe(parseManifestXml, () => {
it('extracts versionName and versionCode from bundletool manifest XML', () => {
const xml = `<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
android:versionCode="10"
android:versionName="3.1.0"
package="com.example.app">
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" />
</manifest>`;
expect(parseManifestXml(xml)).toEqual({
appVersion: '3.1.0',
appBuildVersion: '10',
});
});
it('handles XML with no version attributes', () => {
const xml = `<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.app">
</manifest>`;
expect(parseManifestXml(xml)).toEqual({
appVersion: undefined,
appBuildVersion: undefined,
});
});
});
describe('simulator .app Info.plist parsing', () => {
const FIXTURES_DIR = path.join(__dirname, 'fixtures');
const SIMULATOR_APP_DIR = path.join(FIXTURES_DIR, 'TestSimulator.app');
const INFO_PLIST_PATH = path.join(SIMULATOR_APP_DIR, 'Info.plist');
beforeAll(async () => {
// Create a minimal XML Info.plist fixture
await fs.promises.mkdir(SIMULATOR_APP_DIR, { recursive: true });
await fs.promises.writeFile(
INFO_PLIST_PATH,
`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>com.example.test</string>
<key>CFBundleShortVersionString</key>
<string>4.2.0</string>
<key>CFBundleVersion</key>
<string>99</string>
</dict>
</plist>`
);
});
afterAll(async () => {
await fs.promises.rm(SIMULATOR_APP_DIR, { recursive: true, force: true });
});
it('reads version from a .app Info.plist', async () => {
const buffer = await fs.promises.readFile(INFO_PLIST_PATH);
const infoPlist = parseInfoPlistBuffer(buffer);
expect(infoPlist.CFBundleShortVersionString).toBe('4.2.0');
expect(infoPlist.CFBundleVersion).toBe('99');
});
});
describe(extractIosVersionAsync, () => {
const FIXTURES_DIR = path.join(__dirname, 'fixtures');
const SIMULATOR_APP_DIR = path.join(FIXTURES_DIR, 'ExtractVersion.app');
const INFO_PLIST_PATH = path.join(SIMULATOR_APP_DIR, 'Info.plist');
const IPA_FIXTURE_PATH = path.join(FIXTURES_DIR, 'SmallestAppExample.ipa');
beforeAll(async () => {
await fs.promises.mkdir(SIMULATOR_APP_DIR, { recursive: true });
await fs.promises.writeFile(
INFO_PLIST_PATH,
`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleShortVersionString</key>
<string>2.0.1</string>
<key>CFBundleVersion</key>
<string>55</string>
</dict>
</plist>`
);
});
afterAll(async () => {
await fs.promises.rm(SIMULATOR_APP_DIR, { recursive: true, force: true });
});
it('extracts version from a .app directory', async () => {
const result = await extractIosVersionAsync(SIMULATOR_APP_DIR);
expect(result).toEqual({
appVersion: '2.0.1',
appBuildVersion: '55',
});
});
it('extracts version from an .ipa file', async () => {
const result = await extractIosVersionAsync(IPA_FIXTURE_PATH);
expect(result).toEqual({
appVersion: '1.0',
appBuildVersion: '1',
});
});
});
describe(extractAndroidVersionAsync, () => {
afterEach(() => {
mockedSpawnAsync.mockReset();
});
it('extracts version from an .apk via aapt2', async () => {
mockedSpawnAsync.mockResolvedValue({
stdout: `package: name='com.example.app' versionCode='42' versionName='2.5.0'`,
stderr: '',
} as any);
const result = await extractAndroidVersionAsync('/path/to/app.apk');
expect(mockedSpawnAsync).toHaveBeenCalledWith(
'aapt2',
['dump', 'badging', '/path/to/app.apk'],
{
stdio: 'pipe',
}
);
expect(result).toEqual({
appVersion: '2.5.0',
appBuildVersion: '42',
});
});
it('extracts version from an .aab via bundletool', async () => {
mockedSpawnAsync.mockResolvedValue({
stdout: `<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
android:versionCode="10"
android:versionName="3.1.0"
package="com.example.app">
</manifest>`,
stderr: '',
} as any);
const result = await extractAndroidVersionAsync('/path/to/app.aab');
expect(mockedSpawnAsync).toHaveBeenCalledWith(
'bundletool',
['dump', 'manifest', '--bundle', '/path/to/app.aab'],
{ stdio: 'pipe' }
);
expect(result).toEqual({
appVersion: '3.1.0',
appBuildVersion: '10',
});
});
it('returns empty object for unknown extension', async () => {
const result = await extractAndroidVersionAsync('/path/to/archive.zip');
expect(mockedSpawnAsync).not.toHaveBeenCalled();
expect(result).toEqual({});
});
});
describe(createUpdateResolvedVersionBuildFunction, () => {
afterEach(() => {
mockedSpawnAsync.mockReset();
});
function createStep({
env,
archivePath,
workingDirectory,
mutationResult = { data: {}, error: undefined },
}: {
env: Record<string, string>;
archivePath?: string;
workingDirectory?: string;
mutationResult?: { data?: unknown; error?: Error };
}): { step: BuildStep; mutation: jest.Mock } {
const mutation = jest.fn(() => ({ toPromise: async () => mutationResult }));
const ctx = new BuildContext(createTestAndroidJob({}), {
env: { __API_SERVER_URL: 'http://api.expo.test', ...env },
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
uploadArtifact: jest.fn(),
workingdir: '',
});
ctx.graphqlClient.mutation = mutation as any;
const step = createUpdateResolvedVersionBuildFunction(
new CustomBuildContext(ctx)
).createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs: archivePath ? { application_archive_path: archivePath } : {},
workingDirectory,
});
return { step, mutation };
}
it('resolves a relative archive path against the step working directory', async () => {
mockedSpawnAsync.mockResolvedValue({
stdout: `package: name='com.example.app' versionCode='42' versionName='2.5.0'`,
stderr: '',
} as any);
const { step, mutation } = createStep({
env: { EAS_BUILD_ID: 'build-id' },
archivePath: 'android/app/build/outputs/apk/release/app-release.apk',
workingDirectory: './my-app',
});
await step.executeAsync();
const expectedPath = path.join(
step.ctx.workingDirectory,
'android/app/build/outputs/apk/release/app-release.apk'
);
expect(path.isAbsolute(expectedPath)).toBe(true);
expect(mockedSpawnAsync).toHaveBeenCalledWith('aapt2', ['dump', 'badging', expectedPath], {
stdio: 'pipe',
});
expect(mutation).toHaveBeenCalledTimes(1);
});
it('updates the resolved versions for the build', async () => {
mockedSpawnAsync.mockResolvedValue({
stdout: `package: name='com.example.app' versionCode='42' versionName='2.5.0'`,
stderr: '',
} as any);
const { step, mutation } = createStep({
env: { EAS_BUILD_ID: 'build-id' },
archivePath: '/path/to/app.apk',
});
await step.executeAsync();
expect(mutation).toHaveBeenCalledTimes(1);
expect(mutation.mock.calls[0][1]).toEqual({
buildId: 'build-id',
appVersion: '2.5.0',
appBuildVersion: '42',
});
});
it('omits a missing value instead of sending null', async () => {
mockedSpawnAsync.mockResolvedValue({
stdout: `package: name='com.example.app' versionCode='42'`,
stderr: '',
} as any);
const { step, mutation } = createStep({
env: { EAS_BUILD_ID: 'build-id' },
archivePath: '/path/to/app.apk',
});
await step.executeAsync();
const variables = mutation.mock.calls[0][1];
expect(variables.appBuildVersion).toBe('42');
expect(variables.appVersion).toBeUndefined();
expect(variables.appVersion).not.toBeNull();
});
it('does nothing without a build ID', async () => {
const { step, mutation } = createStep({ env: {}, archivePath: '/path/to/app.apk' });
await step.executeAsync();
expect(mockedSpawnAsync).not.toHaveBeenCalled();
expect(mutation).not.toHaveBeenCalled();
});
it('does nothing without an archive path', async () => {
const { step, mutation } = createStep({ env: { EAS_BUILD_ID: 'build-id' } });
await step.executeAsync();
expect(mockedSpawnAsync).not.toHaveBeenCalled();
expect(mutation).not.toHaveBeenCalled();
});
it('does not fail the step when extraction or the mutation fails', async () => {
mockedSpawnAsync.mockRejectedValueOnce(new Error('aapt2: command not found'));
const failingExtraction = createStep({
env: { EAS_BUILD_ID: 'build-id' },
archivePath: '/path/to/app.apk',
});
await expect(failingExtraction.step.executeAsync()).resolves.not.toThrow();
expect(failingExtraction.mutation).not.toHaveBeenCalled();
mockedSpawnAsync.mockResolvedValue({
stdout: `package: name='com.example.app' versionCode='42' versionName='2.5.0'`,
stderr: '',
} as any);
const failingMutation = createStep({
env: { EAS_BUILD_ID: 'build-id' },
archivePath: '/path/to/app.apk',
mutationResult: { error: new Error('GraphQL error') },
});
await expect(failingMutation.step.executeAsync()).resolves.not.toThrow();
expect(failingMutation.mutation).toHaveBeenCalledTimes(1);
});
});
@@ -3,7 +3,6 @@ import { generateKeyPairSync } from 'node:crypto';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { Sentry } from '../../../sentry';
import { AscApiClient } from '../../utils/ios/AscApiClient';
import {
createUpdateTestFlightMetadataBuildFunction,
@@ -11,7 +10,6 @@ import {
} from '../updateTestFlightMetadata';
jest.unmock('node-fetch');
jest.mock('../../../sentry', () => ({ Sentry: { capture: jest.fn() } }));
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
const key = { keyId: 'TESTKEY', privateKey };
@@ -58,21 +56,8 @@ function mockAssignedGroups(ids: string[] = []): void {
.reply(200, { data: ids.map(id => ({ id })) });
}
function mockInternalBuildState(state: string | null = 'READY_FOR_BETA_TESTING'): void {
api()
.get('/v1/builds/build/buildBetaDetail')
.query(true)
.reply(
state ? 200 : 503,
state
? { data: { id: 'detail', attributes: { internalBuildState: state } } }
: { errors: [{ code: 'UNAVAILABLE' }] }
);
}
beforeAll(() => nock.disableNetConnect());
beforeEach(() => {
jest.mocked(Sentry.capture).mockClear();
options.logger = createMockLogger();
options.client = new AscApiClient({ key });
});
@@ -393,7 +378,6 @@ it('attempts all localization changes and reports every failed update', async ()
it.each([1, 2])('explains automatic internal group rejection (%s errors)', async errorCount => {
mockBuild();
mockInternalBuildState();
mockAssignedGroups();
api()
.get('/v1/betaGroups')
@@ -454,7 +438,6 @@ it('tries group assignment if the changelog update fails', async () => {
it('reports both write failures', async () => {
mockBuild();
mockInternalBuildState();
mockAssignedGroups();
api()
.get('/v1/betaGroups')
@@ -476,7 +459,6 @@ it.each([false, true])(
'attempts all groups and reports assignment errors (multiple: %s)',
async multiple => {
mockBuild();
mockInternalBuildState();
mockAssignedGroups();
api()
.get('/v1/betaGroups')
@@ -502,7 +484,7 @@ it.each([false, true])(
.reply(204);
await expect(
updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A', 'B', 'C'] })
).rejects.toThrow(multiple ? 'FIRST_GROUP_FAILED' : 'Partial assignment');
).rejects.toThrow(multiple ? /FIRST_GROUP_FAILED.*Partial assignment/ : 'Partial assignment');
expect(options.logger.error).toHaveBeenCalledWith(
expect.stringContaining('Group "B" (B): assignment failed.')
);
@@ -549,73 +531,3 @@ it('propagates Apple write failures', async () => {
.reply(403, { errors: [{ code: 'FORBIDDEN', detail: 'Not allowed' }] });
await expect(updateTestFlightMetadataAsync(options)).rejects.toThrow('403');
});
it.each([
['MISSING_EXPORT_COMPLIANCE', 'Complete the export compliance questions'],
['IN_EXPORT_COMPLIANCE_REVIEW', 'Wait for Apple to approve'],
['EXPIRED', 'ASSIGNMENT_FAILED'],
['PROCESSING', 'ASSIGNMENT_FAILED'],
['READY_FOR_BETA_TESTING', 'ASSIGNMENT_FAILED'],
['IN_BETA_TESTING', 'ASSIGNMENT_FAILED'],
[null, 'ASSIGNMENT_FAILED'],
] as const)(
'shares one diagnostic lookup across failed assignments (state: %s)',
async (state, guidance) => {
mockBuild();
mockAssignedGroups();
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200' })
.reply(200, {
data: ['A', 'B', 'C'].map(id => ({ id, attributes: { name: id } })),
});
api()
.post('/v1/builds/build/relationships/betaGroups', {
data: [{ type: 'betaGroups', id: 'A' }],
})
.reply(403, { errors: [{ code: 'ASSIGNMENT_FAILED' }] });
mockInternalBuildState(state);
api()
.post('/v1/builds/build/relationships/betaGroups', {
data: [{ type: 'betaGroups', id: 'B' }],
})
.reply(422, { errors: [{ code: 'ASSIGNMENT_FAILED' }] });
// A failed assignment must not prevent the next group from being assigned.
api()
.post('/v1/builds/build/relationships/betaGroups', {
data: [{ type: 'betaGroups', id: 'C' }],
})
.reply(204);
const failure = await updateTestFlightMetadataAsync({
...options,
changelog: '',
groups: ['A', 'B', 'C'],
}).catch(error => error);
expect(failure).toBeInstanceOf(AggregateError);
expect(failure.errors).toHaveLength(2);
if (state === 'MISSING_EXPORT_COMPLIANCE' || state === 'IN_EXPORT_COMPLIANCE_REVIEW') {
expect(failure.errors[0]).toBe(failure.errors[1]);
} else {
expect(failure.errors[0]).toMatchObject({ status: 403 });
expect(failure.errors[1]).toMatchObject({ status: 422 });
}
expect(failure.errors[0].message).toContain(guidance);
expect(
jest
.mocked(options.logger.info)
.mock.calls.filter(([message]) => String(message).includes('internal TestFlight state ='))
).toEqual([[`Apple build build: internal TestFlight state = ${state ?? 'UNKNOWN'}.`]]);
expect(Sentry.capture).toHaveBeenCalledTimes(1);
expect(Sentry.capture).toHaveBeenCalledWith(
`TestFlight group assignment failed (state: ${state ?? 'UNKNOWN'})`,
{
level: 'error',
tags: { step: 'eas/update_testflight_metadata', internal_build_state: state ?? 'UNKNOWN' },
extras: {
buildId: 'build',
assignmentError: expect.stringContaining('ASSIGNMENT_FAILED'),
},
}
);
}
);
@@ -121,32 +121,26 @@ describe(createUploadArtifactBuildFunction, () => {
expect(typeInput.isRawValueOneOfAllowedValues()).toBe(false);
});
it.each(['other', 'android-apk', 'android-aab', 'ios-simulator-app', 'ios-ipa'])(
'uploads %s as a workflow artifact with metadata',
async type => {
contextUploadArtifact.mockClear();
const metadata = { kind: 'screen-recording', startedAtMs: '123', device: 'emulator-5554' };
const buildStep = uploadArtifact.createBuildStepFromFunctionCall(
createGlobalContextMock({}),
{
callInputs: {
type,
key: 'Screen Recording',
path: '/',
metadata,
},
}
);
it('passes the metadata input through to runtimeApi.uploadArtifact', async () => {
contextUploadArtifact.mockClear();
const metadata = { kind: 'screen-recording', startedAtMs: '123', device: 'emulator-5554' };
const buildStep = uploadArtifact.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs: {
// Generic ('other') artifact: only generic artifacts persist metadata in the worker
// (the managed-type upload paths drop it), so that's the type metadata is meant for.
type: 'other',
key: 'Screen Recording',
path: '/',
metadata,
},
});
await buildStep.executeAsync();
await buildStep.executeAsync();
expect(contextUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({ type: null, metadata }),
})
);
}
);
expect(contextUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({ artifact: expect.objectContaining({ metadata }) })
);
});
it('does not attach metadata to managed artifact types (the worker drops it)', async () => {
contextUploadArtifact.mockClear();
@@ -20,8 +20,7 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockReset()
.mockImplementation(async (_ctx, { openStream }) => {
const stream = openStream();
.mockImplementation(async (_ctx, { stream }) => {
await new Promise<void>((resolve, reject) => {
stream.once('end', resolve);
stream.once('error', reject);
@@ -140,12 +139,9 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
await buildStep.executeAsync();
const uploads = jest
const names = jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mock.calls.map(([, options]) => options);
const names = uploads
.filter(options => options.kind === 'screen-recording')
.map(options => options.name);
.mock.calls.map(([, options]) => options.name);
expect(names).toEqual(
expect.arrayContaining([
'iPhone 16 screen recording (01234567-…, started at Jul 10, 2026, 10:00:00.000 UTC)',
@@ -154,12 +150,6 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
])
);
expect(new Set(names).size).toBe(3);
const metadata = uploads.filter(options => options.kind === 'screen-recording-metadata');
expect(metadata.map(options => options.name)).toEqual(
expect.arrayContaining(names.map(name => `${name} metadata`))
);
expect(metadata).toHaveLength(3);
expect(uploads).toHaveLength(6);
} finally {
await Promise.all(
recordingDirectories.map(directory => fs.rm(directory, { recursive: true, force: true }))
@@ -1,129 +0,0 @@
import { ProjectConfig } from '@expo/config';
import { BuildJob } from '@expo/eas-build-job';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createTestAndroidJob } from '../../../__tests__/utils/job';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { BuildContext } from '../../../context';
import { CustomBuildContext } from '../../../customBuildContext';
import { readAppConfig } from '../../../utils/appConfig';
import { uploadEmbeddedBundleAsync } from '../../../utils/expoUpdatesEmbedded';
import { createUploadEmbeddedBundleBuildFunction } from '../uploadEmbeddedBundle';
jest.mock('../../../utils/appConfig');
jest.mock('../../../utils/expoUpdatesEmbedded');
function mockAppConfig(updatesUrl: string | undefined): void {
jest.mocked(readAppConfig).mockResolvedValue({
exp: { name: 'my-app', slug: 'my-app', updates: updatesUrl ? { url: updatesUrl } : undefined },
} as ProjectConfig);
}
function createCustomContext(job: BuildJob): CustomBuildContext<BuildJob> {
return new CustomBuildContext(
new BuildContext(job, {
env: { __API_SERVER_URL: 'http://api.expo.test' },
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
uploadArtifact: jest.fn(),
workingdir: '',
})
);
}
describe(createUploadEmbeddedBundleBuildFunction, () => {
const job = createTestAndroidJob({});
const customContext = createCustomContext(job);
beforeEach(() => {
jest.resetAllMocks();
});
it('uploads the embedded bundle from the working directory', async () => {
mockAppConfig('https://u.expo.dev/project-id');
jest.mocked(uploadEmbeddedBundleAsync).mockResolvedValue({ status: 'uploaded' });
const globalContext = createGlobalContextMock({});
globalContext.updateEnv({ EAS_BUILD_ID: 'build-123' });
const buildStep =
createUploadEmbeddedBundleBuildFunction(customContext).createBuildStepFromFunctionCall(
globalContext
);
await buildStep.executeAsync();
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(
expect.objectContaining({
job,
env: expect.objectContaining({ EAS_BUILD_ID: 'build-123' }),
projectDir: globalContext.defaultWorkingDirectory,
appConfig: expect.objectContaining({ updates: { url: 'https://u.expo.dev/project-id' } }),
})
);
});
it('logs when the embedded bundle upload is skipped', async () => {
mockAppConfig('https://u.expo.dev/project-id');
jest.mocked(uploadEmbeddedBundleAsync).mockResolvedValue({ status: 'skipped' });
const buildStep = createUploadEmbeddedBundleBuildFunction(
customContext
).createBuildStepFromFunctionCall(createGlobalContextMock({}));
await buildStep.executeAsync();
expect(buildStep.ctx.logger.info).toHaveBeenCalledWith('Skipping embedded bundle upload.');
});
it('fails the step when the upload fails', async () => {
mockAppConfig('https://u.expo.dev/project-id');
jest.mocked(uploadEmbeddedBundleAsync).mockResolvedValue({ status: 'failed' });
const buildStep = createUploadEmbeddedBundleBuildFunction(
customContext
).createBuildStepFromFunctionCall(createGlobalContextMock({}));
await expect(buildStep.executeAsync()).rejects.toThrow('Failed to upload embedded bundle.');
});
it('does not fail the step when the upload fails and ignore_error is set', async () => {
mockAppConfig('https://u.expo.dev/project-id');
jest.mocked(uploadEmbeddedBundleAsync).mockResolvedValue({ status: 'failed' });
const buildStep = createUploadEmbeddedBundleBuildFunction(
customContext
).createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs: { ignore_error: true },
});
await buildStep.executeAsync();
expect(buildStep.ctx.logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to upload embedded bundle. Ignoring error.'
);
});
it('fails the step when the app config cannot be read', async () => {
jest.mocked(readAppConfig).mockRejectedValue(new Error('Invalid app config'));
const buildStep = createUploadEmbeddedBundleBuildFunction(
customContext
).createBuildStepFromFunctionCall(createGlobalContextMock({}));
await expect(buildStep.executeAsync()).rejects.toThrow('Invalid app config');
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
});
it('does not fail the step when the app config cannot be read and ignore_error is set', async () => {
jest.mocked(readAppConfig).mockRejectedValue(new Error('Invalid app config'));
const buildStep = createUploadEmbeddedBundleBuildFunction(
customContext
).createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs: { ignore_error: true },
});
await buildStep.executeAsync();
expect(buildStep.ctx.logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to upload embedded bundle. Ignoring error.'
);
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
});
});
@@ -1,17 +1,7 @@
import { UserError } from '@expo/eas-build-job';
import fs from 'fs-extra';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { AscApiClient } from '../../utils/ios/AscApiClient';
import { AscApiUtils } from '../../utils/ios/AscApiUtils';
import {
createUploadToAscBuildFunction,
isClosedVersionTrainError,
isInvalidBundleIdentifierError,
isMissingAppIconsError,
isMissingBackgroundTaskIdentifiersError,
isMissingPurposeStringError,
isRedundantBinaryUploadError,
isSdkVersionIssueError,
parseMissingUsageDescriptionKeys,
} from '../uploadToAsc';
@@ -55,20 +45,6 @@ describe(isInvalidBundleIdentifierError, () => {
});
});
describe(isRedundantBinaryUploadError, () => {
it('returns true when all errors are redundant-binary-upload codes', () => {
expect(isRedundantBinaryUploadError([{ code: '90189' }, { code: '90189' }])).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(isRedundantBinaryUploadError([{ code: '90189' }, { code: '90062' }])).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isRedundantBinaryUploadError([])).toBe(false);
});
});
describe(isMissingPurposeStringError, () => {
it('returns true when all errors are missing-purpose-string codes', () => {
expect(isMissingPurposeStringError([{ code: '90683' }, { code: '90683' }])).toBe(true);
@@ -97,41 +73,6 @@ describe(isSdkVersionIssueError, () => {
});
});
describe(isMissingBackgroundTaskIdentifiersError, () => {
it('returns true when all errors are missing BG task identifiers codes', () => {
expect(isMissingBackgroundTaskIdentifiersError([{ code: '90771' }, { code: '90771' }])).toBe(
true
);
});
it('returns false when any other error code is present', () => {
expect(isMissingBackgroundTaskIdentifiersError([{ code: '90771' }, { code: '90062' }])).toBe(
false
);
});
it('returns false when there are no errors', () => {
expect(isMissingBackgroundTaskIdentifiersError([])).toBe(false);
});
});
describe(isMissingAppIconsError, () => {
it.each([['90391', '90713', '90713'], ['90713', '90022', '90023'], ['90022'], ['90023']])(
'recognizes missing-icon validation codes %j',
(...codes) => {
expect(isMissingAppIconsError(codes.map(code => ({ code })))).toBe(true);
}
);
it('returns false when any other error code is present', () => {
expect(isMissingAppIconsError([{ code: '90391' }, { code: '90771' }])).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isMissingAppIconsError([])).toBe(false);
});
});
describe(parseMissingUsageDescriptionKeys, () => {
it('extracts missing UsageDescription keys from ASC messages', () => {
expect(
@@ -169,176 +110,3 @@ describe(parseMissingUsageDescriptionKeys, () => {
).toEqual([]);
});
});
describe(createUploadToAscBuildFunction, () => {
it('keeps Apple duplicate-build details in the public error and the full state as its cause', async () => {
const state = {
state: 'FAILED',
errors: [
{
code: '90189',
description: 'Redundant Binary Upload. Build number 1 was already uploaded.',
},
],
warnings: [{ code: 'WARNING', description: 'Apple warning.' }],
infos: [{ code: 'INFO', description: 'Apple information.' }],
};
await fs.writeFile('/app.ipa', 'ipa');
await fs.writeFile('/key.json', '{}');
jest.spyOn(AscApiUtils, 'loadApiKeyAsync').mockResolvedValue({} as never);
jest.spyOn(AscApiUtils, 'getAppInfoAsync').mockResolvedValue({
data: { attributes: { name: 'App', bundleId: 'com.example.app' } },
} as never);
jest.spyOn(AscApiUtils, 'createBuildUploadAsync').mockResolvedValue({
data: { id: 'upload-id' },
} as never);
jest.spyOn(AscApiUtils, 'commitBuildUploadFileAsync').mockResolvedValue(undefined as never);
jest.spyOn(AscApiClient.prototype, 'postAsync').mockResolvedValue({
data: { id: 'file-id', attributes: { uploadOperations: [] } },
} as never);
jest
.spyOn(AscApiClient.prototype, 'getAsync')
.mockResolvedValueOnce({
data: { attributes: { assetDeliveryState: { state: 'COMPLETE' } } },
} as never)
.mockResolvedValueOnce({ data: { attributes: { state } } } as never);
try {
const step = createUploadToAscBuildFunction().createBuildStepFromFunctionCall(
createGlobalContextMock({ staticContextContent: { job: {} } }),
{
callInputs: {
ipa_path: '/app.ipa',
asc_api_key_path: '/key.json',
apple_app_identifier: 'app-id',
bundle_version: '1',
bundle_short_version: '1.0',
wait_for_processing: true,
},
}
);
const error = await step.executeAsync().catch(error => error);
expect(error).toBeInstanceOf(UserError);
expect(error.errorCode).toBe('EAS_UPLOAD_TO_ASC_VERSION_DUPLICATE');
expect(error.cause).toEqual(state);
expect(error.toExternalExpoError().message).toContain(
'Redundant Binary Upload. Build number 1 was already uploaded. (90189)'
);
} finally {
jest.restoreAllMocks();
}
});
});
describe(createUploadToAscBuildFunction, () => {
it('keeps Apple background-task details in the public error and the full state as its cause', async () => {
const state = {
state: 'FAILED',
errors: [
{
code: '90771',
description:
'Missing BGTaskSchedulerPermittedIdentifiers while UIBackgroundModes includes processing.',
},
],
warnings: [{ code: 'WARNING', description: 'Apple warning.' }],
infos: [{ code: 'INFO', description: 'Apple information.' }],
};
await fs.writeFile('/app.ipa', 'ipa');
await fs.writeFile('/key.json', '{}');
jest.spyOn(AscApiUtils, 'loadApiKeyAsync').mockResolvedValue({} as never);
jest.spyOn(AscApiUtils, 'getAppInfoAsync').mockResolvedValue({
data: { attributes: { name: 'App', bundleId: 'com.example.app' } },
} as never);
jest.spyOn(AscApiUtils, 'createBuildUploadAsync').mockResolvedValue({
data: { id: 'upload-id' },
} as never);
jest.spyOn(AscApiUtils, 'commitBuildUploadFileAsync').mockResolvedValue(undefined as never);
jest.spyOn(AscApiClient.prototype, 'postAsync').mockResolvedValue({
data: { id: 'file-id', attributes: { uploadOperations: [] } },
} as never);
jest
.spyOn(AscApiClient.prototype, 'getAsync')
.mockResolvedValueOnce({
data: { attributes: { assetDeliveryState: { state: 'COMPLETE' } } },
} as never)
.mockResolvedValueOnce({ data: { attributes: { state } } } as never);
try {
const step = createUploadToAscBuildFunction().createBuildStepFromFunctionCall(
createGlobalContextMock({ staticContextContent: { job: {} } }),
{
callInputs: {
ipa_path: '/app.ipa',
asc_api_key_path: '/key.json',
apple_app_identifier: 'app-id',
bundle_version: '1',
bundle_short_version: '1.0',
wait_for_processing: true,
},
}
);
const error = await step.executeAsync().catch(error => error);
expect(error).toBeInstanceOf(UserError);
expect(error.errorCode).toBe('EAS_UPLOAD_TO_ASC_MISSING_BG_TASK_IDENTIFIERS');
expect(error.cause).toEqual(state);
expect(error.toExternalExpoError().message).toContain(
'Missing BGTaskSchedulerPermittedIdentifiers while UIBackgroundModes includes processing. (90771)'
);
} finally {
jest.restoreAllMocks();
}
});
});
describe(createUploadToAscBuildFunction, () => {
it('keeps Apple validation details in the public error and the full state as its cause', async () => {
const state = {
state: 'FAILED',
errors: [{ code: '90713', description: 'Missing CFBundleIconName in Watch.app.' }],
warnings: [{ code: 'WARNING', description: 'Apple warning.' }],
infos: [{ code: 'INFO', description: 'Apple information.' }],
};
await fs.writeFile('/app.ipa', 'ipa');
await fs.writeFile('/key.json', '{}');
jest.spyOn(AscApiUtils, 'loadApiKeyAsync').mockResolvedValue({} as never);
jest.spyOn(AscApiUtils, 'getAppInfoAsync').mockResolvedValue({
data: { attributes: { name: 'App', bundleId: 'com.example.app' } },
} as never);
jest.spyOn(AscApiUtils, 'createBuildUploadAsync').mockResolvedValue({
data: { id: 'upload-id' },
} as never);
jest.spyOn(AscApiUtils, 'commitBuildUploadFileAsync').mockResolvedValue(undefined as never);
jest.spyOn(AscApiClient.prototype, 'postAsync').mockResolvedValue({
data: { id: 'file-id', attributes: { uploadOperations: [] } },
} as never);
jest
.spyOn(AscApiClient.prototype, 'getAsync')
.mockResolvedValueOnce({
data: { attributes: { assetDeliveryState: { state: 'COMPLETE' } } },
} as never)
.mockResolvedValueOnce({ data: { attributes: { state } } } as never);
try {
const step = createUploadToAscBuildFunction().createBuildStepFromFunctionCall(
createGlobalContextMock({ staticContextContent: { job: {} } }),
{
callInputs: {
ipa_path: '/app.ipa',
asc_api_key_path: '/key.json',
apple_app_identifier: 'app-id',
bundle_version: '1',
bundle_short_version: '1.0',
wait_for_processing: true,
},
}
);
const error = await step.executeAsync().catch(error => error);
expect(error).toBeInstanceOf(UserError);
expect(error.errorCode).toBe('EAS_UPLOAD_TO_ASC_MISSING_APP_ICONS');
expect(error.cause).toEqual(state);
expect(error.toExternalExpoError().message).toContain(
'Missing CFBundleIconName in Watch.app. (90713)'
);
} finally {
jest.restoreAllMocks();
}
});
});
Loaded 100 of 668 files, more files were not shown because too many files have changed in this diff. Show more