mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
106
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e507735166 | ||
|
|
9b54c203ee | ||
|
|
a0fe3ff2c3 | ||
|
|
fb383d818b | ||
|
|
c07c5edba9 | ||
|
|
56646b7436 | ||
|
|
f775b10549 | ||
|
|
c756c5d0b5 | ||
|
|
9f674b1d19 | ||
|
|
6f33a4d2a4 | ||
|
|
44b63e5a7d | ||
|
|
42e4fa8a7c | ||
|
|
e4bb181802 | ||
|
|
53ac936b11 | ||
|
|
c0d3f4f935 | ||
|
|
5ec3d84fcb | ||
|
|
d0cb5a2d67 | ||
|
|
aae7d13ac0 | ||
|
|
d9b1f866d4 | ||
|
|
a03dca45eb | ||
|
|
f949f3430e | ||
|
|
e61a563b70 | ||
|
|
150cd21174 | ||
|
|
4b9e792e91 | ||
|
|
0199cd9331 | ||
|
|
ffe6bda88d | ||
|
|
904859b824 | ||
|
|
3fd966bb9e | ||
|
|
aa9f5370d4 | ||
|
|
60a0b483eb | ||
|
|
070f15c633 | ||
|
|
c8aa5eb05d | ||
|
|
68596f8c0c | ||
|
|
1e38ada929 | ||
|
|
d52629e9f3 | ||
|
|
bed6354ef5 | ||
|
|
9aac1a0dff | ||
|
|
c1c2b748b4 | ||
|
|
7b39b92df8 | ||
|
|
929905ba7e | ||
|
|
ef7fceba86 | ||
|
|
c2dc766c38 | ||
|
|
785cfe2d53 | ||
|
|
18a008a39a | ||
|
|
f5be6b8d88 | ||
|
|
c4064ea9ba | ||
|
|
f1dda5df94 | ||
|
|
4ec2a5fe9e | ||
|
|
1068fab6ac | ||
|
|
3e00e86e5a | ||
|
|
69a4a06320 | ||
|
|
219df86f88 | ||
|
|
50e1f2aea5 | ||
|
|
4400b93ed3 | ||
|
|
52c1a4ce54 | ||
|
|
f97e6ecfa8 | ||
|
|
6d8c29cc34 | ||
|
|
2630625440 | ||
|
|
7ebd9f1a97 | ||
|
|
8545feb433 | ||
|
|
14e14e3017 | ||
|
|
338d367282 | ||
|
|
102f06cc50 | ||
|
|
f96c266645 | ||
|
|
5739556d67 | ||
|
|
821dcd1a96 | ||
|
|
3b59a744da | ||
|
|
73edec4d68 | ||
|
|
13fe586f3f | ||
|
|
c3413315d1 | ||
|
|
6c256fd9d2 | ||
|
|
6683492955 | ||
|
|
964b4ed93f | ||
|
|
34e8b45bad | ||
|
|
d1de958201 | ||
|
|
2b672fc07f | ||
|
|
8453efcfb6 | ||
|
|
0456e8eea6 | ||
|
|
0ec9a81552 | ||
|
|
3b4fedeee5 | ||
|
|
570617f467 | ||
|
|
8262aa34c6 | ||
|
|
1f72736126 | ||
|
|
3328b60f85 | ||
|
|
4abb8601d5 | ||
|
|
9a881d8366 | ||
|
|
657034a1a6 | ||
|
|
bb98f5beef | ||
|
|
4aca8b1cc4 | ||
|
|
800a0e0893 | ||
|
|
2911b04ea1 | ||
|
|
50b381abe0 | ||
|
|
c8696f6012 | ||
|
|
45d795e076 | ||
|
|
8e98a915b9 | ||
|
|
73c65d3edb | ||
|
|
01e3be71aa | ||
|
|
991bbeae8c | ||
|
|
6c342abb97 | ||
|
|
1016aa35f3 | ||
|
|
ceb8f846c2 | ||
|
|
53cb3a7263 | ||
|
|
09188ab271 | ||
|
|
c4ffc1605e | ||
|
|
d90f022966 | ||
|
|
8b8362e4ae |
No files matched your search
@@ -0,0 +1,39 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@expo/eas-build-job': minor
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,11 @@
|
||||
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;
|
||||
@@ -0,0 +1,50 @@
|
||||
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;
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"$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"
|
||||
]
|
||||
],
|
||||
"linked": [],
|
||||
"ignore": [],
|
||||
"privatePackages": {
|
||||
"version": true,
|
||||
"tag": false
|
||||
},
|
||||
"updateInternalDependencies": "patch"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Retry the build lookup and the archive download of `eas/download_build` on transient failures, and name the failure cause in the build log.
|
||||
@@ -0,0 +1 @@
|
||||
export declare function formatEntry(summary: string, attribution: string): string;
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': none
|
||||
---
|
||||
|
||||
Make command descriptions in `eas --help` consistently lowercase without trailing periods.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'eas-cli': none
|
||||
'@expo/build-tools': none
|
||||
---
|
||||
|
||||
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).
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': minor
|
||||
---
|
||||
|
||||
Stop sandboxes after an optional maximum duration measured from readiness.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Show why a simulator session failed in `eas simulator:start`, such as an unknown `--device` name, instead of only the session status.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/worker': patch
|
||||
---
|
||||
|
||||
Mask secrets in every field of uploaded job log records, such as logged errors, not only in log messages.
|
||||
@@ -54,13 +54,16 @@ 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 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.)
|
||||
- **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.)
|
||||
|
||||
## What NOT to flag
|
||||
|
||||
|
||||
@@ -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 CHANGELOG entry is a `warning` (not a
|
||||
mis-billing, or a missing/malformed changeset 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
|
||||
|
||||
@@ -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 CHANGELOG entry (breaks release automation). These are measurable
|
||||
missing/malformed changeset (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 CHANGELOG entry.
|
||||
`SystemError`/`UserError` misclassification, a broken changeset.
|
||||
- `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,4 +1,4 @@
|
||||
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->
|
||||
<!-- Add a changeset with `yarn changeset`, or skip the check by labeling the PR with "no changelog". -->
|
||||
|
||||
# Why
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: CHANGELOG.md PR check
|
||||
name: Changeset PR check
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -13,31 +13,55 @@ concurrency:
|
||||
group: changelog-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
pr-changelog-entry:
|
||||
name: Check CHANGELOG.md updated
|
||||
pr-changeset:
|
||||
name: Check for a changeset
|
||||
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@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
|
||||
- name: Check if CHANGELOG.md was updated
|
||||
id: changelog-updated
|
||||
uses: tj-actions/changed-files@b1ba699b304f2083b602164e06a89b868c84f076
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
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!')
|
||||
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"
|
||||
- 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_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) &&
|
||||
github.event.pull_request.user.login != 'dependabot[bot]'
|
||||
with:
|
||||
message-id: changelog-entry-check
|
||||
refresh-message-position: true
|
||||
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: ${{ env.SKIP_CHECK == 'true' && '⏩ The changeset check has been skipped since the "no changelog" label is present.' || '✅ Thank you for adding a changeset!' }}
|
||||
message-failure: |
|
||||
❌ 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.
|
||||
❌ 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.
|
||||
@@ -46,44 +46,22 @@ jobs:
|
||||
- name: Publish packages to npm
|
||||
run: yarn run -T lerna publish from-package --yes --no-verify-access
|
||||
release-changelog:
|
||||
name: Update changelog and publish release
|
||||
name: Publish the GitHub 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: Configure git
|
||||
- name: Extract the changelog section
|
||||
run: |
|
||||
git config user.email "[email protected]"
|
||||
git config user.name "Expo CI"
|
||||
- name: Check whether the changelog is already updated
|
||||
run: |
|
||||
if grep -qF "## [$EAS_CLI_VERSION](" CHANGELOG.md; then
|
||||
echo "CHANGELOG.md already has an entry for v$EAS_CLI_VERSION."
|
||||
echo "CHANGELOG_ALREADY_UPDATED=true" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "CHANGELOG_ALREADY_UPDATED=false" >> "$GITHUB_ENV"
|
||||
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
|
||||
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
|
||||
@@ -97,16 +75,6 @@ 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
|
||||
|
||||
@@ -22,4 +22,3 @@ jobs:
|
||||
uses: ./.github/actions/setup-mise
|
||||
- run: yarn install --immutable
|
||||
- run: yarn verify-graphql-code
|
||||
working-directory: packages/eas-cli
|
||||
@@ -9,9 +9,13 @@ 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 }}
|
||||
@@ -26,3 +30,4 @@ jobs:
|
||||
uses: ./.github/actions/setup-mise
|
||||
- run: yarn install --immutable
|
||||
- run: yarn typecheck
|
||||
- run: yarn test
|
||||
@@ -15,9 +15,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: ['20.18.3', '22', '24']
|
||||
node: ['22', '24']
|
||||
include:
|
||||
- node: '20.18.3'
|
||||
- node: '22'
|
||||
coverage: true
|
||||
name: Test with Node ${{ matrix.node }}
|
||||
steps:
|
||||
@@ -71,8 +71,6 @@ jobs:
|
||||
if: ${{ matrix.coverage }}
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
- if: ${{ matrix.coverage }}
|
||||
run: yarn lint-changelog
|
||||
|
||||
notify-slack:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
type: string
|
||||
required: false
|
||||
description: 'Version number for the release'
|
||||
dry_run:
|
||||
type: boolean
|
||||
required: true
|
||||
@@ -19,7 +15,6 @@ 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
|
||||
@@ -29,7 +24,7 @@ jobs:
|
||||
token: ${{ secrets.EXPO_BOT_PAT }}
|
||||
- uses: ./.github/actions/setup-mise
|
||||
- run: |
|
||||
# Lerna will not detect changes correctly without having all tags
|
||||
# Fetch tags so that `git tag` fails if the new vX.Y.Z tag already exists
|
||||
git fetch --depth=1 origin +refs/tags/*:refs/tags/*
|
||||
# Checkout the main branch
|
||||
git checkout main
|
||||
@@ -37,10 +32,5 @@ jobs:
|
||||
git config --global user.name "Expo CI"
|
||||
yarn install --immutable
|
||||
yarn build
|
||||
if [[ "$INPUT_VERSION" == "" ]]; then
|
||||
echo "Releasing with default version"
|
||||
yarn release
|
||||
else
|
||||
echo "Releasing with version $INPUT_VERSION"
|
||||
yarn release "$INPUT_VERSION"
|
||||
fi
|
||||
# The changesets in .changeset/ set the version bump.
|
||||
yarn release
|
||||
+129
-2
@@ -2,10 +2,134 @@
|
||||
|
||||
This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
## main
|
||||
## [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))
|
||||
@@ -18,9 +142,12 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
- [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
|
||||
@@ -38,7 +165,6 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🧹 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))
|
||||
- [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))
|
||||
|
||||
@@ -224,6 +350,7 @@ 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
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@ Each package has independent TypeScript compilation:
|
||||
|
||||
TypeScript configs:
|
||||
|
||||
- `tsconfig.json` - Base configuration (extends @tsconfig/node20)
|
||||
- `tsconfig.json` - Base configuration (extends @tsconfig/node22)
|
||||
- `tsconfig.build.json` - Production builds
|
||||
- `tsconfig.allowUnused.json` - Development with relaxed rules
|
||||
|
||||
@@ -331,12 +331,13 @@ When creating pull requests, make sure to adhere to [PULL_REQUEST_TEMPLATE](./.g
|
||||
|
||||
## Release Process
|
||||
|
||||
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
|
||||
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`
|
||||
4. Notifications sent to Slack #eas-cli channel
|
||||
|
||||
## Licensing
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ yarn start-allow-unused
|
||||
|
||||
## Format
|
||||
|
||||
- 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.
|
||||
- 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).
|
||||
- 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`.
|
||||
|
||||
|
||||
+26
-7
@@ -1,7 +1,8 @@
|
||||
# Releasing EAS CLI
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
The release also sets the `STAGING` and `PRODUCTION` versions in `cli-versions.json`. See [EAS Build CLI versions](#eas-build-cli-versions).
|
||||
|
||||
@@ -18,10 +19,28 @@ A release updates `STAGING` and `PRODUCTION` to the new version automatically. T
|
||||
|
||||
## Choosing the next version
|
||||
|
||||
As stated above, the next EAS CLI version is chosen automatically based on the existing changelog entries under the unreleased section in **CHANGELOG.md**.
|
||||
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:
|
||||
|
||||
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.
|
||||
- If any changeset has a `major` bump, bump the MAJOR version.
|
||||
- Otherwise, if any changeset has a `minor` bump, 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
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "24.11.0",
|
||||
"PRODUCTION": "24.11.0"
|
||||
"STAGING": "25.3.0",
|
||||
"PRODUCTION": "25.3.0"
|
||||
}
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "24.12.0",
|
||||
"version": "25.3.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
}
|
||||
@@ -6,4 +6,4 @@ MISE_NODE_COREPACK = "true"
|
||||
actionlint = "1.7.12"
|
||||
gcloud = "570.0.0"
|
||||
jq = "1.8.1"
|
||||
node = "20.19.4"
|
||||
node = "22.23.4"
|
||||
+6
-3
@@ -7,6 +7,7 @@
|
||||
"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",
|
||||
@@ -15,14 +16,16 @@
|
||||
"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"
|
||||
"clean": "lerna run clean && rimraf node_modules coverage",
|
||||
"changeset": "changeset"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "3.0.3",
|
||||
"@changesets/get-github-info": "1.0.1",
|
||||
"@types/jest": "29.5.11",
|
||||
"@types/node": "20.19.43",
|
||||
"@types/node": "22.20.5",
|
||||
"eslint-plugin-async-protect": "3.1.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-watch-typeahead": "2.2.2",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# @expo/build-tools
|
||||
|
||||
## 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,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "24.12.0",
|
||||
"version": "25.3.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -26,7 +26,7 @@
|
||||
"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]2.0 -- node scripts/generate-appium-commands.js",
|
||||
"generate-appium-commands": "mise exec [email protected]3.4 -- 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",
|
||||
@@ -36,22 +36,23 @@
|
||||
"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": "24.8.0",
|
||||
"@expo/eas-build-job": "24.9.0",
|
||||
"@expo/downloader": "25.3.0",
|
||||
"@expo/eas-build-job": "25.3.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "24.8.0",
|
||||
"@expo/logger": "25.3.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": "24.9.0",
|
||||
"@expo/template-file": "24.8.0",
|
||||
"@expo/turtle-spawn": "24.8.0",
|
||||
"@expo/steps": "25.3.0",
|
||||
"@expo/template-file": "25.3.0",
|
||||
"@expo/turtle-spawn": "25.3.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
@@ -88,7 +89,7 @@
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/lodash": "^4.17.4",
|
||||
"@types/node": "20.19.43",
|
||||
"@types/node": "22.20.5",
|
||||
"@types/node-fetch": "^2.6.11",
|
||||
"@types/node-forge": "^1.3.11",
|
||||
"@types/plist": "^3.0.5",
|
||||
|
||||
@@ -71,6 +71,17 @@ 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
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
#!/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,6 +318,49 @@ 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(
|
||||
|
||||
@@ -69,6 +69,7 @@ export interface BuildContextOptions {
|
||||
metadata?: Metadata;
|
||||
expoApiV2BaseUrl?: string;
|
||||
mcpServerUrl?: string;
|
||||
addBuildLogMask?: (secret: string) => void;
|
||||
}
|
||||
|
||||
export class SkipNativeBuildError extends Error {}
|
||||
@@ -86,6 +87,7 @@ 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,6 +117,7 @@ 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 = {
|
||||
|
||||
@@ -32,6 +32,7 @@ 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 {
|
||||
@@ -63,12 +64,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>) {
|
||||
@@ -84,6 +85,7 @@ 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;
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
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 };
|
||||
| { kind: 'jobRun'; turtleJobRunId: string }
|
||||
// A device run session job with a session token. The token names the session and its job run.
|
||||
| { kind: 'deviceRunSession' };
|
||||
|
||||
type DatadogSetupOptions = {
|
||||
expoApiV2BaseUrl: string;
|
||||
robotAccessToken: string;
|
||||
target: MetricsTarget;
|
||||
};
|
||||
type DatadogSetupOptions =
|
||||
| {
|
||||
expoApiV2BaseUrl: string;
|
||||
robotAccessToken: string;
|
||||
target: Exclude<MetricsTarget, { kind: 'deviceRunSession' }>;
|
||||
}
|
||||
| {
|
||||
expoApiV2BaseUrl: string;
|
||||
deviceRunSessionToken: string;
|
||||
target: Extract<MetricsTarget, { kind: 'deviceRunSession' }>;
|
||||
};
|
||||
|
||||
let setupOptions: DatadogSetupOptions | null = null;
|
||||
let pendingUploads: Promise<unknown>[] = [];
|
||||
@@ -18,9 +27,30 @@ 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;
|
||||
@@ -30,7 +60,7 @@ export const Datadog = {
|
||||
if (!setupOptions) {
|
||||
return;
|
||||
}
|
||||
const { expoApiV2BaseUrl, robotAccessToken, target } = setupOptions;
|
||||
const { expoApiV2BaseUrl, target } = setupOptions;
|
||||
const metrics = [
|
||||
{
|
||||
name,
|
||||
@@ -40,16 +70,11 @@ export const Datadog = {
|
||||
},
|
||||
];
|
||||
|
||||
const metricsPath =
|
||||
target.kind === 'build'
|
||||
? `turtle-builds/${target.turtleBuildId}/metrics`
|
||||
: `turtle-job-runs/${target.turtleJobRunId}/metrics`;
|
||||
const { path: metricsPath, headers } = getMetricsRequest(setupOptions);
|
||||
|
||||
const uploadPromise = turtleFetch(new URL(metricsPath, expoApiV2BaseUrl).toString(), 'POST', {
|
||||
json: { metrics },
|
||||
headers: {
|
||||
Authorization: `Bearer ${robotAccessToken}`,
|
||||
},
|
||||
headers,
|
||||
retries: 2,
|
||||
}).catch(err => {
|
||||
Sentry.capture(`Failed to report turtle ${target.kind} metric`, err, {
|
||||
@@ -61,7 +86,9 @@ export const Datadog = {
|
||||
},
|
||||
|
||||
log(message: string, tags: Record<string, string> = {}): void {
|
||||
if (!setupOptions) {
|
||||
// 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)) {
|
||||
return;
|
||||
}
|
||||
const { expoApiV2BaseUrl, robotAccessToken, target } = setupOptions;
|
||||
|
||||
@@ -33,5 +33,6 @@ 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';
|
||||
@@ -11,7 +11,7 @@ import { getEasFunctions } from '../easFunctions';
|
||||
jest.mock('fs');
|
||||
|
||||
describe('hook anchor self-declarations', () => {
|
||||
it('exactly the four v0 functions declare anchors, with these bindings', () => {
|
||||
it('declares the expected hook anchors', () => {
|
||||
const declarations: Record<string, HookAnchorId> = {};
|
||||
for (const buildFunction of getEasFunctions(createCustomBuildContextMock())) {
|
||||
if (buildFunction.__hookId !== undefined) {
|
||||
@@ -24,6 +24,7 @@ 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',
|
||||
});
|
||||
});
|
||||
@@ -87,6 +88,17 @@ 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',
|
||||
|
||||
@@ -10,6 +10,7 @@ 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';
|
||||
@@ -27,6 +28,8 @@ 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';
|
||||
@@ -43,6 +46,7 @@ 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 { createStartAgentDeviceSessionBuildFunction } from './functions/startAgentDeviceSession';
|
||||
@@ -82,7 +86,9 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createInstallNodeModulesBuildFunction(),
|
||||
createPrebuildBuildFunction(),
|
||||
createReadIpaInfoBuildFunction(),
|
||||
createReadAndroidAppInfoBuildFunction(),
|
||||
createDownloadBuildFunction(ctx),
|
||||
createDownloadSubmissionArchiveFunction(ctx),
|
||||
createInstallBuildFunction(),
|
||||
createLaunchApplicationFunction(),
|
||||
createEasExportBuildFunction(),
|
||||
@@ -119,6 +125,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createStartServeSimLogsBuildFunction(),
|
||||
createCollectServeSimLogsBuildFunction(ctx),
|
||||
createStartSandboxBuildFunction(ctx),
|
||||
createRunAgentBuildFunction(ctx),
|
||||
createCollectServeSimMetricsBuildFunction(ctx),
|
||||
createInstallMaestroBuildFunction(),
|
||||
createInstallMitmproxyBuildFunction(),
|
||||
@@ -135,6 +142,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
calculateEASUpdateRuntimeVersionFunction(),
|
||||
|
||||
createSubmissionEntityFunction(),
|
||||
createSubmitToGooglePlayBuildFunction(),
|
||||
createUploadToAscBuildFunction(),
|
||||
createUpdateTestFlightMetadataBuildFunction(),
|
||||
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+47
@@ -0,0 +1,47 @@
|
||||
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),
|
||||
});
|
||||
});
|
||||
+147
-8
@@ -79,9 +79,11 @@ beforeEach(() => {
|
||||
apiUrl: 'https://preview.example.test',
|
||||
closeAsync: closePreview,
|
||||
});
|
||||
jest
|
||||
.mocked(startDeviceSessionHostAsync)
|
||||
.mockResolvedValue({ openPreviewAsync: openPreview, finishAsync: finishHost });
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: openPreview,
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: finishHost,
|
||||
});
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: undefined,
|
||||
getOutput: () => '',
|
||||
@@ -108,7 +110,11 @@ describe.each([
|
||||
['Appium', createStartAppiumRemoteSessionBuildFunction],
|
||||
['Agent Device', createStartAgentDeviceSessionBuildFunction],
|
||||
] as const)('%s host ownership', (name, createFunction) => {
|
||||
async function runAsync() {
|
||||
async function runAsync(
|
||||
publicPreview?: boolean,
|
||||
frameAncestors?: string[],
|
||||
corsOrigins?: string[]
|
||||
) {
|
||||
const fn = createFunction({} as CustomBuildContext);
|
||||
await fn.fn!(
|
||||
{
|
||||
@@ -117,9 +123,16 @@ describe.each([
|
||||
} as unknown 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: 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: undefined },
|
||||
}
|
||||
),
|
||||
outputs: {},
|
||||
env: {
|
||||
DEVICE_RUN_SESSION_ID: 'session-id',
|
||||
@@ -132,7 +145,7 @@ describe.each([
|
||||
|
||||
it('holds the host until session completion', async () => {
|
||||
await runAsync();
|
||||
expect(openPreview).toHaveBeenCalledWith({ baseDomain: 'example.test' });
|
||||
expect(openPreview).toHaveBeenCalledWith({ baseDomain: 'example.test', authtoken: 'token' });
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: expect.objectContaining({
|
||||
@@ -151,6 +164,59 @@ describe.each([
|
||||
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;
|
||||
@@ -260,3 +326,76 @@ 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 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')) {
|
||||
return install;
|
||||
}
|
||||
return command === 'npm' && appiumError
|
||||
? Promise.reject(appiumError)
|
||||
: Promise.resolve({ stdout: '{}' });
|
||||
}) as never);
|
||||
return finish;
|
||||
}
|
||||
|
||||
it.each(['installed', 'failed'] as const)(
|
||||
'starts Appium after the ffmpeg install settles (%s)',
|
||||
async outcome => {
|
||||
const finishFfmpegInstall = holdFfmpegInstall();
|
||||
|
||||
const run = runAppiumAsync();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
|
||||
finishFfmpegInstall(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 finishFfmpegInstall = holdFfmpegInstall({ appiumError });
|
||||
|
||||
let settled = false;
|
||||
const run = runAppiumAsync().finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(settled).toBe(false);
|
||||
|
||||
finishFfmpegInstall('installed');
|
||||
await expect(run).rejects.toBe(appiumError);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ 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';
|
||||
|
||||
@@ -30,11 +31,17 @@ 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().mockResolvedValue(
|
||||
const toPromise = jest.fn();
|
||||
if (transientError) {
|
||||
toPromise.mockResolvedValueOnce({ error: transientError, data: undefined });
|
||||
}
|
||||
toPromise.mockResolvedValue(
|
||||
error
|
||||
? { error, data: undefined }
|
||||
: {
|
||||
@@ -62,7 +69,7 @@ function createSuccessfulResponse({
|
||||
url,
|
||||
headers,
|
||||
}: {
|
||||
body: Buffer;
|
||||
body: Buffer | Readable;
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}): Response {
|
||||
@@ -72,7 +79,7 @@ function createSuccessfulResponse({
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
body: Readable.from(body),
|
||||
body: body instanceof Readable ? body : Readable.from(body),
|
||||
url,
|
||||
headers: {
|
||||
get: (name: string): string | null => normalizedHeaders[name.toLowerCase()] ?? null,
|
||||
@@ -80,6 +87,47 @@ 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`);
|
||||
@@ -108,6 +156,11 @@ async function createFlatAppTarGzBufferAsync(): Promise<Buffer> {
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('downloadBuild', () => {
|
||||
it('downloads from applicationArchiveUrl returned by GraphQL', async () => {
|
||||
const buildId = randomUUID();
|
||||
@@ -465,13 +518,13 @@ describe('downloadBuild', () => {
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws SystemError when GraphQL request fails with a network error', async () => {
|
||||
it('throws SystemError with the cause code after retrying a failing build lookup', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
error: new CombinedError({
|
||||
networkError: new Error('Network request failed'),
|
||||
}),
|
||||
error: createUndiciNetworkError('ECONNRESET'),
|
||||
});
|
||||
const datadogLog = jest.spyOn(Datadog, 'log');
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
@@ -480,19 +533,81 @@ describe('downloadBuild', () => {
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
});
|
||||
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await fireBackoffTimersAsync(5);
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await rejection;
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
message: `Could not fetch build ${buildId}: [Network] Network request failed`,
|
||||
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',
|
||||
});
|
||||
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws SystemError when GraphQL request returns 5xx', async () => {
|
||||
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({
|
||||
error: Object.assign(new Error('Internal Server Error'), {
|
||||
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' }],
|
||||
response: { status: 500 },
|
||||
}),
|
||||
});
|
||||
@@ -504,12 +619,14 @@ describe('downloadBuild', () => {
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
});
|
||||
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await fireBackoffTimersAsync(5);
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await rejection;
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
message: `Could not fetch build ${buildId}: Internal Server Error`,
|
||||
message: `Could not fetch build ${buildId}: [GraphQL] Internal Server Error`,
|
||||
});
|
||||
|
||||
expect(graphqlClient.query).toHaveBeenCalledTimes(6);
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -534,10 +651,193 @@ 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,
|
||||
@@ -591,6 +891,131 @@ 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({
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
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);
|
||||
});
|
||||
@@ -463,114 +463,127 @@ describe('createInstallMaestroBuildFunction', () => {
|
||||
}
|
||||
});
|
||||
|
||||
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 }
|
||||
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-')
|
||||
);
|
||||
for (const runtimeVersion of ['18.3', '26.0']) {
|
||||
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.readFile(
|
||||
path.join(
|
||||
homeDirectory,
|
||||
'.maestro-runner',
|
||||
'cache',
|
||||
'wda-builds',
|
||||
`sim-ios${runtimeVersion}-iphone`,
|
||||
'DerivedData',
|
||||
'Build',
|
||||
'Products',
|
||||
'WebDriverAgentRunner.xctestrun'
|
||||
),
|
||||
'utf8'
|
||||
fs.promises.access(
|
||||
path.join(homeDirectory, '.maestro-runner', 'cache', 'wda-builds', 'sim-ios17.5-iphone')
|
||||
)
|
||||
).resolves.toBe('cached');
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
await fs.promises.rm(homeDirectory, { force: true, recursive: true });
|
||||
}
|
||||
await expect(
|
||||
fs.promises.access(
|
||||
path.join(homeDirectory, '.maestro-runner', 'cache', 'wda-builds', 'sim-ios17.5-iphone')
|
||||
)
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
await fs.promises.rm(homeDirectory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
it('does not download the WDA cache when the installed WDA version is unknown', async () => {
|
||||
mockedSpawn.mockImplementation((async (command: string) => ({
|
||||
stdout:
|
||||
command === 'xcodebuild' ? 'Xcode 26.5\nBuild version 17F90\n' : 'maestro-runner 1.2.3\n',
|
||||
command === 'xcodebuild' ? 'Xcode 26.5\nBuild version 17F90\n' : 'maestro-runner 1.1.27\n',
|
||||
})) as any);
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
@@ -593,7 +606,7 @@ describe('createInstallMaestroBuildFunction', () => {
|
||||
mockedSpawn.mockImplementation((async (command: string) => {
|
||||
switch (command) {
|
||||
case 'maestro-runner':
|
||||
return { stdout: 'maestro-runner 1.2.3\n' };
|
||||
return { stdout: 'maestro-runner 1.1.27\n' };
|
||||
case 'xcodebuild':
|
||||
return { stdout: 'Xcode 26.5\nBuild version 17F90\n' };
|
||||
case 'xcrun':
|
||||
@@ -633,7 +646,7 @@ describe('createInstallMaestroBuildFunction', () => {
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).resolves.toBeUndefined();
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('1.2.3');
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('1.1.27');
|
||||
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,5 +1,6 @@
|
||||
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';
|
||||
@@ -179,17 +180,7 @@ describe(launchApplicationAsync, () => {
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'-a',
|
||||
'android.intent.action.VIEW',
|
||||
'-d',
|
||||
'exp://example.test',
|
||||
'-n',
|
||||
'host.exp.exponent/host.exp.exponent.MainActivity',
|
||||
],
|
||||
['shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', 'exp://example.test'],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(logger.info).toHaveBeenNthCalledWith(
|
||||
@@ -211,6 +202,263 @@ 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(
|
||||
@@ -240,16 +488,36 @@ 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',
|
||||
'-a',
|
||||
'android.intent.action.VIEW',
|
||||
'-d',
|
||||
'exp://example.test',
|
||||
'--es',
|
||||
'probe',
|
||||
"'hello world'",
|
||||
'-n',
|
||||
'com.example.app/com.example.app.MainActivity',
|
||||
],
|
||||
|
||||
+27
-2
@@ -14,6 +14,12 @@ import {
|
||||
registerLocalEgressResources,
|
||||
stopLocalEgressResourcesAsync,
|
||||
} from '../../utils/localEgress';
|
||||
import {
|
||||
reportLocalEgressGuardCoverageAsync,
|
||||
resolveLocalEgressServeSimBootEnvironmentAsync,
|
||||
startLocalEgressGuardRelayAsync,
|
||||
verifyLocalEgressGuardAsync,
|
||||
} from '../../utils/localEgressGuard';
|
||||
import {
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
@@ -29,8 +35,8 @@ jest.mock('../../../utils/processes');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/agentDeviceArtifacts');
|
||||
// The agent-device session step boots the Simulator itself; this test covers local egress.
|
||||
jest.mock('../startIosSimulator', () => ({
|
||||
...jest.requireActual('../startIosSimulator'),
|
||||
bootIosSimulatorAsync: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ deviceIdentifier: 'sim', udid: 'sim', displayName: 'iPhone' }),
|
||||
@@ -51,6 +57,13 @@ 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',
|
||||
@@ -126,9 +139,20 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
jest.mocked(isProcessDescendantOfAsync).mockResolvedValue(true);
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([{ udid: 'sim' }] as never);
|
||||
.mockResolvedValue([{ udid: 'sim', name: 'iPhone 17' }] as never);
|
||||
jest.mocked(IosSimulatorUtils.resolveUdidAsync).mockResolvedValue('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',
|
||||
@@ -138,6 +162,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: openPreview,
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: stopPreview,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
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', 'LANG', 'PATH', 'TMPDIR']);
|
||||
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".'
|
||||
);
|
||||
});
|
||||
});
|
||||
+360
-25
@@ -19,6 +19,7 @@ import {
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
waitForFileAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { createProcessOutput } from '../../utils/processOutput';
|
||||
import { type StartupTasks, createStartupTasks } from '../../utils/startupTasks';
|
||||
import { runAgentDeviceRemoteSessionAsync } from '../startAgentDeviceRemoteSession';
|
||||
|
||||
@@ -69,11 +70,14 @@ const mockEventCollectionStopAsync = jest.fn();
|
||||
type CaptureInputs = Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]['capture'];
|
||||
const NO_CAPTURE: CaptureInputs = { networkCapture: false, networkCaptureFields: [] };
|
||||
|
||||
/** Runs the session for a device that is already booted, with the app (if any) launched. */
|
||||
/** 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 },
|
||||
runtimePlatform: BuildRuntimePlatform,
|
||||
capture: CaptureInputs = NO_CAPTURE
|
||||
capture: CaptureInputs = NO_CAPTURE,
|
||||
disableShare = false,
|
||||
privatePreviewUrl?: string,
|
||||
maxIdleTimeMinutes?: number
|
||||
): Promise<void> {
|
||||
await runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env: {},
|
||||
@@ -85,11 +89,16 @@ async function runAsync(
|
||||
ngrokAuthtoken: 'ngrok-token',
|
||||
},
|
||||
packageVersion: undefined,
|
||||
maxIdleTimeMinutes: undefined,
|
||||
maxIdleTimeMinutes,
|
||||
maxDurationSeconds: undefined,
|
||||
capture,
|
||||
disableShare,
|
||||
privatePreviewUrl,
|
||||
tasks: createStartupTasks(logger as never),
|
||||
device: { booted: Promise.resolve(), ready: Promise.resolve() },
|
||||
device:
|
||||
runtimePlatform === BuildRuntimePlatform.DARWIN
|
||||
? { iosSimulatorUdid: 'selected-udid', application: Promise.resolve() }
|
||||
: { booted: Promise.resolve(), ready: Promise.resolve() },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -119,13 +128,24 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
subdomainId: 'agent-device-abc',
|
||||
stopAsync: mockTunnelStopAsync,
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
const host = {
|
||||
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(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
@@ -145,7 +165,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
|
||||
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX, timeoutMs: 60_000 })
|
||||
);
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -163,6 +183,42 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
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, NO_CAPTURE, false, undefined, 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 () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
|
||||
@@ -177,6 +233,24 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('hands a private preview choice to the session host', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
await runAsync(
|
||||
logger,
|
||||
BuildRuntimePlatform.DARWIN,
|
||||
NO_CAPTURE,
|
||||
true,
|
||||
'https://expo.dev/private-preview'
|
||||
);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
disableShare: true,
|
||||
privatePreviewUrl: 'https://expo.dev/private-preview',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('starts the session host without waiting for the agent-device daemon', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const sessionHost = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
|
||||
@@ -245,14 +319,14 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
});
|
||||
|
||||
describe('runAgentDeviceRemoteSessionAsync with a device that is still starting', () => {
|
||||
function deferred(): {
|
||||
promise: Promise<void>;
|
||||
resolve: () => void;
|
||||
function deferred<T = void>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (e: Error) => void;
|
||||
} {
|
||||
let resolve!: () => void;
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (e: Error) => void;
|
||||
const promise = new Promise<void>((res, rej) => {
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
@@ -260,18 +334,26 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
type Device = { booted: Promise<unknown>; ready: Promise<unknown> };
|
||||
function startSession(device: Device | ((tasks: StartupTasks) => Device)) {
|
||||
type Device = Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]['device'];
|
||||
function startSession(
|
||||
device: Device | ((tasks: StartupTasks) => Device),
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
child: jest.fn().mockReturnThis(),
|
||||
} as never;
|
||||
const tasks = createStartupTasks(logger);
|
||||
const tasks = createStartupTasks(logger, signal);
|
||||
const sessionDevice = typeof device === 'function' ? device(tasks) : device;
|
||||
return runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env: {},
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
signal,
|
||||
runtimePlatform:
|
||||
'iosSimulatorUdid' in sessionDevice
|
||||
? BuildRuntimePlatform.DARWIN
|
||||
: BuildRuntimePlatform.LINUX,
|
||||
sessionEnv: {
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
ngrokTunnelDomain: 'tunnel.example.com',
|
||||
@@ -282,7 +364,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
maxDurationSeconds: undefined,
|
||||
capture: NO_CAPTURE,
|
||||
tasks,
|
||||
device: typeof device === 'function' ? device(tasks) : device,
|
||||
device: sessionDevice,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -292,7 +374,31 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
}
|
||||
}
|
||||
|
||||
it('starts the daemon during the boot and the session host after it', async () => {
|
||||
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 () => {
|
||||
const booted = deferred();
|
||||
const session = startSession({ booted: booted.promise, ready: booted.promise });
|
||||
await flushAsync();
|
||||
@@ -304,10 +410,237 @@ 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('reports the session as ready only after the app is launched', async () => {
|
||||
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({
|
||||
baseDomain: 'tunnel.example.com',
|
||||
authtoken: 'ngrok-token',
|
||||
});
|
||||
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('starts the Android host before application readiness and waits before reporting ready', async () => {
|
||||
const ready = deferred();
|
||||
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
|
||||
await flushAsync();
|
||||
@@ -317,10 +650,11 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
|
||||
ready.resolve();
|
||||
await session;
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops waiting for the boot when the daemon fails', async () => {
|
||||
it('stops waiting for the Android boot when the daemon fails', async () => {
|
||||
jest.mocked(waitForFileAsync).mockRejectedValue(new Error('no daemon credentials'));
|
||||
const neverBooted = new Promise<void>(() => {});
|
||||
|
||||
@@ -334,7 +668,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops the session host when the app fails while the daemon install hangs', async () => {
|
||||
it('cancels daemon installation and stops the Android host when the download fails', async () => {
|
||||
jest.mocked(spawn).mockImplementation(((
|
||||
command: string,
|
||||
args: string[],
|
||||
@@ -350,21 +684,22 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
await flushAsync();
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
|
||||
|
||||
ready.reject(new Error('simctl install failed'));
|
||||
ready.reject(new Error('download failed'));
|
||||
|
||||
await expect(session).rejects.toThrow('simctl install failed');
|
||||
await expect(session).rejects.toThrow('download failed');
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops everything it started when the app install fails', async () => {
|
||||
it('stops the Android host, daemon and tunnel when the download fails', async () => {
|
||||
const ready = deferred();
|
||||
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
|
||||
await flushAsync();
|
||||
ready.reject(new Error('simctl install failed'));
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
|
||||
ready.reject(new Error('download failed'));
|
||||
|
||||
await expect(session).rejects.toThrow('simctl install failed');
|
||||
await expect(session).rejects.toThrow('download failed');
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
|
||||
+46
-1
@@ -93,6 +93,28 @@ 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',
|
||||
@@ -108,6 +130,8 @@ 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',
|
||||
@@ -121,6 +145,20 @@ 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,
|
||||
@@ -136,10 +174,15 @@ 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();
|
||||
@@ -151,7 +194,7 @@ describe(startAgentDeviceDaemonAsync, () => {
|
||||
logger,
|
||||
signal: controller.signal,
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
await started;
|
||||
controller.abort(failure);
|
||||
|
||||
await expect(daemon).rejects.toBe(failure);
|
||||
@@ -199,6 +242,8 @@ describe(startAgentDeviceDaemonAsync, () => {
|
||||
);
|
||||
expect(spawnDetached).toHaveBeenCalledWith({
|
||||
command: 'bun',
|
||||
logger,
|
||||
secrets: undefined,
|
||||
args: ['run', 'src/daemon.ts'],
|
||||
cwd: '/tmp/agent-device-src',
|
||||
env: expect.objectContaining({
|
||||
|
||||
+407
-76
@@ -1,10 +1,20 @@
|
||||
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { DeviceRunSessionWorkerApi } from '../../utils/deviceRunSessionWorkerApi';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { stopLocalEgressResourcesAsync } from '../../utils/localEgress';
|
||||
import {
|
||||
resolveLocalEgressServeSimBootEnvironmentAsync,
|
||||
startLocalEgressGuardRelayAsync,
|
||||
} from '../../utils/localEgressGuard';
|
||||
import { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession';
|
||||
import { downloadBuildAsync } from '../downloadBuild';
|
||||
import { installBuildAsync } from '../installBuild';
|
||||
import { readIosApplicationIdentifierAsync } from '../../utils/iosAppArtifact';
|
||||
import { launchApplicationAsync } from '../launchApplication';
|
||||
import {
|
||||
getAgentDeviceRemoteSessionEnvOrThrow,
|
||||
@@ -14,17 +24,30 @@ import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceS
|
||||
import { startAndroidEmulatorAsync } from '../startAndroidEmulator';
|
||||
import { bootIosSimulatorAsync } from '../startIosSimulator';
|
||||
|
||||
jest.mock('../../utils/localEgressSession', () => ({
|
||||
withLocalEgressSession: (fn: unknown) => fn,
|
||||
jest.mock('../../utils/localEgress', () => ({
|
||||
stopLocalEgressResourcesAsync: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
jest.mock('../../utils/localEgressGuard', () => ({
|
||||
resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn(),
|
||||
startLocalEgressGuardRelayAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../../utils/IosSimulatorUtils');
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../startIosSimulator', () => ({ bootIosSimulatorAsync: jest.fn() }));
|
||||
jest.mock('../startIosSimulator', () => ({
|
||||
...jest.requireActual('../startIosSimulator'),
|
||||
bootIosSimulatorAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../startAndroidEmulator', () => ({ startAndroidEmulatorAsync: jest.fn() }));
|
||||
jest.mock('../downloadBuild', () => ({ downloadBuildAsync: jest.fn() }));
|
||||
jest.mock('../installBuild', () => ({ installBuildAsync: jest.fn() }));
|
||||
jest.mock('../../utils/iosAppArtifact', () => ({
|
||||
...jest.requireActual('../../utils/iosAppArtifact'),
|
||||
readIosApplicationIdentifierAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../launchApplication', () => ({
|
||||
...jest.requireActual('../launchApplication'),
|
||||
launchApplicationAsync: jest.fn(),
|
||||
@@ -42,7 +65,17 @@ const sessionEnv = {
|
||||
ngrokAuthtoken: 'ngrok-token',
|
||||
};
|
||||
|
||||
type Device = { booted: Promise<unknown>; ready: Promise<unknown> };
|
||||
type Device = Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]['device'];
|
||||
let deviceLaunch: unknown;
|
||||
|
||||
function runDevicePreparationAsync(device: Device): Promise<unknown> {
|
||||
return (
|
||||
'iosSimulatorUdid' in device ? (device.application ?? Promise.resolve()) : device.ready
|
||||
).then(async launch => {
|
||||
deviceLaunch = launch;
|
||||
return launch;
|
||||
});
|
||||
}
|
||||
|
||||
function deferred<T = void>(): {
|
||||
promise: Promise<T>;
|
||||
@@ -67,9 +100,11 @@ async function flushAsync(): Promise<void> {
|
||||
|
||||
function runStep(
|
||||
runtimePlatform: BuildRuntimePlatform,
|
||||
inputValues: Record<string, unknown> = {}
|
||||
inputValues: Record<string, unknown> = {},
|
||||
signal?: AbortSignal,
|
||||
stepCtx: CustomBuildContext = ctx
|
||||
): Promise<void> {
|
||||
const buildFunction = createStartAgentDeviceSessionBuildFunction(ctx);
|
||||
const buildFunction = createStartAgentDeviceSessionBuildFunction(stepCtx);
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const inputs = Object.fromEntries(
|
||||
[
|
||||
@@ -84,6 +119,10 @@ function runStep(
|
||||
'open_url',
|
||||
'network_capture',
|
||||
'network_capture_fields',
|
||||
'public_preview',
|
||||
'private_preview_url',
|
||||
'frame_ancestors',
|
||||
'cors_origins',
|
||||
'package_version',
|
||||
'max_idle_time_minutes',
|
||||
'max_duration_seconds',
|
||||
@@ -97,22 +136,32 @@ function runStep(
|
||||
staticContext: { job: { secrets: { robotAccessToken: 'robot-token' } } },
|
||||
},
|
||||
} as unknown as BuildStepContext,
|
||||
{ inputs, outputs: {}, env: {}, signal: undefined } as never
|
||||
{ inputs, outputs: {}, env: {}, signal } as never
|
||||
) as Promise<void>;
|
||||
}
|
||||
|
||||
/** The `device` promises handed to the shared agent-device session code. */
|
||||
function sessionDevice(): Device {
|
||||
return jest.mocked(runAgentDeviceRemoteSessionAsync).mock.calls[0][1].device;
|
||||
}
|
||||
|
||||
describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
deviceLaunch = undefined;
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(null);
|
||||
jest.mocked(startLocalEgressGuardRelayAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({ state: 'Shutdown' } as never);
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: '' } as never);
|
||||
jest.mocked(IosSimulatorUtils.resolveUdidAsync).mockResolvedValue('selected-udid' as never);
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([{ name: 'iPhone 17', udid: 'SELECTED-UDID' }] as never);
|
||||
jest.mocked(getAgentDeviceRemoteSessionEnvOrThrow).mockReturnValue(sessionEnv);
|
||||
jest.mocked(runAgentDeviceRemoteSessionAsync).mockImplementation(async (_ctx, { device }) => {
|
||||
await device.ready;
|
||||
});
|
||||
jest
|
||||
.mocked(runAgentDeviceRemoteSessionAsync)
|
||||
.mockImplementation(async (_ctx, { device, tasks }) => {
|
||||
await tasks.untilAborted(runDevicePreparationAsync(device));
|
||||
});
|
||||
jest.mocked(bootIosSimulatorAsync).mockResolvedValue({
|
||||
deviceIdentifier: 'iPhone 17' as never,
|
||||
udid: 'udid' as never,
|
||||
@@ -128,22 +177,31 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
.mocked(installBuildAsync)
|
||||
.mockResolvedValue({ applicationIdentifier: 'dev.example.app', activityName: '.Main' });
|
||||
jest.mocked(launchApplicationAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(readIosApplicationIdentifierAsync).mockResolvedValue('dev.example.app');
|
||||
});
|
||||
|
||||
it('downloads the build and starts the session while the iOS Simulator boots', async () => {
|
||||
const boot = deferred<Awaited<ReturnType<typeof bootIosSimulatorAsync>>>();
|
||||
jest.mocked(bootIosSimulatorAsync).mockReturnValue(boot.promise);
|
||||
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, {
|
||||
it('hands guarded iOS boot to serve-sim and starts reporting before host startup', async () => {
|
||||
const bootEnv = {
|
||||
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
|
||||
SIMCTL_CHILD_EAS_EGRESS_GUARD_MODE: 'block',
|
||||
SIMCTL_CHILD_http_proxy: 'http://127.0.0.1:8899',
|
||||
};
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(bootEnv);
|
||||
await runStep(BuildRuntimePlatform.DARWIN, {
|
||||
build_id: 'build-id',
|
||||
launch_args: ['-flag'],
|
||||
open_url: 'exp://example.test',
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
expect(
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
|
||||
).toBeLessThan(jest.mocked(bootIosSimulatorAsync).mock.invocationCallOrder[0]);
|
||||
).toBeLessThan(
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(startLocalEgressGuardRelayAsync).toHaveBeenCalledTimes(1);
|
||||
expect(jest.mocked(startLocalEgressGuardRelayAsync).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
jest.mocked(runAgentDeviceRemoteSessionAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
buildId: 'build-id',
|
||||
@@ -153,32 +211,180 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
})
|
||||
);
|
||||
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledTimes(1);
|
||||
// The install needs the booted Simulator.
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
|
||||
boot.resolve({
|
||||
deviceIdentifier: 'iPhone 17' as never,
|
||||
udid: 'udid' as never,
|
||||
displayName: '',
|
||||
const device = sessionDevice();
|
||||
expect(device).toEqual({
|
||||
iosSimulatorUdid: 'SELECTED-UDID',
|
||||
bootEnv,
|
||||
application: expect.any(Promise),
|
||||
});
|
||||
await step;
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({
|
||||
artifactPath: '/tmp/App.app',
|
||||
env: {},
|
||||
});
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
expect(deviceLaunch).toEqual({
|
||||
installAppPath: '/tmp/App.app',
|
||||
launchAppIdentifier: 'dev.example.app',
|
||||
launchArgs: ['-flag'],
|
||||
openUrl: 'exp://example.test',
|
||||
});
|
||||
});
|
||||
|
||||
expect(installBuildAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ artifactPath: '/tmp/App.app' })
|
||||
);
|
||||
expect(launchApplicationAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
applicationIdentifier: 'dev.example.app',
|
||||
launchArgs: ['-flag'],
|
||||
openUrl: 'exp://example.test',
|
||||
})
|
||||
);
|
||||
expect(jest.mocked(installBuildAsync).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
jest.mocked(launchApplicationAsync).mock.invocationCallOrder[0]
|
||||
it('shuts down only the selected guarded Booted device before host startup', async () => {
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
|
||||
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
|
||||
});
|
||||
jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({ state: 'Booted' } as never);
|
||||
await runStep(BuildRuntimePlatform.DARWIN);
|
||||
expect(IosSimulatorUtils.getDeviceAsync).toHaveBeenCalledWith({
|
||||
udid: 'SELECTED-UDID',
|
||||
env: {},
|
||||
});
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
expect(spawn).toHaveBeenCalledWith('xcrun', ['simctl', 'shutdown', 'SELECTED-UDID'], {
|
||||
env: {},
|
||||
logger: expect.any(Object),
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(jest.mocked(spawn).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
jest.mocked(runAgentDeviceRemoteSessionAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('waits for the download when the Simulator boots first', async () => {
|
||||
it('downloads build_id through the device run session worker API when the job has a session token', async () => {
|
||||
const sessionCtx = {
|
||||
graphqlClient,
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'session-token' },
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
} as unknown as CustomBuildContext;
|
||||
|
||||
await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' }, undefined, sessionCtx);
|
||||
|
||||
expect(downloadBuildAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
buildId: 'build-id',
|
||||
deviceRunSessionWorkerApi: expect.any(DeviceRunSessionWorkerApi),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not use the worker API without a session token or for an archive URL', async () => {
|
||||
await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' });
|
||||
expect(downloadBuildAsync).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ buildId: 'build-id', deviceRunSessionWorkerApi: null })
|
||||
);
|
||||
|
||||
const sessionCtx = {
|
||||
graphqlClient,
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'session-token' },
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
} as unknown as CustomBuildContext;
|
||||
await runStep(
|
||||
BuildRuntimePlatform.LINUX,
|
||||
{ application_archive_url: 'https://example.test/app.tar.gz' },
|
||||
undefined,
|
||||
sessionCtx
|
||||
);
|
||||
expect(downloadBuildAsync).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
applicationArchiveUrl: 'https://example.test/app.tar.gz',
|
||||
deviceRunSessionWorkerApi: null,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('fails before download or host startup when guarded device shutdown fails', async () => {
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
|
||||
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
|
||||
});
|
||||
jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({ state: 'Booted' } as never);
|
||||
jest.mocked(spawn).mockRejectedValue(new Error('shutdown failed'));
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'shutdown failed'
|
||||
);
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('provides Simulator preparation and downloaded startup options to the remote session', async () => {
|
||||
const hostReady = deferred();
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
|
||||
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
|
||||
});
|
||||
jest.mocked(runAgentDeviceRemoteSessionAsync).mockImplementation(async (_ctx, { device }) => {
|
||||
expect('iosSimulatorUdid' in device).toBe(true);
|
||||
if (!('iosSimulatorUdid' in device)) {
|
||||
return;
|
||||
}
|
||||
await hostReady.promise;
|
||||
deviceLaunch = await device.application;
|
||||
});
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, {
|
||||
device_identifier: 'iPhone 17',
|
||||
build_id: 'build-id',
|
||||
launch_args: ['-flag'],
|
||||
open_url: 'exp://example.test',
|
||||
});
|
||||
await flushAsync();
|
||||
expect(IosSimulatorUtils.resolveUdidAsync).toHaveBeenCalledWith({
|
||||
deviceIdentifier: 'iPhone 17',
|
||||
env: {},
|
||||
});
|
||||
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).toHaveBeenCalledTimes(1);
|
||||
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({
|
||||
artifactPath: '/tmp/App.app',
|
||||
env: {},
|
||||
});
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
expect(deviceLaunch).toBeUndefined();
|
||||
hostReady.resolve();
|
||||
await step;
|
||||
expect(deviceLaunch).toEqual({
|
||||
installAppPath: '/tmp/App.app',
|
||||
launchAppIdentifier: 'dev.example.app',
|
||||
launchArgs: ['-flag'],
|
||||
openUrl: 'exp://example.test',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails a guarded session before download or host startup if its boot files are missing', async () => {
|
||||
jest
|
||||
.mocked(resolveLocalEgressServeSimBootEnvironmentAsync)
|
||||
.mockRejectedValue(new Error('missing bin/egress-guard-check'));
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'missing bin/egress-guard-check'
|
||||
);
|
||||
expect(startLocalEgressGuardRelayAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
expect(IosSimulatorUtils.getDeviceAsync).not.toHaveBeenCalled();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('releases guard reporting and egress resources when host startup fails', async () => {
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
|
||||
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
|
||||
});
|
||||
jest
|
||||
.mocked(runAgentDeviceRemoteSessionAsync)
|
||||
.mockRejectedValueOnce(new Error('guard check failed'));
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'guard check failed'
|
||||
);
|
||||
expect(startLocalEgressGuardRelayAsync).toHaveBeenCalledTimes(1);
|
||||
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('waits for the download before handing the app to the host', async () => {
|
||||
const download = deferred<{ artifactPath: string }>();
|
||||
jest.mocked(downloadBuildAsync).mockReturnValue(download.promise);
|
||||
|
||||
@@ -190,16 +396,21 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
|
||||
download.resolve({ artifactPath: '/tmp/App.app' });
|
||||
await step;
|
||||
expect(installBuildAsync).toHaveBeenCalledTimes(1);
|
||||
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledTimes(1);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('makes the session ready when the device boots if there is no app', async () => {
|
||||
it('passes the selected Simulator to the host even when there is no app', async () => {
|
||||
await runStep(BuildRuntimePlatform.DARWIN);
|
||||
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
await expect(sessionDevice().ready).resolves.toBeUndefined();
|
||||
expect(sessionDevice()).toEqual({
|
||||
iosSimulatorUdid: 'SELECTED-UDID',
|
||||
bootEnv: undefined,
|
||||
application: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('boots the Android Emulator with the device inputs', async () => {
|
||||
@@ -231,23 +442,10 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
});
|
||||
|
||||
it('fails the session when the download fails, without an unhandled rejection', async () => {
|
||||
const boot = deferred<Awaited<ReturnType<typeof bootIosSimulatorAsync>>>();
|
||||
jest.mocked(bootIosSimulatorAsync).mockReturnValue(boot.promise);
|
||||
jest.mocked(downloadBuildAsync).mockRejectedValue(new Error('download failed'));
|
||||
|
||||
const stepFailure = expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })
|
||||
).rejects.toThrow('download failed');
|
||||
// The download fails while the boot still runs. Nothing inside the step may leave
|
||||
// that rejection unhandled; Jest fails the test if it does.
|
||||
await flushAsync();
|
||||
boot.resolve({
|
||||
deviceIdentifier: 'iPhone 17' as never,
|
||||
udid: 'udid' as never,
|
||||
displayName: '',
|
||||
});
|
||||
|
||||
await stepFailure;
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'download failed'
|
||||
);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -276,23 +474,24 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
jest
|
||||
.mocked(runAgentDeviceRemoteSessionAsync)
|
||||
.mockImplementation(async (_ctx, { tasks, device }) => {
|
||||
const ready = runDevicePreparationAsync(device);
|
||||
await beforeFailure?.();
|
||||
const daemon = tasks.run('agent-device daemon', async () => {
|
||||
throw new Error('daemon failed');
|
||||
});
|
||||
try {
|
||||
await Promise.all([daemon, device.ready]);
|
||||
await Promise.all([daemon, ready]);
|
||||
} finally {
|
||||
await Promise.allSettled([daemon, device.ready]);
|
||||
await Promise.allSettled([daemon, ready]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it('stops the download and waits for it when the boot fails', async () => {
|
||||
it('stops the download and waits for it when Android boot fails', async () => {
|
||||
const download = mockStalledDownload();
|
||||
jest.mocked(bootIosSimulatorAsync).mockRejectedValue(new Error('boot failed'));
|
||||
jest.mocked(startAndroidEmulatorAsync).mockRejectedValue(new Error('boot failed'));
|
||||
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
await expect(runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'boot failed'
|
||||
);
|
||||
|
||||
@@ -316,25 +515,79 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not launch the app when startup fails during the install', async () => {
|
||||
const install = deferred<Awaited<ReturnType<typeof installBuildAsync>>>();
|
||||
const installStarted = deferred();
|
||||
jest.mocked(installBuildAsync).mockImplementation(() => {
|
||||
installStarted.resolve();
|
||||
return install.promise;
|
||||
it('drains the download when host startup fails before app preparation', async () => {
|
||||
const download = mockStalledDownload();
|
||||
jest.mocked(runAgentDeviceRemoteSessionAsync).mockRejectedValue(new Error('host failed'));
|
||||
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'host failed'
|
||||
);
|
||||
expect(download.aborted()).toBe(true);
|
||||
expect(download.settled()).toBe(true);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not start work for an already cancelled step', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error('cancelled'));
|
||||
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }, controller.signal)
|
||||
).rejects.toThrow('cancelled');
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancels and drains a pending download through the external signal', async () => {
|
||||
const controller = new AbortController();
|
||||
const download = mockStalledDownload();
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }, controller.signal);
|
||||
const failure = expect(step).rejects.toThrow('cancelled');
|
||||
await flushAsync();
|
||||
controller.abort(new Error('cancelled'));
|
||||
|
||||
await failure;
|
||||
expect(download.aborted()).toBe(true);
|
||||
expect(download.settled()).toBe(true);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not start device or download work after cancellation during Xcode selection', async () => {
|
||||
const controller = new AbortController();
|
||||
const selected = deferred();
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockReturnValue(selected.promise);
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }, controller.signal);
|
||||
const failure = expect(step).rejects.toThrow('cancelled');
|
||||
await flushAsync();
|
||||
controller.abort(new Error('cancelled'));
|
||||
selected.resolve();
|
||||
|
||||
await failure;
|
||||
expect(IosSimulatorUtils.resolveUdidAsync).not.toHaveBeenCalled();
|
||||
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drains app preparation when the daemon fails', async () => {
|
||||
const metadata = deferred<string>();
|
||||
const metadataStarted = deferred();
|
||||
jest.mocked(readIosApplicationIdentifierAsync).mockImplementation(() => {
|
||||
metadataStarted.resolve();
|
||||
return metadata.promise;
|
||||
});
|
||||
mockSessionWithFailingDaemon(async () => {
|
||||
await installStarted.promise;
|
||||
// The install finishes only after the daemon failed.
|
||||
setImmediate(() => install.resolve({ applicationIdentifier: 'dev.example.app' }));
|
||||
await metadataStarted.promise;
|
||||
setImmediate(() => metadata.resolve('dev.example.app'));
|
||||
});
|
||||
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'daemon failed'
|
||||
);
|
||||
|
||||
expect(installBuildAsync).toHaveBeenCalledTimes(1);
|
||||
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledTimes(1);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
expect(deviceLaunch).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects conflicting application inputs before it boots anything', async () => {
|
||||
@@ -370,12 +623,90 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
'open_url',
|
||||
'network_capture',
|
||||
'network_capture_fields',
|
||||
'public_preview',
|
||||
'private_preview_url',
|
||||
'frame_ancestors',
|
||||
'cors_origins',
|
||||
'package_version',
|
||||
'max_idle_time_minutes',
|
||||
'max_duration_seconds',
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([undefined, true, false])(
|
||||
'passes public_preview=%s to the iOS session host',
|
||||
async publicPreview => {
|
||||
await runStep(BuildRuntimePlatform.DARWIN, {
|
||||
public_preview: publicPreview,
|
||||
private_preview_url: 'https://expo.dev/private-preview',
|
||||
});
|
||||
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
disableShare: publicPreview === false,
|
||||
privatePreviewUrl: 'https://expo.dev/private-preview',
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('passes frame_ancestors to the session host', async () => {
|
||||
await runStep(BuildRuntimePlatform.DARWIN, {
|
||||
frame_ancestors: ['https://embed.example.test'],
|
||||
});
|
||||
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ frameAncestors: ['https://embed.example.test'] })
|
||||
);
|
||||
});
|
||||
|
||||
it('passes cors_origins to the session host', async () => {
|
||||
await runStep(BuildRuntimePlatform.DARWIN, {
|
||||
cors_origins: ['https://app.example.test'],
|
||||
});
|
||||
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ corsOrigins: ['https://app.example.test'] })
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['argument count', { launch_args: Array(257).fill('a') }],
|
||||
['argument length', { launch_args: ['a'.repeat(9000)] }],
|
||||
['NUL argument', { launch_args: ['a\u0000b'] }],
|
||||
['total UTF-8 bytes', { launch_args: Array(8).fill('😃'.repeat(4096)) }],
|
||||
['NUL URL', { open_url: 'example:\u0000screen' }],
|
||||
['URL length', { open_url: `example://${'a'.repeat(8192)}` }],
|
||||
])('rejects iOS launch %s before startup work', async (_name, inputs) => {
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id', ...inputs });
|
||||
await expect(step).rejects.toBeInstanceOf(UserError);
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
expect(IosSimulatorUtils.resolveUdidAsync).not.toHaveBeenCalled();
|
||||
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['NUL argument', { launch_args: ['a\u0000b'] }],
|
||||
['command length', { launch_args: ['--es', 'quotes', "'".repeat(20_000)] }],
|
||||
['NUL URL', { open_url: 'example:\u0000screen' }],
|
||||
])('rejects Android launch %s before startup work', async (_name, inputs) => {
|
||||
const step = runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id', ...inputs });
|
||||
await expect(step).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
|
||||
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps Android launch inputs on its existing direct path', async () => {
|
||||
const launchArgs = ['a'.repeat(9000)];
|
||||
await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id', launch_args: launchArgs });
|
||||
expect(launchApplicationAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX, launchArgs })
|
||||
);
|
||||
});
|
||||
|
||||
it('passes network capture to the session', async () => {
|
||||
await runStep(BuildRuntimePlatform.DARWIN, {
|
||||
network_capture: true,
|
||||
|
||||
+550
-10
@@ -1,4 +1,4 @@
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
|
||||
@@ -6,7 +6,19 @@ import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession';
|
||||
import * as processes from '../../../utils/processes';
|
||||
import { sleepAsync } from '../../../utils/retry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { startAppiumEventCollectionAsync } from '../../utils/appiumEvents';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import { preparePreinstalledWdaAsync } from '../../utils/preinstalledWda';
|
||||
import {
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
|
||||
import {
|
||||
createStartAppiumRemoteSessionBuildFunction,
|
||||
@@ -19,13 +31,45 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
|
||||
AndroidEmulatorUtils: { getAttachedDevicesAsync: jest.fn() },
|
||||
}));
|
||||
jest.mock('../../../utils/IosSimulatorUtils', () => ({
|
||||
IosSimulatorUtils: { getAvailableDevicesAsync: jest.fn() },
|
||||
IosSimulatorUtils: {
|
||||
getAvailableDevicesAsync: jest.fn(),
|
||||
getDeviceAsync: jest.fn(),
|
||||
resolveUdidAsync: jest.fn().mockResolvedValue('chosen-ios-udid'),
|
||||
},
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
ensureFfmpegInstalledOnceAsync: jest.fn(),
|
||||
spawnDetached: jest.fn(),
|
||||
startNgrokTunnelAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../../utils/retry', () => ({
|
||||
...jest.requireActual('../../../utils/retry'),
|
||||
sleepAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../utils/turtleFetch', () => ({
|
||||
...jest.requireActual('../../../utils/turtleFetch'),
|
||||
turtleFetch: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/appiumEvents', () => ({ startAppiumEventCollectionAsync: jest.fn() }));
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/preinstalledWda', () => ({ preparePreinstalledWdaAsync: jest.fn() }));
|
||||
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/localEgressSession', () => ({
|
||||
...jest.requireActual('../../utils/localEgressSession'),
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as never;
|
||||
|
||||
@@ -51,23 +95,25 @@ describe(resolveAppiumDeviceAsync, () => {
|
||||
});
|
||||
|
||||
it('returns XCUITest capabilities for the booted iOS simulator', async () => {
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([{ udid: 'ios-simulator-id' } as never]);
|
||||
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockResolvedValue([
|
||||
{
|
||||
udid: 'ios-simulator-id',
|
||||
runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5',
|
||||
} as never,
|
||||
]);
|
||||
|
||||
await expect(
|
||||
resolveAppiumDeviceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: {},
|
||||
logger,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
platformName: 'iOS',
|
||||
automationName: 'XCUITest',
|
||||
driverName: 'xcuitest',
|
||||
udid: 'ios-simulator-id',
|
||||
runtimeIdentifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5',
|
||||
});
|
||||
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledWith({ env: {}, logger });
|
||||
expect(IosSimulatorUtils.getAvailableDevicesAsync).toHaveBeenCalledWith({
|
||||
env: {},
|
||||
filter: 'booted',
|
||||
@@ -83,7 +129,6 @@ describe(resolveAppiumDeviceAsync, () => {
|
||||
resolveAppiumDeviceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
platformName: 'Android',
|
||||
@@ -164,6 +209,501 @@ describe('createStartAppiumRemoteSessionBuildFunction', () => {
|
||||
|
||||
expect(
|
||||
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
|
||||
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
'launch_app_identifier',
|
||||
'launch_args',
|
||||
'open_url',
|
||||
'public_preview',
|
||||
'private_preview_url',
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () => {
|
||||
const stopError = new Error('Process output drain timed out after 5000ms.');
|
||||
const stopAppium = jest.fn();
|
||||
const stopEventCollection = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(ensureFfmpegInstalledOnceAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(preparePreinstalledWdaAsync).mockResolvedValue({});
|
||||
jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({
|
||||
udid: 'CHOSEN-IOS-UDID',
|
||||
runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5',
|
||||
state: 'Shutdown',
|
||||
} as never);
|
||||
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => ({
|
||||
stdout: args.includes('--json') ? '{}' : '',
|
||||
})) as never);
|
||||
jest
|
||||
.mocked(AndroidEmulatorUtils.getAttachedDevicesAsync)
|
||||
.mockResolvedValue([{ serialId: 'emulator-5554', state: 'device' } as never]);
|
||||
stopAppium.mockRejectedValue(stopError);
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 4242,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: stopAppium,
|
||||
});
|
||||
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as never);
|
||||
stopEventCollection.mockResolvedValue(undefined);
|
||||
jest.mocked(startAppiumEventCollectionAsync).mockResolvedValue({
|
||||
stopAsync: stopEventCollection,
|
||||
getLastEventObservedAt: () => undefined,
|
||||
});
|
||||
jest.mocked(startNgrokTunnelAsync).mockResolvedValue({
|
||||
url: 'https://appium-abc.tunnel.example.com',
|
||||
subdomainId: 'appium-abc',
|
||||
stopAsync: jest.fn(),
|
||||
});
|
||||
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: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function runSessionAsync(
|
||||
logger: { info: jest.Mock; warn: jest.Mock },
|
||||
maxIdleTimeMinutes?: number,
|
||||
runtimePlatform = BuildRuntimePlatform.LINUX,
|
||||
startupInputs: Record<string, { value: unknown }> = {}
|
||||
): Promise<void> {
|
||||
await createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: maxIdleTimeMinutes },
|
||||
...startupInputs,
|
||||
},
|
||||
outputs: {},
|
||||
env: {
|
||||
DEVICE_RUN_SESSION_ID: 'device-run-session-id',
|
||||
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'tunnel.example.com',
|
||||
NGROK_AUTHTOKEN: 'ngrok-token',
|
||||
},
|
||||
} as never
|
||||
);
|
||||
}
|
||||
|
||||
function expectAppiumStoppedAndHomeRemoved(logger: { warn: jest.Mock }): void {
|
||||
expect(stopAppium).toHaveBeenCalledTimes(1);
|
||||
expect(fs.existsSync(jest.mocked(spawnDetached).mock.calls[0][0].env.APPIUM_HOME!)).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: stopError },
|
||||
'Could not stop the Appium server during remote session teardown.'
|
||||
);
|
||||
}
|
||||
|
||||
it.each([false, true])(
|
||||
'preserves preview settings with Simulator startup %s',
|
||||
async bootSimulator => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([{ udid: 'chosen-ios-udid' } as never]);
|
||||
await runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
|
||||
...(bootSimulator
|
||||
? { boot_simulator: { value: true }, device_identifier: { value: 'chosen-device' } }
|
||||
: {}),
|
||||
public_preview: { value: false },
|
||||
private_preview_url: { value: 'https://expo.dev/private-preview' },
|
||||
frame_ancestors: { value: ['https://preview.example.com'] },
|
||||
cors_origins: { value: ['https://api.example.com'] },
|
||||
});
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
disableShare: true,
|
||||
privatePreviewUrl: 'https://expo.dev/private-preview',
|
||||
frameAncestors: ['https://preview.example.com'],
|
||||
corsOrigins: ['https://api.example.com'],
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('selects Xcode only once for an already-prepared iOS session', async () => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockClear();
|
||||
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockResolvedValue([
|
||||
{
|
||||
udid: 'chosen-ios-udid',
|
||||
runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-26-4',
|
||||
} as never,
|
||||
]);
|
||||
jest.mocked(preparePreinstalledWdaAsync).mockResolvedValue({
|
||||
'appium:usePreinstalledWDA': true,
|
||||
'appium:prebuiltWDAPath': '/tmp/legacy-wda.app',
|
||||
});
|
||||
await runSessionAsync(
|
||||
{ info: jest.fn(), warn: jest.fn() },
|
||||
undefined,
|
||||
BuildRuntimePlatform.DARWIN
|
||||
);
|
||||
expect(preparePreinstalledWdaAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ runtimeIdentifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-4' })
|
||||
);
|
||||
expect(IosSimulatorUtils.getDeviceAsync).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(jest.mocked(spawnDetached).mock.calls[0][0].args.at(-1)!)).toEqual({
|
||||
'appium:eventTimings': true,
|
||||
'appium:usePreinstalledWDA': true,
|
||||
'appium:prebuiltWDAPath': '/tmp/legacy-wda.app',
|
||||
});
|
||||
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
|
||||
).toBeLessThan(
|
||||
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('starts serve-sim on the selected iOS device while Appium installation is pending', async () => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockClear();
|
||||
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockClear();
|
||||
jest.mocked(preparePreinstalledWdaAsync).mockResolvedValue({
|
||||
'appium:usePreinstalledWDA': true,
|
||||
'appium:prebuiltWDAPath': '/tmp/selected-wda.app',
|
||||
});
|
||||
let releaseInstall!: (value: unknown) => void;
|
||||
let enteredInstall!: () => void;
|
||||
const installEntered = new Promise<void>(resolve => {
|
||||
enteredInstall = resolve;
|
||||
});
|
||||
jest.mocked(spawn).mockImplementationOnce(() => {
|
||||
enteredInstall();
|
||||
return new Promise(resolve => {
|
||||
releaseInstall = resolve;
|
||||
}) as never;
|
||||
});
|
||||
const running = createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
device_identifier: { value: 'chosen-device' },
|
||||
install_app_path: { value: '/tmp/App.app' },
|
||||
launch_args: { value: ['--literal'] },
|
||||
open_url: { value: 'example://home' },
|
||||
},
|
||||
outputs: {},
|
||||
env: {
|
||||
DEVICE_RUN_SESSION_ID: 'device-run-session-id',
|
||||
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'tunnel.example.com',
|
||||
NGROK_AUTHTOKEN: 'ngrok-token',
|
||||
},
|
||||
} as never
|
||||
);
|
||||
await installEntered;
|
||||
expect(
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
|
||||
).toBeLessThan(jest.mocked(IosSimulatorUtils.resolveUdidAsync).mock.invocationCallOrder[0]);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
iosSimulatorUdid: 'CHOSEN-IOS-UDID',
|
||||
installAppPath: '/tmp/App.app',
|
||||
launchAppIdentifier: 'dev.example.app',
|
||||
launchArgs: ['--literal'],
|
||||
openUrl: 'example://home',
|
||||
})
|
||||
);
|
||||
expect(IosSimulatorUtils.getAvailableDevicesAsync).not.toHaveBeenCalled();
|
||||
expect(preparePreinstalledWdaAsync).not.toHaveBeenCalled();
|
||||
const readyHost = await jest.mocked(startDeviceSessionHostAsync).mock.results[0].value;
|
||||
expect(readyHost.finishAsync).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1].signal?.aborted).toBe(false);
|
||||
releaseInstall({ stdout: '' });
|
||||
await running;
|
||||
expect(readyHost.finishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(IosSimulatorUtils.getDeviceAsync).toHaveBeenCalledWith({
|
||||
udid: 'CHOSEN-IOS-UDID',
|
||||
env: expect.objectContaining({ DEVICE_RUN_SESSION_ID: 'device-run-session-id' }),
|
||||
});
|
||||
expect(preparePreinstalledWdaAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runtimeIdentifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5',
|
||||
appiumHome: jest.mocked(spawnDetached).mock.calls[0][0].env.APPIUM_HOME,
|
||||
})
|
||||
);
|
||||
expect(JSON.parse(jest.mocked(spawnDetached).mock.calls[0][0].args.at(-1)!)).toEqual({
|
||||
'appium:eventTimings': true,
|
||||
'appium:usePreinstalledWDA': true,
|
||||
'appium:prebuiltWDAPath': '/tmp/selected-wda.app',
|
||||
});
|
||||
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledTimes(1);
|
||||
expect(stopAppium).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('drains a failed Simulator startup installer before removing its Appium home', async () => {
|
||||
const hostError = new Error('Simulator boot failed');
|
||||
let failHost!: (error: Error) => void;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
failHost = reject;
|
||||
})
|
||||
);
|
||||
let enteredInstall!: () => void;
|
||||
const installEntered = new Promise<void>(resolve => {
|
||||
enteredInstall = resolve;
|
||||
});
|
||||
let closeInstaller!: (error: Error) => void;
|
||||
const child = { pid: 12345, kill: jest.fn() };
|
||||
jest.mocked(spawn).mockImplementationOnce(() => {
|
||||
enteredInstall();
|
||||
return Object.assign(
|
||||
new Promise((_resolve, reject) => {
|
||||
closeInstaller = reject;
|
||||
}),
|
||||
{ child }
|
||||
) as never;
|
||||
});
|
||||
const killGroup = jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {});
|
||||
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
|
||||
boot_simulator: { value: true },
|
||||
device_identifier: { value: 'chosen-device' },
|
||||
});
|
||||
const settled = jest.fn();
|
||||
const observed = running.then(settled, settled);
|
||||
try {
|
||||
await installEntered;
|
||||
const home = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
|
||||
const hostOptions = jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1];
|
||||
hostOptions.onStartupError?.(hostError);
|
||||
failHost(hostError);
|
||||
await Promise.resolve();
|
||||
expect(killGroup).toHaveBeenCalledWith(child, 'SIGKILL');
|
||||
expect(settled).not.toHaveBeenCalled();
|
||||
expect(fs.existsSync(home)).toBe(true);
|
||||
closeInstaller(new Error('installer interrupted'));
|
||||
await expect(running).rejects.toBe(hostError);
|
||||
expect(fs.existsSync(home)).toBe(false);
|
||||
expect(preparePreinstalledWdaAsync).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
closeInstaller?.(new Error('test cleanup'));
|
||||
await observed;
|
||||
}
|
||||
});
|
||||
|
||||
it('removes a completed Appium install when Simulator failure interrupts FFmpeg setup', async () => {
|
||||
const hostError = new Error('Simulator startup failed');
|
||||
let failHost!: (error: Error) => void;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
failHost = reject;
|
||||
})
|
||||
);
|
||||
jest
|
||||
.mocked(ensureFfmpegInstalledOnceAsync)
|
||||
.mockImplementationOnce(
|
||||
({ signal }) =>
|
||||
new Promise(resolve => signal?.addEventListener('abort', () => resolve(), { once: true }))
|
||||
);
|
||||
let installed!: () => void;
|
||||
const installationFinished = new Promise<void>(resolve => {
|
||||
installed = resolve;
|
||||
});
|
||||
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => {
|
||||
if (args[0] === 'driver' && args[1] === 'install') {
|
||||
installed();
|
||||
}
|
||||
return { stdout: args.includes('--json') ? '{}' : '' };
|
||||
}) as never);
|
||||
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
|
||||
boot_simulator: { value: true },
|
||||
device_identifier: { value: 'chosen-device' },
|
||||
});
|
||||
await installationFinished;
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1].onStartupError?.(hostError);
|
||||
failHost(hostError);
|
||||
await expect(running).rejects.toBe(hostError);
|
||||
const home = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
|
||||
expect(fs.existsSync(home)).toBe(false);
|
||||
expect(preparePreinstalledWdaAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves an installer failure before a later host failure during home cleanup', async () => {
|
||||
const installError = new Error('npm install failed');
|
||||
const hostError = new Error('later Simulator failure');
|
||||
let failHost!: (error: Error) => void;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
failHost = reject;
|
||||
})
|
||||
);
|
||||
jest.mocked(spawn).mockRejectedValueOnce(installError);
|
||||
const remove = fs.promises.rm.bind(fs.promises);
|
||||
let enteredCleanup!: () => void;
|
||||
const cleanupEntered = new Promise<void>(resolve => {
|
||||
enteredCleanup = resolve;
|
||||
});
|
||||
let releaseCleanup!: () => void;
|
||||
const cleanupGate = new Promise<void>(resolve => {
|
||||
releaseCleanup = resolve;
|
||||
});
|
||||
jest.spyOn(fs.promises, 'rm').mockImplementationOnce(async (directory, options) => {
|
||||
enteredCleanup();
|
||||
await cleanupGate;
|
||||
await remove(directory, options);
|
||||
});
|
||||
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
|
||||
boot_simulator: { value: true },
|
||||
device_identifier: { value: 'chosen-device' },
|
||||
});
|
||||
const observed = running.catch(() => {});
|
||||
try {
|
||||
await cleanupEntered;
|
||||
const options = jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1];
|
||||
expect(options.signal?.reason).toBe(installError);
|
||||
options.onStartupError?.(hostError);
|
||||
failHost(hostError);
|
||||
releaseCleanup();
|
||||
await expect(running).rejects.toBe(installError);
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
failHost(hostError);
|
||||
releaseCleanup();
|
||||
await observed;
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['WDA download', 'Appium readiness'])(
|
||||
'keeps the Simulator error when it interrupts %s',
|
||||
async stage => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
const hostError = new Error('Simulator launch failed');
|
||||
const laterError = new Error(`${stage} interrupted`);
|
||||
let failHost!: (error: Error) => void;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
failHost = reject;
|
||||
})
|
||||
);
|
||||
let enteredStage!: () => void;
|
||||
const stageEntered = new Promise<void>(resolve => {
|
||||
enteredStage = resolve;
|
||||
});
|
||||
let rejectStage!: (error: Error) => void;
|
||||
let setupSignal: AbortSignal | undefined;
|
||||
const waitForStage = (signal?: AbortSignal): Promise<never> => {
|
||||
setupSignal = signal;
|
||||
enteredStage();
|
||||
return new Promise((_resolve, reject) => {
|
||||
rejectStage = reject;
|
||||
signal?.addEventListener('abort', () => reject(laterError), { once: true });
|
||||
});
|
||||
};
|
||||
if (stage === 'WDA download') {
|
||||
jest
|
||||
.mocked(preparePreinstalledWdaAsync)
|
||||
.mockImplementationOnce(({ signal }) => waitForStage(signal));
|
||||
} else {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockImplementationOnce((_url, _method, options) =>
|
||||
waitForStage(options?.signal ?? undefined)
|
||||
);
|
||||
}
|
||||
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
|
||||
boot_simulator: { value: true },
|
||||
device_identifier: { value: 'chosen-device' },
|
||||
});
|
||||
const observed = running.catch(() => {});
|
||||
try {
|
||||
await stageEntered;
|
||||
jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1].onStartupError?.(hostError);
|
||||
failHost(hostError);
|
||||
await Promise.resolve();
|
||||
expect(setupSignal?.aborted).toBe(true);
|
||||
await expect(running).rejects.toBe(hostError);
|
||||
const home = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
|
||||
expect(fs.existsSync(home)).toBe(false);
|
||||
expect(startAppiumEventCollectionAsync).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
rejectStage?.(laterError);
|
||||
await observed;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('keeps the readiness error when Appium cannot be stopped', async () => {
|
||||
let now = Date.now();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
jest.mocked(sleepAsync).mockImplementation(async ms => {
|
||||
now += ms;
|
||||
});
|
||||
jest.mocked(turtleFetch).mockRejectedValue(new Error('connect ECONNREFUSED'));
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
|
||||
await expect(runSessionAsync(logger)).rejects.toThrow(
|
||||
'Timed out waiting for Appium to become ready.'
|
||||
);
|
||||
expectAppiumStoppedAndHomeRemoved(logger);
|
||||
expect(startAppiumEventCollectionAsync).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the event collection error when Appium cannot be stopped', async () => {
|
||||
const collectionError = new Error('event collection failed');
|
||||
jest.mocked(startAppiumEventCollectionAsync).mockRejectedValueOnce(collectionError);
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
|
||||
await expect(runSessionAsync(logger)).rejects.toBe(collectionError);
|
||||
expectAppiumStoppedAndHomeRemoved(logger);
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resets the idle timer on preview input from the session host', async () => {
|
||||
const previewInputAt = new Date('2026-10-19T12:00:00Z');
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
|
||||
{} as never,
|
||||
{} as never
|
||||
);
|
||||
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(previewInputAt);
|
||||
|
||||
await expect(runSessionAsync({ info: jest.fn(), warn: jest.fn() }, 5)).rejects.toBe(stopError);
|
||||
|
||||
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
|
||||
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
|
||||
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(previewInputAt);
|
||||
});
|
||||
|
||||
it('fails a finished session when Appium cannot be stopped', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
|
||||
await expect(runSessionAsync(logger)).rejects.toBe(stopError);
|
||||
expect(preparePreinstalledWdaAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ runtimeIdentifier: undefined })
|
||||
);
|
||||
expect(IosSimulatorUtils.getDeviceAsync).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(jest.mocked(spawnDetached).mock.calls[0][0].args.at(-1)!)).toEqual({
|
||||
'appium:eventTimings': true,
|
||||
});
|
||||
expectAppiumStoppedAndHomeRemoved(logger);
|
||||
expect(stopEventCollection.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
stopAppium.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
});
|
||||
+628
-8
@@ -6,10 +6,13 @@ import path from 'node:path';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import { isProcessDescendantOfAsync } from '../../../utils/processes';
|
||||
import { isProcessDescendantOfAsync, killProcessGroup } from '../../../utils/processes';
|
||||
import { pollArgentArtifactsForUploadAsync } from '../../utils/argentArtifacts';
|
||||
import { startArgentEventCollectionAsync } from '../../utils/argentEvents';
|
||||
import { createProcessOutput } from '../../utils/processOutput';
|
||||
import {
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
@@ -22,6 +25,7 @@ import {
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartArgentRemoteSessionBuildFunction } from '../startArgentRemoteSession';
|
||||
import { readIosApplicationIdentifierAsync } from '../../utils/iosAppArtifact';
|
||||
|
||||
// Redirect ~/.argent (where the tool-server writes its state file and event log) to a temp
|
||||
// home so waitForArgentToolServerStateAsync — which lives in the module under test and cannot
|
||||
@@ -36,13 +40,32 @@ jest.mock('node:os', () => {
|
||||
});
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../../utils/processes', () => ({ isProcessDescendantOfAsync: jest.fn() }));
|
||||
jest.mock('../../../utils/processes', () => ({
|
||||
isProcessDescendantOfAsync: jest.fn(),
|
||||
killProcessGroup: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/argentArtifacts', () => ({ pollArgentArtifactsForUploadAsync: jest.fn() }));
|
||||
jest.mock('../../utils/argentEvents', () => ({
|
||||
...jest.requireActual('../../utils/argentEvents'),
|
||||
startArgentEventCollectionAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/iosAppArtifact', () => ({
|
||||
...jest.requireActual('../../utils/iosAppArtifact'),
|
||||
readIosApplicationIdentifierAsync: jest.fn().mockResolvedValue('dev.example.app'),
|
||||
}));
|
||||
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/localEgressGuard', () => ({
|
||||
resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
ensureFfmpegInstalledOnceAsync: jest.fn(),
|
||||
@@ -102,6 +125,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
apiUrl: 'https://web-preview.tunnel.example.com',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: mockPreviewStopAsync,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
@@ -119,18 +143,21 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
await fs.promises.rm(TEST_HOME, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reports an early exit with output before opening tunnels or publishing readiness', async () => {
|
||||
it('reports an early exit with output and stops Argent before opening tunnels', async () => {
|
||||
const stopError = new Error('drain timed out');
|
||||
const stopServer = jest.fn().mockRejectedValue(stopError);
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 4242,
|
||||
getOutput: () => 'could not bind server port',
|
||||
getExitError: () => new Error('process exited with code 1'),
|
||||
stopAsync: jest.fn(),
|
||||
stopAsync: stopServer,
|
||||
});
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
await expect(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
@@ -145,11 +172,66 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
).rejects.toThrow(
|
||||
'Argent exited before becoming ready: process exited with code 1\nArgent tool-server output:\ncould not bind server port'
|
||||
);
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: stopError },
|
||||
'Could not stop the Argent tool-server during remote session teardown.'
|
||||
);
|
||||
expect(pollArgentArtifactsForUploadAsync).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
expect(startArgentEventCollectionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drains artifact polling and stops the tool-server when event collection cannot start', async () => {
|
||||
const collectionError = new Error('event collection failed');
|
||||
const stopError = new Error('drain timed out');
|
||||
let pollingFinished = false;
|
||||
jest
|
||||
.mocked(pollArgentArtifactsForUploadAsync)
|
||||
.mockImplementationOnce(async (_ctx, { signal }) => {
|
||||
await new Promise(resolve => signal.addEventListener('abort', resolve, { once: true }));
|
||||
pollingFinished = true;
|
||||
});
|
||||
jest.mocked(startArgentEventCollectionAsync).mockRejectedValueOnce(collectionError);
|
||||
const stopServer = jest.fn(async () => {
|
||||
expect(pollingFinished).toBe(true);
|
||||
throw stopError;
|
||||
});
|
||||
jest.mocked(spawnDetached).mockReturnValueOnce({
|
||||
pid: 4242,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: stopServer,
|
||||
});
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
await expect(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
).rejects.toBe(collectionError);
|
||||
expect(pollingFinished).toBe(true);
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: stopError },
|
||||
'Could not stop the Argent tool-server during remote session teardown.'
|
||||
);
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['preview', 'config', 'wait'])(
|
||||
'finishes the host and tools after %s fails',
|
||||
async phase => {
|
||||
@@ -157,6 +239,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
if (phase === 'preview') {
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
|
||||
openPreviewAsync: jest.fn().mockRejectedValue(error),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: mockPreviewStopAsync,
|
||||
});
|
||||
} else if (phase === 'config') {
|
||||
@@ -188,7 +271,60 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
}
|
||||
);
|
||||
|
||||
it('enables the event log flag, shares one path, and starts/stops the collector', async () => {
|
||||
it.each([
|
||||
['succeeded', false],
|
||||
['failed', true],
|
||||
])(
|
||||
'fails after stopping the host and tools when the tools tunnel cannot close and the session %s',
|
||||
async (_description, sessionFails) => {
|
||||
const closeError = new Error('ngrok close failed');
|
||||
const sessionError = new Error('session failed');
|
||||
mockTunnelStopAsync.mockRejectedValueOnce(closeError);
|
||||
if (sessionFails) {
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValueOnce(sessionError);
|
||||
}
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
// The tunnel may still be serving, so its failure fails a session that otherwise succeeded.
|
||||
await expect(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
).rejects.toBe(sessionFails ? sessionError : closeError);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(jest.mocked(spawnDetached).mock.results[0].value.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: closeError },
|
||||
'Could not stop the Argent tunnel during remote session teardown.'
|
||||
);
|
||||
expect(jest.mocked(Sentry.capture).mock.calls).toEqual(
|
||||
sessionFails
|
||||
? [
|
||||
[
|
||||
'Could not stop the Argent tunnel after the remote session failed',
|
||||
closeError,
|
||||
{ level: 'warning' },
|
||||
],
|
||||
]
|
||||
: []
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('enables the event log flag, disables the video watermark, shares one path, and starts/stops the collector', async () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
|
||||
|
||||
@@ -231,6 +367,20 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
jest.mocked(spawnDetached).mock.invocationCallOrder[0]
|
||||
);
|
||||
|
||||
// (2b) The video watermark flag is disabled, before the tool-server is launched.
|
||||
const disableWatermarkIndex = spawnCalls.findIndex(
|
||||
([command, args]) =>
|
||||
command === 'bun' &&
|
||||
Array.isArray(args) &&
|
||||
args[0] === 'x' &&
|
||||
args.includes('disable') &&
|
||||
args.includes('video-watermark')
|
||||
);
|
||||
expect(disableWatermarkIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(jest.mocked(spawn).mock.invocationCallOrder[disableWatermarkIndex]).toBeLessThan(
|
||||
jest.mocked(spawnDetached).mock.invocationCallOrder[0]
|
||||
);
|
||||
|
||||
// (3) The tool-server and the collector are pinned to the exact same event log path.
|
||||
const serverEnv = jest.mocked(spawnDetached).mock.calls[0][0].env;
|
||||
expect(serverEnv.ARGENT_EVENT_LOG).toBe(EXPECTED_EVENT_LOG_PATH);
|
||||
@@ -254,7 +404,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
|
||||
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX, disableShare: false })
|
||||
);
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -267,6 +417,194 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('passes public_preview=false to the session host', async () => {
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
|
||||
await buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
public_preview: { value: false },
|
||||
private_preview_url: { value: 'https://expo.dev/private-preview' },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
disableShare: true,
|
||||
privatePreviewUrl: 'https://expo.dev/private-preview',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('passes frame_ancestors to the session host', async () => {
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
|
||||
await buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
frame_ancestors: { value: ['https://embed.example.test'] },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ frameAncestors: ['https://embed.example.test'] })
|
||||
);
|
||||
});
|
||||
|
||||
it('passes cors_origins to the session host', async () => {
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
|
||||
await buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
cors_origins: { value: ['https://app.example.test'] },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ corsOrigins: ['https://app.example.test'] })
|
||||
);
|
||||
});
|
||||
|
||||
it('continues the session when disabling the video watermark fails', async () => {
|
||||
const error = new Error('bun x failed');
|
||||
jest
|
||||
.mocked(spawn)
|
||||
.mockImplementation(
|
||||
(_command, args) =>
|
||||
(args.includes('video-watermark')
|
||||
? Promise.reject(error)
|
||||
: Promise.resolve(undefined)) as never
|
||||
);
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
|
||||
await buildFunction.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: error },
|
||||
expect.stringContaining('Could not disable the Argent video watermark')
|
||||
);
|
||||
expect(spawnDetached).toHaveBeenCalledTimes(1);
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
|
||||
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('resets the idle timer on preview input from the session host', async () => {
|
||||
const previewInputAt = new Date('2026-10-19T12:00:00Z');
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
|
||||
{} as never,
|
||||
{} as never
|
||||
);
|
||||
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(previewInputAt);
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
|
||||
await buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: 5 },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
|
||||
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
|
||||
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
|
||||
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(previewInputAt);
|
||||
});
|
||||
|
||||
it('redacts startup credentials and registers the state token for later output', async () => {
|
||||
const token = 'tool/server "secret"';
|
||||
await fs.promises.writeFile(
|
||||
path.join(ARGENT_STATE_DIR, 'tool-server-orchestration.json'),
|
||||
JSON.stringify({ port: 5678, pid: 9999, token })
|
||||
);
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
jest.mocked(spawnDetached).mockImplementationOnce(options => {
|
||||
const output = createProcessOutput(options.logger, options.secrets);
|
||||
output.stdout.append(`argent link argent://${encodeURIComponent(token)}@127.0.0.1:5678\n`);
|
||||
return {
|
||||
pid: 4242,
|
||||
getOutput: output.getOutput,
|
||||
getExitError: () => undefined,
|
||||
stopAsync: async () => {
|
||||
output.stderr.append(`opaque ${token}\nencoded ${encodeURIComponent(token)}\n`);
|
||||
output.stderr.append(`escaped ${JSON.stringify(token).slice(1, -1)}\n`);
|
||||
output.finish();
|
||||
},
|
||||
};
|
||||
});
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
await buildFunction.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
{ source: 'stdout' },
|
||||
'argent link argent://[REDACTED]@127.0.0.1:5678'
|
||||
);
|
||||
for (const label of ['opaque', 'encoded', 'escaped']) {
|
||||
expect(logger.info).toHaveBeenCalledWith({ source: 'stderr' }, `${label} [REDACTED]`);
|
||||
}
|
||||
expect(JSON.stringify(logger.info.mock.calls)).not.toContain('secret');
|
||||
});
|
||||
|
||||
it('hands the launch inputs to serve-sim and announces them on an iOS session', async () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
@@ -306,6 +644,280 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('boots and prepares the downloaded app while Argent installation is pending, and closes the host if installation fails', async () => {
|
||||
let rejectInstall!: (error: Error) => void;
|
||||
let enteredInstall!: () => void;
|
||||
const installEntered = new Promise<void>(resolve => {
|
||||
enteredInstall = resolve;
|
||||
});
|
||||
jest.mocked(spawn).mockImplementationOnce(() => {
|
||||
enteredInstall();
|
||||
return new Promise((_resolve, reject) => {
|
||||
rejectInstall = reject;
|
||||
}) as never;
|
||||
});
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
const running = buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
device_identifier: { value: 'chosen-device' },
|
||||
install_app_path: { value: '/tmp/App.app' },
|
||||
launch_args: { value: ['--literal'] },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
await installEntered;
|
||||
expect(
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
|
||||
).toBeLessThan(jest.mocked(IosSimulatorUtils.resolveUdidAsync).mock.invocationCallOrder[0]);
|
||||
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({
|
||||
artifactPath: '/tmp/App.app',
|
||||
env: {},
|
||||
});
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
iosSimulatorUdid: 'SELECTED-IOS-UDID',
|
||||
installAppPath: '/tmp/App.app',
|
||||
launchAppIdentifier: 'dev.example.app',
|
||||
launchArgs: ['--literal'],
|
||||
})
|
||||
);
|
||||
const error = new Error('Argent install failed');
|
||||
rejectInstall(error);
|
||||
await expect(running).rejects.toBe(error);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['artifacts-list-endpoint', 'tool-server-event-log', 'video-watermark'])(
|
||||
'stops and drains the pending %s command when the concurrent host fails',
|
||||
async flag => {
|
||||
const hostError = new Error('serve-sim startup failed');
|
||||
const commandError = new Error('Argent command exited after cancellation');
|
||||
let failHost!: (error: Error) => void;
|
||||
let finishHostCleanup!: () => void;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(
|
||||
async (_ctx, options) =>
|
||||
await new Promise((_resolve, reject) => {
|
||||
failHost = error => {
|
||||
options.onStartupError?.(error);
|
||||
finishHostCleanup = () => reject(error);
|
||||
};
|
||||
})
|
||||
);
|
||||
let failCommand!: (error: Error) => void;
|
||||
let commandStarted!: () => void;
|
||||
const commandEntered = new Promise<void>(resolve => {
|
||||
commandStarted = resolve;
|
||||
});
|
||||
const child = { pid: 4242, kill: jest.fn() };
|
||||
jest.mocked(spawn).mockImplementation((_command, args) => {
|
||||
if (!args.includes(flag)) {
|
||||
return Promise.resolve(undefined) as never;
|
||||
}
|
||||
const pending = new Promise((_resolve, reject) => {
|
||||
failCommand = reject;
|
||||
});
|
||||
commandStarted();
|
||||
return Object.assign(pending, { child }) as never;
|
||||
});
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
const running = Promise.resolve(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
boot_simulator: { value: true },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
);
|
||||
let settled = false;
|
||||
void running
|
||||
.finally(() => {
|
||||
settled = true;
|
||||
})
|
||||
.catch(() => {});
|
||||
try {
|
||||
await commandEntered;
|
||||
failHost(hostError);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(killProcessGroup).toHaveBeenCalledWith(child, 'SIGKILL');
|
||||
expect(settled).toBe(false);
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
failCommand(commandError);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(settled).toBe(false);
|
||||
finishHostCleanup();
|
||||
await expect(running).rejects.toBe(hostError);
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
failCommand?.(commandError);
|
||||
finishHostCleanup?.();
|
||||
await running.catch(() => {});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('interrupts tool-server state polling and retains the original host error', async () => {
|
||||
await fs.promises.rm(path.join(ARGENT_STATE_DIR, 'tool-server-orchestration.json'));
|
||||
const hostError = new Error('serve-sim startup failed');
|
||||
let failHost!: (error: Error) => void;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(
|
||||
async () =>
|
||||
await new Promise((_resolve, reject) => {
|
||||
failHost = reject;
|
||||
})
|
||||
);
|
||||
let pollingStarted!: () => void;
|
||||
const pollingEntered = new Promise<void>(resolve => {
|
||||
pollingStarted = resolve;
|
||||
});
|
||||
const getExitError = jest.fn<Error | undefined, []>(() => {
|
||||
pollingStarted();
|
||||
return undefined;
|
||||
});
|
||||
const stopServer = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(spawnDetached).mockReturnValueOnce({
|
||||
pid: 4242,
|
||||
getOutput: () => 'later controller diagnostic',
|
||||
getExitError,
|
||||
stopAsync: stopServer,
|
||||
});
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
const running = Promise.resolve(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
boot_simulator: { value: true },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
);
|
||||
void running.catch(() => {});
|
||||
try {
|
||||
await pollingEntered;
|
||||
failHost(hostError);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
const options = jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1];
|
||||
expect(options.signal?.aborted).toBe(true);
|
||||
getExitError.mockReturnValue(new Error('later Argent exit'));
|
||||
await expect(running).rejects.toBe(hostError);
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
expect(startArgentEventCollectionAsync).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
getExitError.mockReturnValue(new Error('stop pending state poll'));
|
||||
await running.catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps Argent setup concurrent but waits for the iOS host before opening tools', async () => {
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
|
||||
{} as CustomBuildContext,
|
||||
{} as never
|
||||
);
|
||||
let releaseHost!: () => void;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
|
||||
new Promise(resolve => {
|
||||
releaseHost = () => resolve(host);
|
||||
})
|
||||
);
|
||||
let stateReady!: () => void;
|
||||
const stateRead = new Promise<void>(resolve => {
|
||||
stateReady = resolve;
|
||||
});
|
||||
jest.mocked(isProcessDescendantOfAsync).mockImplementationOnce(async () => {
|
||||
stateReady();
|
||||
return true;
|
||||
});
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
const running = Promise.resolve(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
boot_simulator: { value: true },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
);
|
||||
try {
|
||||
await stateRead;
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(spawnDetached).toHaveBeenCalledTimes(1);
|
||||
expect(startArgentEventCollectionAsync).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
releaseHost();
|
||||
await running;
|
||||
}
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
|
||||
'keeps %s prepared setup commands outside the new cancellation path',
|
||||
async runtimePlatform => {
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
await buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
expect(spawn).toHaveBeenCalledTimes(3);
|
||||
for (const [, , options] of jest.mocked(spawn).mock.calls) {
|
||||
expect(options).not.toHaveProperty('detached');
|
||||
expect(options).not.toHaveProperty('signal');
|
||||
}
|
||||
expect(killProcessGroup).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('fails before starting anything when a launch is asked for on Android', async () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
@@ -339,7 +951,15 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
|
||||
expect(
|
||||
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
|
||||
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
'launch_app_identifier',
|
||||
'launch_args',
|
||||
'open_url',
|
||||
'public_preview',
|
||||
'private_preview_url',
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('stops automation without waiting for recording upload', async () => {
|
||||
|
||||
@@ -32,6 +32,7 @@ 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 } from '@expo/eas-build-job';
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { Client, fetchExchange } from '@urql/core';
|
||||
|
||||
import * as sandboxDaemon from '../../utils/sandboxDaemon';
|
||||
@@ -6,6 +6,90 @@ 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;
|
||||
|
||||
+166
-3
@@ -3,6 +3,7 @@ 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,
|
||||
@@ -14,6 +15,22 @@ import {
|
||||
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
|
||||
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
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(),
|
||||
@@ -24,7 +41,8 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
}));
|
||||
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const env = {} as BuildStepEnv;
|
||||
const env = { NGROK_AUTHTOKEN: 'ngrok-token', PATH: '/usr/bin' } as BuildStepEnv;
|
||||
const childEnv = { PATH: '/usr/bin' } as BuildStepEnv;
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const stopAsync = jest.fn();
|
||||
|
||||
@@ -59,9 +77,11 @@ 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(undefined);
|
||||
@@ -73,10 +93,12 @@ 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',
|
||||
previewToken: 'tok-1',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: stopAsync,
|
||||
});
|
||||
|
||||
@@ -95,11 +117,62 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
);
|
||||
});
|
||||
|
||||
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') {
|
||||
@@ -115,21 +188,46 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
[BuildRuntimePlatform.DARWIN, true],
|
||||
[BuildRuntimePlatform.LINUX, false],
|
||||
])('starts the web preview for %s', async (runtimePlatform, selectsXcode) => {
|
||||
await runAsync(runtimePlatform);
|
||||
await runAsync(runtimePlatform, selectsXcode ? { boot_simulator: { value: true } } : {});
|
||||
|
||||
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,
|
||||
env: childEnv,
|
||||
logger,
|
||||
timeoutMs: 60_000,
|
||||
signal: undefined,
|
||||
...(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({
|
||||
baseDomain: 'tunnel.example.com',
|
||||
authtoken: 'ngrok-token',
|
||||
});
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
|
||||
ctx,
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
@@ -160,8 +258,15 @@ 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_duration_seconds',
|
||||
]);
|
||||
@@ -188,6 +293,64 @@ 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 },
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
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();
|
||||
await expect(task.run()).rejects.toBeInstanceOf(SystemError);
|
||||
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...',
|
||||
]);
|
||||
});
|
||||
+13
-3
@@ -20,7 +20,8 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockReset()
|
||||
.mockImplementation(async (_ctx, { stream }) => {
|
||||
.mockImplementation(async (_ctx, { openStream }) => {
|
||||
const stream = openStream();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
stream.once('end', resolve);
|
||||
stream.once('error', reject);
|
||||
@@ -139,9 +140,12 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
const names = jest
|
||||
const uploads = jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mock.calls.map(([, options]) => options.name);
|
||||
.mock.calls.map(([, options]) => options);
|
||||
const names = uploads
|
||||
.filter(options => options.kind === 'screen-recording')
|
||||
.map(options => options.name);
|
||||
expect(names).toEqual(
|
||||
expect.arrayContaining([
|
||||
'iPhone 16 screen recording (01234567-…, started at Jul 10, 2026, 10:00:00.000 UTC)',
|
||||
@@ -150,6 +154,12 @@ 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 }))
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
import { Client } from '@urql/core';
|
||||
import { Client, CombinedError } from '@urql/core';
|
||||
import contentDisposition from 'content-disposition';
|
||||
import { glob } from 'fast-glob';
|
||||
import { graphql } from 'gql.tada';
|
||||
@@ -26,8 +26,18 @@ import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { decompressTarAsync, isFileTarGzAsync } from '../../utils/files';
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
|
||||
import {
|
||||
describeError,
|
||||
isTransferFailure,
|
||||
isTransientGraphqlError,
|
||||
} from '../../utils/networkErrors';
|
||||
import { retryOnNetworkFailureAsync } from '../../utils/retryOnNetworkFailure';
|
||||
import { pluralize } from '../../utils/strings';
|
||||
import {
|
||||
type DeviceRunSessionWorkerApi,
|
||||
DeviceRunSessionWorkerApiError,
|
||||
getDeviceRunSessionWorkerApi,
|
||||
} from '../utils/deviceRunSessionWorkerApi';
|
||||
|
||||
const streamPipeline = promisify(stream.pipeline);
|
||||
|
||||
@@ -118,6 +128,7 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
|
||||
logger,
|
||||
...source,
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
deviceRunSessionWorkerApi: buildId ? getDeviceRunSessionWorkerApi(ctx) : null,
|
||||
robotAccessToken: stepsCtx.global.staticContext.job.secrets?.robotAccessToken ?? null,
|
||||
extensions,
|
||||
signal,
|
||||
@@ -131,28 +142,50 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
|
||||
async function fetchApplicationArchiveUrlAsync({
|
||||
buildId,
|
||||
graphqlClient,
|
||||
deviceRunSessionWorkerApi,
|
||||
signal,
|
||||
}: {
|
||||
buildId: string;
|
||||
graphqlClient: Client;
|
||||
deviceRunSessionWorkerApi?: DeviceRunSessionWorkerApi | null;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<string> {
|
||||
signal?.throwIfAborted();
|
||||
const result = await graphqlClient
|
||||
.query(BUILD_BY_ID_QUERY, { buildId }, graphqlAbortContext(signal))
|
||||
.toPromise();
|
||||
if (deviceRunSessionWorkerApi) {
|
||||
return await fetchDeviceRunSessionApplicationArchiveUrlAsync({
|
||||
buildId,
|
||||
deviceRunSessionWorkerApi,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
let data;
|
||||
try {
|
||||
data = await retryOnNetworkFailureAsync(
|
||||
async () => {
|
||||
const queryResult = await graphqlClient
|
||||
.query(BUILD_BY_ID_QUERY, { buildId }, graphqlAbortContext(signal))
|
||||
.toPromise();
|
||||
if (queryResult.error) {
|
||||
throw queryResult.error;
|
||||
}
|
||||
return queryResult.data;
|
||||
},
|
||||
{
|
||||
operation: 'download_build.build_lookup',
|
||||
signal,
|
||||
isRetryable: error => error instanceof CombinedError && isTransientGraphqlError(error),
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
const message = `Could not fetch build ${buildId}: ${describeError(error)}`;
|
||||
throw error instanceof CombinedError && !isTransientGraphqlError(error)
|
||||
? new UserError('EAS_DOWNLOAD_BUILD_FETCH_FAILED', message, { cause: error })
|
||||
: new SystemError(message, { cause: error });
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
|
||||
if (result.error) {
|
||||
const { error } = result;
|
||||
const message = `Could not fetch build ${buildId}: ${error.message}`;
|
||||
|
||||
throw error.networkError || result.error.response?.status >= 500
|
||||
? new SystemError(message, { cause: error })
|
||||
: new UserError('EAS_DOWNLOAD_BUILD_FETCH_FAILED', message, { cause: error });
|
||||
}
|
||||
|
||||
const applicationArchiveUrl = result.data?.builds.byId?.artifacts?.applicationArchiveUrl;
|
||||
const applicationArchiveUrl = data?.builds.byId?.artifacts?.applicationArchiveUrl;
|
||||
if (!applicationArchiveUrl) {
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_BUILD_NO_APPLICATION_ARCHIVE',
|
||||
@@ -163,17 +196,76 @@ async function fetchApplicationArchiveUrlAsync({
|
||||
return applicationArchiveUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* A device run session token can read only the build of its own session, so the build comes from
|
||||
* `GET /v2/device-run-session-worker/application` instead of the builds GraphQL query.
|
||||
*
|
||||
* Only device run session jobs, whose steps www generates from the session, reach this path, so
|
||||
* every failure here is a system error: neither the request nor `build_id` comes from the user.
|
||||
*/
|
||||
async function fetchDeviceRunSessionApplicationArchiveUrlAsync({
|
||||
buildId,
|
||||
deviceRunSessionWorkerApi,
|
||||
signal,
|
||||
}: {
|
||||
buildId: string;
|
||||
deviceRunSessionWorkerApi: DeviceRunSessionWorkerApi;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<string> {
|
||||
let application;
|
||||
try {
|
||||
application = await retryOnNetworkFailureAsync(
|
||||
() => deviceRunSessionWorkerApi.getApplicationAsync({ signal }),
|
||||
{
|
||||
operation: 'download_build.session_build_lookup',
|
||||
signal,
|
||||
isRetryable: error => error instanceof DeviceRunSessionWorkerApiError && error.isTransient,
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
signal?.throwIfAborted();
|
||||
throw new SystemError(
|
||||
`Could not fetch build ${buildId} for this device run session: ${describeError(err)}`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
|
||||
if (application.build?.id !== buildId) {
|
||||
throw new SystemError(
|
||||
`Could not fetch build ${buildId}: this device run session runs ${
|
||||
application.build ? `build ${application.build.id}` : 'no build'
|
||||
}, and its session token can read only that build. www generates this step from the ` +
|
||||
'session, so the two should always match. Start a new session, and contact support if ' +
|
||||
'it happens again.'
|
||||
);
|
||||
}
|
||||
if (!application.build.applicationArchiveUrl) {
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_BUILD_NO_APPLICATION_ARCHIVE',
|
||||
'Build does not have an application archive url'
|
||||
);
|
||||
}
|
||||
return application.build.applicationArchiveUrl;
|
||||
}
|
||||
|
||||
export async function downloadBuildAsync(
|
||||
params: DownloadBuildSource & {
|
||||
logger: bunyan;
|
||||
graphqlClient: Client;
|
||||
/**
|
||||
* Set in a device run session job that has a session token. The build lookup then uses the
|
||||
* device run session worker API instead of GraphQL.
|
||||
*/
|
||||
deviceRunSessionWorkerApi?: DeviceRunSessionWorkerApi | null;
|
||||
robotAccessToken: string | null;
|
||||
extensions: string[];
|
||||
/** Stops the build lookup, the request and the file write when aborted, and skips the rest. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
): Promise<{ artifactPath: string }> {
|
||||
const { logger, graphqlClient, robotAccessToken, extensions, signal } = params;
|
||||
const { logger, graphqlClient, deviceRunSessionWorkerApi, robotAccessToken, extensions, signal } =
|
||||
params;
|
||||
|
||||
let downloadUrl: string;
|
||||
let headers: { Authorization: string } | undefined;
|
||||
@@ -188,8 +280,19 @@ export async function downloadBuildAsync(
|
||||
headers = undefined;
|
||||
} else if (params.buildId) {
|
||||
const buildId = z.string().uuid().parse(params.buildId);
|
||||
downloadUrl = await fetchApplicationArchiveUrlAsync({ buildId, graphqlClient, signal });
|
||||
headers = robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined;
|
||||
downloadUrl = await fetchApplicationArchiveUrlAsync({
|
||||
buildId,
|
||||
graphqlClient,
|
||||
deviceRunSessionWorkerApi,
|
||||
signal,
|
||||
});
|
||||
// The archive URL from the device run session worker API points to an artifact route that
|
||||
// needs no auth. After www stops minting EXPO_TOKEN for session jobs, robotAccessToken is the
|
||||
// session token, and www rejects it as a bearer credential on every route.
|
||||
headers =
|
||||
robotAccessToken && !deviceRunSessionWorkerApi
|
||||
? { Authorization: `Bearer ${robotAccessToken}` }
|
||||
: undefined;
|
||||
} else {
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
|
||||
@@ -202,18 +305,34 @@ export async function downloadBuildAsync(
|
||||
);
|
||||
|
||||
signal?.throwIfAborted();
|
||||
const response = await retryOnDNSFailure(fetch)(downloadUrl, { headers, signal });
|
||||
|
||||
if (!response.ok) {
|
||||
const textResult = await asyncResult(response.text());
|
||||
throw new Error(`Unexpected response from server (${response.status}): ${textResult.value}`);
|
||||
let archivePath: string;
|
||||
try {
|
||||
archivePath = await retryOnNetworkFailureAsync(
|
||||
async () => {
|
||||
const response = await fetch(downloadUrl, { headers, signal });
|
||||
if (!response.ok) {
|
||||
const textResult = await asyncResult(response.text());
|
||||
throw new Error(
|
||||
`Unexpected response from server (${response.status}): ${textResult.value}`
|
||||
);
|
||||
}
|
||||
const archiveFilename = resolveArchiveFilename({ response, extensions });
|
||||
const attemptArchivePath = path.join(downloadDestinationDirectory, archiveFilename);
|
||||
// On abort, node-fetch emits an error on the body stream, which fails the pipeline.
|
||||
await streamPipeline(response.body, fs.createWriteStream(attemptArchivePath));
|
||||
return attemptArchivePath;
|
||||
},
|
||||
{ operation: 'download_build.archive_download', signal, isRetryable: isTransferFailure }
|
||||
);
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
if (params.buildId && isTransferFailure(error)) {
|
||||
throw new SystemError(`Could not download the application archive: ${describeError(error)}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const archiveFilename = resolveArchiveFilename({ response, extensions });
|
||||
const archivePath = path.join(downloadDestinationDirectory, archiveFilename);
|
||||
|
||||
// On abort, node-fetch emits an error on the body stream, which fails the pipeline.
|
||||
await streamPipeline(response.body, fs.createWriteStream(archivePath));
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const { size } = await fs.promises.stat(archivePath);
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
import { graphql } from 'gql.tada';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { downloadBuildAsync } from './downloadBuild';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
import { promiseRetryWithCondition } from '../../utils/promiseRetryWithCondition';
|
||||
|
||||
const CREATE_ARCHIVE_DOWNLOAD_URL = graphql(`
|
||||
mutation GenerateSubmissionArchiveDownloadUrl($submissionId: ID!) {
|
||||
submission {
|
||||
generateSubmissionArchiveDownloadUrl(submissionId: $submissionId)
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export function createDownloadSubmissionArchiveFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'download_submission_archive',
|
||||
name: 'Download submission archive',
|
||||
__metricsId: 'eas/download_submission_archive',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'submission_id',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'extensions',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
defaultValue: ['apk', 'aab', 'ipa'],
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'artifact_path',
|
||||
required: true,
|
||||
}),
|
||||
],
|
||||
fn: async (stepsCtx, { inputs, outputs, signal }) => {
|
||||
const { submissionId, extensions } = z
|
||||
.object({
|
||||
submissionId: z.string().uuid(),
|
||||
extensions: z.array(z.string()),
|
||||
})
|
||||
.parse({
|
||||
submissionId: inputs.submission_id.value,
|
||||
extensions: inputs.extensions.value,
|
||||
});
|
||||
|
||||
stepsCtx.logger.info(`Downloading archive for submission ${submissionId}...`);
|
||||
|
||||
const applicationArchiveUrl = await promiseRetryWithCondition(
|
||||
async () => {
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(CREATE_ARCHIVE_DOWNLOAD_URL, { submissionId }, graphqlAbortContext(signal))
|
||||
.toPromise();
|
||||
|
||||
signal?.throwIfAborted();
|
||||
|
||||
if (result.error) {
|
||||
throw result.error.networkError || result.error.response?.status >= 500
|
||||
? new SystemError('Could not request the submission archive. Try again later.', {
|
||||
cause: result.error,
|
||||
})
|
||||
: new UserError(
|
||||
'EAS_SUBMISSION_ARCHIVE_FETCH_FAILED',
|
||||
'Could not request the submission archive. Check your project access and the submission archive.',
|
||||
{ cause: result.error }
|
||||
);
|
||||
}
|
||||
|
||||
const url = result.data?.submission.generateSubmissionArchiveDownloadUrl;
|
||||
|
||||
if (!url) {
|
||||
throw new SystemError(
|
||||
'The server did not return a submission archive URL. Try again later.'
|
||||
);
|
||||
}
|
||||
return url;
|
||||
},
|
||||
error => !signal?.aborted && error instanceof SystemError,
|
||||
{ retries: 3, minTimeout: 1000, maxTimeout: 1000 },
|
||||
({ attemptNumber, maxAttemptsCount }) => {
|
||||
stepsCtx.logger.info(
|
||||
`Retrying submission archive URL request (${attemptNumber}/${maxAttemptsCount})...`
|
||||
);
|
||||
}
|
||||
)();
|
||||
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: stepsCtx.logger,
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
applicationArchiveUrl,
|
||||
robotAccessToken: null,
|
||||
extensions,
|
||||
signal,
|
||||
});
|
||||
|
||||
outputs.artifact_path.set(artifactPath);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -199,10 +199,15 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
|
||||
}
|
||||
|
||||
if (backend === 'maestro-runner' && global.runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await installMaestroRunnerWdaCache({
|
||||
logger,
|
||||
env,
|
||||
});
|
||||
// Runner 1.1.28 switched the default iOS driver to the bundled DeviceLab agent.
|
||||
// Use the detected version after installation, including when `latest` was requested.
|
||||
const installedRunnerVersion = semver.coerce(maestroVersionResult.value);
|
||||
if (!installedRunnerVersion || semver.lt(installedRunnerVersion, '1.1.28')) {
|
||||
await installMaestroRunnerWdaCache({
|
||||
logger,
|
||||
env,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`${backend} ${maestroVersionResult.value} is ready.`);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { UserError, quoteAndroidShellCommand } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import {
|
||||
BuildFunction,
|
||||
@@ -106,34 +106,40 @@ export async function launchApplicationAsync({
|
||||
logApplicationLaunch(logger, applicationIdentifier, launchArgs);
|
||||
// Android does not support process arguments like iOS. Pass raw `am start` Intent
|
||||
// arguments instead, such as `--es key value` or `--ez key true`.
|
||||
await spawn(
|
||||
'adb',
|
||||
['shell', 'am', 'start', ...launchArgs, '-n', `${applicationIdentifier}/${activityName}`],
|
||||
{
|
||||
env,
|
||||
logger,
|
||||
try {
|
||||
await spawnAndroidShellCommandAsync(
|
||||
['am', 'start', ...launchArgs, '-n', `${applicationIdentifier}/${activityName}`],
|
||||
{ env, logger }
|
||||
);
|
||||
} catch (err) {
|
||||
// An empty argument that is not an option value reaches `am start` as an empty package, and
|
||||
// `am start` then fails to resolve the Intent without saying why.
|
||||
const emptyArgumentIndex = launchArgs.indexOf('');
|
||||
if (emptyArgumentIndex === -1 || err instanceof UserError) {
|
||||
throw err;
|
||||
}
|
||||
);
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_FAILED',
|
||||
`Launching ${applicationIdentifier} failed. Input "launch_args" has an empty argument at position ${emptyArgumentIndex + 1}, which Android passes to "am start" as an empty argument. Remove it unless it is an option value, such as "--es", "key", "".`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
if (openUrl) {
|
||||
logger.info(`Opening ${openUrl} in ${applicationIdentifier}.`);
|
||||
await spawn(
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'-a',
|
||||
'android.intent.action.VIEW',
|
||||
'-d',
|
||||
openUrl,
|
||||
'-n',
|
||||
`${applicationIdentifier}/${activityName}`,
|
||||
],
|
||||
await spawnAndroidShellCommandAsync(
|
||||
['am', 'start', '-a', 'android.intent.action.VIEW', '-d', openUrl],
|
||||
{ env, logger }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function spawnAndroidShellCommandAsync(
|
||||
command: string[],
|
||||
{ env, logger }: { env: BuildStepEnv; logger: bunyan }
|
||||
): Promise<void> {
|
||||
await spawn('adb', ['shell', ...quoteAndroidShellCommand(command)], { env, logger });
|
||||
}
|
||||
|
||||
async function preapproveIosUrlSchemeAsync({
|
||||
applicationIdentifier,
|
||||
openUrl,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { readAndroidArtifactInfoAsync } from '../utils/android/appArtifact';
|
||||
|
||||
export function createReadAndroidAppInfoBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'read_android_app_info',
|
||||
name: 'Read Android app info',
|
||||
__metricsId: 'eas/read_android_app_info',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'artifact_path',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({ id: 'artifact_type', required: true }),
|
||||
BuildStepOutput.createProvider({ id: 'package_name', required: true }),
|
||||
],
|
||||
fn: async (ctx, { inputs, outputs, signal }) => {
|
||||
const artifactPath = path.resolve(
|
||||
ctx.workingDirectory,
|
||||
z.string().min(1).parse(inputs.artifact_path.value)
|
||||
);
|
||||
ctx.logger.info(`Reading Android artifact: ${artifactPath}.`);
|
||||
const artifact = await readAndroidArtifactInfoAsync(artifactPath, signal);
|
||||
ctx.logger.info(`Android artifact type: ${artifact.artifactType}.`);
|
||||
ctx.logger.info(`Android package name: ${artifact.packageName}.`);
|
||||
outputs.artifact_type.set(artifact.artifactType);
|
||||
outputs.package_name.set(artifact.packageName);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { AGENT_KINDS, runAgentAsync } from '../utils/agentRun';
|
||||
|
||||
export function createRunAgentBuildFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'run_agent',
|
||||
name: 'Run agent',
|
||||
__metricsId: 'eas/run_agent',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.LINUX],
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'agent_kind',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'agent_run_id',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'max_duration_seconds',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'prompt',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs, env, signal }) => {
|
||||
const inputErrors = {
|
||||
agentKind: `The "agent_kind" input must be one of ${AGENT_KINDS.join(', ')}, but received "${String(inputs.agent_kind.value)}".`,
|
||||
agentRunId: `The "agent_run_id" input must be the UUID of an agent run, but received "${String(inputs.agent_run_id.value)}".`,
|
||||
maxDurationSeconds: `The "max_duration_seconds" input must be a positive whole number of seconds, but received "${String(inputs.max_duration_seconds.value)}".`,
|
||||
prompt: 'The "prompt" input must be a non-empty string.',
|
||||
};
|
||||
const parsedInputs = z
|
||||
.object({
|
||||
agent_kind: z.enum(AGENT_KINDS, { error: inputErrors.agentKind }),
|
||||
agent_run_id: z.uuid({ error: inputErrors.agentRunId }),
|
||||
max_duration_seconds: z
|
||||
.number({ error: inputErrors.maxDurationSeconds })
|
||||
.int({ error: inputErrors.maxDurationSeconds })
|
||||
.positive({ error: inputErrors.maxDurationSeconds }),
|
||||
prompt: z.string({ error: inputErrors.prompt }).min(1, { error: inputErrors.prompt }),
|
||||
})
|
||||
.safeParse({
|
||||
agent_kind: inputs.agent_kind.value,
|
||||
agent_run_id: inputs.agent_run_id.value,
|
||||
max_duration_seconds: inputs.max_duration_seconds.value,
|
||||
prompt: inputs.prompt.value,
|
||||
});
|
||||
if (!parsedInputs.success) {
|
||||
throw createValidationError(parsedInputs.error);
|
||||
}
|
||||
const {
|
||||
agent_kind: agentKind,
|
||||
agent_run_id: agentRunId,
|
||||
max_duration_seconds: maxDurationSeconds,
|
||||
prompt,
|
||||
} = parsedInputs.data;
|
||||
const parsedEnv = z
|
||||
.object({
|
||||
EXPO_TOKEN: z
|
||||
.string({ error: 'EXPO_TOKEN is required to run an agent.' })
|
||||
.min(1, { error: 'EXPO_TOKEN is required to run an agent.' }),
|
||||
PATH: z
|
||||
.string({ error: 'PATH is required to run an agent.' })
|
||||
.min(1, { error: 'PATH is required to run an agent.' }),
|
||||
EAS_BUILD_NPM_CACHE_URL: z.string().optional(),
|
||||
})
|
||||
.safeParse(env);
|
||||
if (!parsedEnv.success) {
|
||||
throw createValidationError(parsedEnv.error);
|
||||
}
|
||||
const {
|
||||
EXPO_TOKEN: expoToken,
|
||||
PATH: pathEnv,
|
||||
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
|
||||
} = parsedEnv.data;
|
||||
if (!ctx.expoApiV2BaseUrl) {
|
||||
throw new SystemError('Expo API URL is required to run an agent.');
|
||||
}
|
||||
if (!ctx.mcpServerUrl) {
|
||||
throw new SystemError('MCP server URL is required to run an agent.');
|
||||
}
|
||||
if (!ctx.runtimeApi.addBuildLogMask) {
|
||||
throw new SystemError('A build log secrets filter is required to run an agent.');
|
||||
}
|
||||
// The worker config holds the MCP server's WebSocket URL; its HTTP endpoint is on the same host.
|
||||
const mcpUrl = new URL('/mcp', ctx.mcpServerUrl);
|
||||
mcpUrl.protocol = mcpUrl.protocol.replace(/^ws/, 'http');
|
||||
|
||||
const timeoutError = new UserError(
|
||||
'EAS_RUN_AGENT_TIMEOUT',
|
||||
`The agent did not finish within its limit of ${maxDurationSeconds} seconds, so it was stopped. Raise the agent's maximum duration or give it a smaller task, then start a new agent run.`
|
||||
);
|
||||
const timeoutController = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => timeoutController.abort(timeoutError),
|
||||
maxDurationSeconds * 1000
|
||||
);
|
||||
try {
|
||||
await runAgentAsync({
|
||||
agentKind,
|
||||
agentRunId,
|
||||
prompt,
|
||||
expoToken,
|
||||
expoApiV2BaseUrl: ctx.expoApiV2BaseUrl,
|
||||
mcpUrl: mcpUrl.toString(),
|
||||
pathEnv,
|
||||
npmCacheUrl,
|
||||
logger: stepCtx.logger,
|
||||
addBuildLogMask: ctx.runtimeApi.addBuildLogMask,
|
||||
signal: signal
|
||||
? AbortSignal.any([signal, timeoutController.signal])
|
||||
: timeoutController.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createValidationError(error: z.ZodError): SystemError {
|
||||
return new SystemError(error.issues.map(issue => issue.message).join(' '));
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
|
||||
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { pollAgentDeviceArtifactsForUploadAsync } from '../utils/agentDeviceArtifacts';
|
||||
import { type parseNetworkCaptureInputs } from '../utils/networkCaptureFields';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../utils/agentDeviceEvents';
|
||||
import { type ServeSimApplicationOptions } from '../utils/remoteDeviceRunSession';
|
||||
import { type StartupTasks } from '../utils/startupTasks';
|
||||
import {
|
||||
type DetachedProcessHandle,
|
||||
@@ -76,13 +77,9 @@ export function getAgentDeviceRemoteSessionEnvOrThrow(
|
||||
* Starts the agent-device daemon, its tunnel, the session host and the web preview,
|
||||
* reports the session as ready, and keeps it alive until it stops.
|
||||
*
|
||||
* The daemon does not need the device, so it starts at once. The session host starts
|
||||
* when `device.booted` resolves. The session is reported as ready only when both are
|
||||
* up and `device.ready` (the app is installed and launched) resolved too.
|
||||
*
|
||||
* The first failure aborts `tasks.signal`: each part stops before its next stage, and
|
||||
* the teardown then stops whatever was started. `device.ready` must settle soon after
|
||||
* an abort, because the teardown waits for it.
|
||||
* Serve-sim boots while the iOS download runs. After device preparation verifies
|
||||
* any egress guard, its control API installs and launches the app.
|
||||
* Startup failures abort the other tasks; teardown drains them before stopping acquired resources.
|
||||
*/
|
||||
export async function runAgentDeviceRemoteSessionAsync(
|
||||
ctx: CustomBuildContext,
|
||||
@@ -96,6 +93,10 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
maxIdleTimeMinutes,
|
||||
maxDurationSeconds,
|
||||
capture,
|
||||
disableShare = false,
|
||||
privatePreviewUrl,
|
||||
frameAncestors,
|
||||
corsOrigins,
|
||||
tasks,
|
||||
device,
|
||||
}: {
|
||||
@@ -108,10 +109,26 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
maxIdleTimeMinutes: number | undefined;
|
||||
maxDurationSeconds: number | undefined;
|
||||
capture: ReturnType<typeof parseNetworkCaptureInputs>;
|
||||
disableShare?: boolean;
|
||||
privatePreviewUrl?: string;
|
||||
frameAncestors?: string[];
|
||||
corsOrigins?: string[];
|
||||
tasks: StartupTasks;
|
||||
device: { booted: Promise<unknown>; ready: Promise<unknown> };
|
||||
device:
|
||||
| {
|
||||
booted: Promise<unknown>;
|
||||
ready: Promise<unknown>;
|
||||
}
|
||||
| {
|
||||
iosSimulatorUdid: string;
|
||||
bootEnv?: Record<string, string>;
|
||||
application?: Promise<ServeSimApplicationOptions | void>;
|
||||
};
|
||||
}
|
||||
): Promise<void> {
|
||||
signal?.throwIfAborted();
|
||||
tasks.signal.throwIfAborted();
|
||||
|
||||
logger.info(
|
||||
`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
@@ -121,6 +138,9 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
let sessionHost: DeviceSessionHost | undefined;
|
||||
let eventCollection: Awaited<ReturnType<typeof startAgentDeviceEventCollectionAsync>> | undefined;
|
||||
let sessionFailed = false;
|
||||
const daemonSecrets = [ngrokAuthtoken];
|
||||
const hostBootedDevice = 'iosSimulatorUdid' in device ? device : undefined;
|
||||
const externallyBootedDevice = 'booted' in device ? device : undefined;
|
||||
|
||||
// Each task stores what it started, so the teardown below can stop it even when
|
||||
// another task failed first.
|
||||
@@ -131,12 +151,14 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
env,
|
||||
logger: taskLogger,
|
||||
signal: tasks.signal,
|
||||
secrets: daemonSecrets,
|
||||
});
|
||||
|
||||
taskLogger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
|
||||
// The daemon is stored above, so the teardown stops it. The wait itself is bounded
|
||||
// (STARTUP_TIMEOUT_MS) and only reads a file, so it may finish in the background.
|
||||
const daemonInfo = await tasks.untilAborted(waitForDaemonInfoAsync({ daemonProcess }));
|
||||
daemonSecrets.push(daemonInfo.token);
|
||||
taskLogger.info(`Daemon is listening on port ${daemonInfo.port}; loaded auth token.`);
|
||||
|
||||
tasks.signal.throwIfAborted();
|
||||
@@ -147,40 +169,60 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
authtoken: ngrokAuthtoken,
|
||||
logger: taskLogger,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
taskLogger.info(`Tunnel is ready at ${agentDeviceTunnel.url}.`);
|
||||
return { ...daemonInfo, remoteSessionUrl: agentDeviceTunnel.url };
|
||||
});
|
||||
const sessionHostStartup = tasks.run('session host', async taskLogger => {
|
||||
// A boot cannot be cancelled, so stop waiting for it when startup is aborted.
|
||||
await tasks.untilAborted(device.booted);
|
||||
if (externallyBootedDevice) {
|
||||
await tasks.untilAborted(externallyBootedDevice.booted);
|
||||
}
|
||||
tasks.signal.throwIfAborted();
|
||||
sessionHost = await startDeviceSessionHostAsync(ctx, {
|
||||
separateLogPhase: true,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
disableShare,
|
||||
privatePreviewUrl,
|
||||
frameAncestors,
|
||||
corsOrigins,
|
||||
signal: tasks.signal,
|
||||
onStartupError: tasks.abort,
|
||||
networkCapture: capture.networkCapture,
|
||||
networkCaptureFields: capture.networkCaptureFields,
|
||||
iosSimulatorUdid: hostBootedDevice?.iosSimulatorUdid,
|
||||
bootEnv: hostBootedDevice?.bootEnv,
|
||||
application: hostBootedDevice?.application,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
const webPreview = await sessionHost.openPreviewAsync({
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
|
||||
taskLogger.info(
|
||||
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
|
||||
webPreview.sharePageUrl
|
||||
? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
|
||||
: 'Private preview URL unavailable for this session.'
|
||||
);
|
||||
return webPreview;
|
||||
});
|
||||
const deviceReady = externallyBootedDevice?.ready;
|
||||
|
||||
try {
|
||||
const [
|
||||
{ port: daemonPort, token: daemonToken, remoteSessionUrl: agentDeviceRemoteSessionUrl },
|
||||
webPreview,
|
||||
] = await Promise.all([agentDeviceStartup, sessionHostStartup, device.ready]).catch(
|
||||
] = await Promise.all([agentDeviceStartup, sessionHostStartup, deviceReady]).catch(
|
||||
(err: unknown) => {
|
||||
// Also aborts for a `device.ready` that does not come from `tasks.run`.
|
||||
tasks.abort(err);
|
||||
throw err;
|
||||
throw tasks.signal.reason;
|
||||
}
|
||||
);
|
||||
tasks.signal.throwIfAborted();
|
||||
logger.info(tasks.summary());
|
||||
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
@@ -197,6 +239,7 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
},
|
||||
logger,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
daemonUrl: `http://127.0.0.1:${daemonPort}`,
|
||||
@@ -222,6 +265,7 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
||||
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
@@ -232,7 +276,7 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
// Promise.all rejects on the first failure while other tasks can still be starting.
|
||||
// They stop at their next abort check. Wait for all of them, so the teardown sees
|
||||
// everything that was started.
|
||||
await Promise.allSettled([agentDeviceStartup, sessionHostStartup, device.ready]);
|
||||
await Promise.allSettled([agentDeviceStartup, sessionHostStartup, deviceReady]);
|
||||
const startedDaemon = daemonProcess;
|
||||
await finishRemoteSessionAsync({
|
||||
logger,
|
||||
@@ -267,13 +311,16 @@ export async function startAgentDeviceDaemonAsync({
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
secrets,
|
||||
}: {
|
||||
packageVersion: string | undefined;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
/** Kills the install and stops before the daemon starts, when aborted. No git fallback then. */
|
||||
signal?: AbortSignal;
|
||||
secrets?: string[];
|
||||
}): Promise<DetachedProcessHandle> {
|
||||
signal?.throwIfAborted();
|
||||
const packageSpec = createAgentDevicePackageSpec(packageVersion);
|
||||
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
|
||||
const installDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-agent-device-'));
|
||||
@@ -283,6 +330,7 @@ export async function startAgentDeviceDaemonAsync({
|
||||
);
|
||||
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const add = resolvePackageAdd(packageManager, packageSpec);
|
||||
logger.info(`Installing ${packageSpec} with ${add.command}.`);
|
||||
await spawn(add.command, add.args, { cwd: installDir, env, logger, signal });
|
||||
@@ -298,12 +346,17 @@ export async function startAgentDeviceDaemonAsync({
|
||||
command: 'node',
|
||||
args: [daemonPath],
|
||||
env: { ...env, ...AGENT_DEVICE_DAEMON_ENV },
|
||||
logger,
|
||||
secrets,
|
||||
});
|
||||
return {
|
||||
...daemonProcess,
|
||||
stopAsync: async () => {
|
||||
await daemonProcess.stopAsync();
|
||||
await fs.promises.rm(installDir, { recursive: true, force: true });
|
||||
try {
|
||||
await daemonProcess.stopAsync();
|
||||
} finally {
|
||||
await fs.promises.rm(installDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
@@ -334,7 +387,13 @@ export async function startAgentDeviceDaemonAsync({
|
||||
logger.warn(
|
||||
`Failed to start daemon from ${packageSpec} via ${packageManager}; falling back to git clone: ${error.message}`
|
||||
);
|
||||
return await startAgentDeviceDaemonFromGitAsync({ packageVersion, env, logger, signal });
|
||||
return await startAgentDeviceDaemonFromGitAsync({
|
||||
packageVersion,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
secrets,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,11 +424,13 @@ async function startAgentDeviceDaemonFromGitAsync({
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
secrets,
|
||||
}: {
|
||||
packageVersion: string | undefined;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
secrets?: string[];
|
||||
}): Promise<DetachedProcessHandle> {
|
||||
logger.info(
|
||||
packageVersion
|
||||
@@ -396,6 +457,8 @@ async function startAgentDeviceDaemonFromGitAsync({
|
||||
args: ['run', 'src/daemon.ts'],
|
||||
cwd: SRC_DIR,
|
||||
env: { ...env, ...AGENT_DEVICE_DAEMON_ENV },
|
||||
logger,
|
||||
secrets,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { UserError, validateAndroidLaunchOptions } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
@@ -15,14 +15,27 @@ import {
|
||||
AndroidEmulatorUtils,
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorName, IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
import { getDeviceRunSessionWorkerApi } from '../utils/deviceRunSessionWorkerApi';
|
||||
import { withLocalEgressSession } from '../utils/localEgressSession';
|
||||
import { selectXcodeDeveloperDirectoryAsync } from '../utils/remoteDeviceRunSession';
|
||||
import {
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createPrivatePreviewUrlInputProvider,
|
||||
createPublicPreviewInputProvider,
|
||||
omitNgrokAuthtoken,
|
||||
parseCorsOriginsInput,
|
||||
parseFrameAncestorsInput,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
import {
|
||||
createNetworkCaptureInputProviders,
|
||||
parseNetworkCaptureInputs,
|
||||
} from '../utils/networkCaptureFields';
|
||||
import { createStartupTasks } from '../utils/startupTasks';
|
||||
import {
|
||||
type ServeSimApplicationOptions,
|
||||
validateServeSimLaunchOptions,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
import { downloadBuildAsync } from './downloadBuild';
|
||||
import { installBuildAsync } from './installBuild';
|
||||
@@ -36,26 +49,15 @@ import {
|
||||
runAgentDeviceRemoteSessionAsync,
|
||||
} from './startAgentDeviceRemoteSession';
|
||||
import { startAndroidEmulatorAsync } from './startAndroidEmulator';
|
||||
import { bootIosSimulatorAsync } from './startIosSimulator';
|
||||
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
|
||||
import { readIosApplicationIdentifierAsync } from '../utils/iosAppArtifact';
|
||||
|
||||
const ANDROID_DEVICE_NAME = 'EasAndroidDevice01' as AndroidVirtualDeviceName;
|
||||
|
||||
/**
|
||||
* One step for a whole agent-device session: boot the device, download, install and
|
||||
* launch the app, and start the agent-device daemon and the web preview.
|
||||
*
|
||||
* It does the work of eas/start_ios_simulator or eas/start_android_emulator,
|
||||
* eas/download_build, eas/install_build and eas/launch_application, plus the agent-device
|
||||
* session, so the parts that do not depend on each other can run at the same time:
|
||||
*
|
||||
* boot ─────────────┬─► install ─► launch ─┐
|
||||
* download ─────────┘ │
|
||||
* boot ─► session host ─► web preview ─────┼─► ready
|
||||
* agent-device daemon ─► tunnel ───────────┘
|
||||
*
|
||||
* The first failure aborts the rest: the download stops, and nothing installs, launches
|
||||
* or starts after it. A boot cannot be cancelled, so it can still run when a failed
|
||||
* step returns.
|
||||
* Starts a device session, downloading the app alongside daemon startup.
|
||||
* On iOS, serve-sim boots the selected Simulator, then installs and launches
|
||||
* the downloaded app. Build-tools verifies any egress guard before install and launch.
|
||||
*/
|
||||
export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
return new BuildFunction({
|
||||
@@ -114,6 +116,10 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
|
||||
}),
|
||||
// Session.
|
||||
...createNetworkCaptureInputProviders(),
|
||||
createPublicPreviewInputProvider(),
|
||||
createPrivatePreviewUrlInputProvider(),
|
||||
createFrameAncestorsInputProvider(),
|
||||
createCorsOriginsInputProvider(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -130,13 +136,14 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: withLocalEgressSession(async (stepsCtx, { inputs, env, signal }) => {
|
||||
fn: withLocalEgressSession(async (stepsCtx, { inputs, env: stepEnv, signal }) => {
|
||||
const { logger, global } = stepsCtx;
|
||||
const { runtimePlatform } = global;
|
||||
const isIos = runtimePlatform === BuildRuntimePlatform.DARWIN;
|
||||
|
||||
// Validate everything before any expensive work starts.
|
||||
const sessionEnv = getAgentDeviceRemoteSessionEnvOrThrow(env);
|
||||
const sessionEnv = getAgentDeviceRemoteSessionEnvOrThrow(stepEnv);
|
||||
const env = omitNgrokAuthtoken(stepEnv);
|
||||
const buildId = inputs.build_id.value as string | undefined;
|
||||
const applicationArchiveUrl = inputs.application_archive_url.value as string | undefined;
|
||||
if (buildId && applicationArchiveUrl) {
|
||||
@@ -155,6 +162,11 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
|
||||
'launch_args and open_url need an application: pass build_id or application_archive_url.'
|
||||
);
|
||||
}
|
||||
if (isIos) {
|
||||
validateServeSimLaunchOptions({ launchArgs, openUrl });
|
||||
} else {
|
||||
validateAndroidLaunchOptions({ launchArgs, openUrl });
|
||||
}
|
||||
const deviceIdentifier = inputs.device_identifier.value as string | undefined;
|
||||
const capture = parseNetworkCaptureInputs(
|
||||
{
|
||||
@@ -164,104 +176,137 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
|
||||
{ runtimePlatform }
|
||||
);
|
||||
|
||||
if (isIos) {
|
||||
// Before the boot, so every Xcode tool below uses the same developer directory.
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
const tasks = createStartupTasks(logger, signal);
|
||||
let downloaded: Promise<string> | undefined;
|
||||
let applicationReady: Promise<ServeSimApplicationOptions | void> | undefined;
|
||||
|
||||
const tasks = createStartupTasks(logger);
|
||||
|
||||
const booted = tasks.run(
|
||||
isIos ? 'iOS Simulator boot' : 'Android Emulator boot',
|
||||
async taskLogger => {
|
||||
if (isIos) {
|
||||
await bootIosSimulatorAsync({
|
||||
deviceIdentifier: deviceIdentifier as IosSimulatorUuid | IosSimulatorName | undefined,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await startAndroidEmulatorAsync({
|
||||
deviceName: ANDROID_DEVICE_NAME,
|
||||
systemImagePackage: `${inputs.system_image_package.value}`,
|
||||
deviceIdentifier: deviceIdentifier as AndroidDeviceName | undefined,
|
||||
lcdWidth: inputs.lcd_width.value as number | undefined,
|
||||
lcdHeight: inputs.lcd_height.value as number | undefined,
|
||||
lcdDensity: inputs.lcd_density.value as number | undefined,
|
||||
logcatDirectory: await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
|
||||
),
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
try {
|
||||
if (isIos) {
|
||||
// Select Xcode before serve-sim boots the device or any Simulator tools run.
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
);
|
||||
|
||||
const downloaded = hasApplication
|
||||
? tasks.run('build download', async taskLogger => {
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: taskLogger,
|
||||
...(buildId ? { buildId } : { applicationArchiveUrl: applicationArchiveUrl! }),
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
robotAccessToken: global.staticContext.job.secrets?.robotAccessToken ?? null,
|
||||
extensions: isIos ? ['app'] : ['apk'],
|
||||
tasks.signal.throwIfAborted();
|
||||
const { iosStartup } = isIos
|
||||
? await resolveIosSessionStartupAsync({
|
||||
runtimePlatform,
|
||||
bootSimulator: true,
|
||||
deviceIdentifier,
|
||||
env,
|
||||
logger,
|
||||
signal: tasks.signal,
|
||||
});
|
||||
return artifactPath;
|
||||
})
|
||||
: undefined;
|
||||
})
|
||||
: {};
|
||||
const iosSimulatorUdid = iosStartup?.iosSimulatorUdid;
|
||||
if (iosSimulatorUdid) {
|
||||
logger.info(`Selected iOS Simulator: ${iosSimulatorUdid}.`);
|
||||
}
|
||||
|
||||
// `ready` settles only after everything it started has settled, so the session
|
||||
// teardown, which waits for it, never leaves a download running. It must also settle
|
||||
// soon after an abort: it stops waiting for the boot then, and the download stops.
|
||||
const ready = downloaded
|
||||
? tasks.run('app install and launch', async taskLogger => {
|
||||
const [download, boot] = await Promise.allSettled([
|
||||
downloaded,
|
||||
tasks.untilAborted(booted),
|
||||
]);
|
||||
// After a failure elsewhere, report that failure, not the abort it caused here.
|
||||
tasks.signal.throwIfAborted();
|
||||
if (download.status === 'rejected') {
|
||||
throw download.reason;
|
||||
}
|
||||
if (boot.status === 'rejected') {
|
||||
throw boot.reason;
|
||||
}
|
||||
const { applicationIdentifier, activityName } = await installBuildAsync({
|
||||
artifactPath: download.value,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
const booted = iosSimulatorUdid
|
||||
? undefined
|
||||
: tasks.run('Android Emulator boot', async taskLogger => {
|
||||
const logcatDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
|
||||
);
|
||||
tasks.signal.throwIfAborted();
|
||||
await startAndroidEmulatorAsync({
|
||||
deviceName: ANDROID_DEVICE_NAME,
|
||||
systemImagePackage: `${inputs.system_image_package.value}`,
|
||||
deviceIdentifier: deviceIdentifier as AndroidDeviceName | undefined,
|
||||
lcdWidth: inputs.lcd_width.value as number | undefined,
|
||||
lcdHeight: inputs.lcd_height.value as number | undefined,
|
||||
lcdDensity: inputs.lcd_density.value as number | undefined,
|
||||
logcatDirectory,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier,
|
||||
activityName,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
})
|
||||
: tasks.untilAborted(booted);
|
||||
|
||||
await runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
runtimePlatform,
|
||||
sessionEnv,
|
||||
packageVersion: inputs.package_version.value as string | undefined,
|
||||
// A missing or non-positive value disables the idle timeout (opt-in feature).
|
||||
maxIdleTimeMinutes: inputs.max_idle_time_minutes.value as number | undefined,
|
||||
maxDurationSeconds: inputs.max_duration_seconds.value as number | undefined,
|
||||
capture,
|
||||
tasks,
|
||||
device: { booted, ready },
|
||||
});
|
||||
downloaded = hasApplication
|
||||
? tasks.run('build download', async taskLogger => {
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: taskLogger,
|
||||
...(buildId ? { buildId } : { applicationArchiveUrl: applicationArchiveUrl! }),
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
deviceRunSessionWorkerApi: buildId ? getDeviceRunSessionWorkerApi(ctx) : null,
|
||||
robotAccessToken: global.staticContext.job.secrets?.robotAccessToken ?? null,
|
||||
extensions: isIos ? ['app'] : ['apk'],
|
||||
signal: tasks.signal,
|
||||
});
|
||||
return artifactPath;
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const download = downloaded;
|
||||
applicationReady = download
|
||||
? tasks.run(isIos ? 'app preparation' : 'app install and launch', async taskLogger => {
|
||||
if (booted) {
|
||||
await tasks.untilAborted(booted);
|
||||
}
|
||||
const artifactPath = await download;
|
||||
tasks.signal.throwIfAborted();
|
||||
if (isIos) {
|
||||
const applicationIdentifier = await readIosApplicationIdentifierAsync({
|
||||
artifactPath,
|
||||
env,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
return {
|
||||
installAppPath: artifactPath,
|
||||
launchAppIdentifier: applicationIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
};
|
||||
}
|
||||
const { applicationIdentifier, activityName } = await installBuildAsync({
|
||||
artifactPath,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier,
|
||||
activityName,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
})
|
||||
: undefined;
|
||||
|
||||
await runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
runtimePlatform,
|
||||
sessionEnv,
|
||||
packageVersion: inputs.package_version.value as string | undefined,
|
||||
// A missing or non-positive value disables the idle timeout (opt-in feature).
|
||||
maxIdleTimeMinutes: inputs.max_idle_time_minutes.value as number | undefined,
|
||||
maxDurationSeconds: inputs.max_duration_seconds.value as number | undefined,
|
||||
capture,
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
|
||||
tasks,
|
||||
device: iosSimulatorUdid
|
||||
? {
|
||||
...iosStartup,
|
||||
iosSimulatorUdid,
|
||||
application: applicationReady,
|
||||
}
|
||||
: {
|
||||
booted: booted!,
|
||||
ready: applicationReady ?? tasks.untilAborted(booted!),
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
tasks.abort(new Error('Agent-device session ended.'));
|
||||
await Promise.allSettled([downloaded, applicationReady]);
|
||||
}
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type Result, asyncResult } from '@expo/results';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
@@ -7,10 +8,11 @@ import {
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import spawn, { type SpawnOptions, type SpawnResult } from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -20,28 +22,39 @@ import {
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
|
||||
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
|
||||
import {
|
||||
createNetworkCaptureInputProviders,
|
||||
parseNetworkCaptureInputs,
|
||||
} from '../utils/networkCaptureFields';
|
||||
import { AndroidEmulatorUtils } from '../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
|
||||
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
import {
|
||||
PackageManager,
|
||||
resolveConfiguredPackageManager,
|
||||
resolvePackageAdd,
|
||||
} from '../../utils/packageManager';
|
||||
import { killProcessGroup } from '../../utils/processes';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
|
||||
import { preparePreinstalledWdaAsync } from '../utils/preinstalledWda';
|
||||
import {
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createIosSessionStartupInputProviders,
|
||||
createPrivatePreviewUrlInputProvider,
|
||||
createPublicPreviewInputProvider,
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
finishRemoteSessionAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
parseServeSimLaunchInputs,
|
||||
omitNgrokAuthtoken,
|
||||
parseCorsOriginsInput,
|
||||
parseFrameAncestorsInput,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
@@ -68,7 +81,12 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
__metricsId: 'eas/start_appium_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
...createIosSessionStartupInputProviders(),
|
||||
...createNetworkCaptureInputProviders(),
|
||||
createPublicPreviewInputProvider(),
|
||||
createPrivatePreviewUrlInputProvider(),
|
||||
createFrameAncestorsInputProvider(),
|
||||
createCorsOriginsInputProvider(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -80,21 +98,29 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
|
||||
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env: stepEnv, signal }) => {
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(stepEnv);
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(stepEnv);
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(stepEnv);
|
||||
const env = omitNgrokAuthtoken(stepEnv);
|
||||
const packageVersion = inputs.package_version.value as string | undefined;
|
||||
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
|
||||
const { runtimePlatform } = global;
|
||||
const launch = parseServeSimLaunchInputs(
|
||||
{
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value,
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
|
||||
runtimePlatform,
|
||||
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
|
||||
deviceIdentifier: inputs.device_identifier?.value as string | undefined,
|
||||
installAppPath: inputs.install_app_path?.value as string | undefined,
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
});
|
||||
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
|
||||
{
|
||||
networkCapture: inputs.network_capture?.value,
|
||||
@@ -107,53 +133,131 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
logger.info(
|
||||
`Starting Appium remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
const device = await resolveAppiumDeviceAsync({ runtimePlatform, env, logger });
|
||||
const { appiumHome, appiumBinPath, appiumEnv } = await installAppiumAsync({
|
||||
versionSpec,
|
||||
driverName: device.driverName,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
const appiumProcess = spawnDetached({
|
||||
command: appiumBinPath,
|
||||
args: [
|
||||
'--address',
|
||||
APPIUM_HOST,
|
||||
'--port',
|
||||
String(APPIUM_PORT),
|
||||
'--base-path',
|
||||
'/',
|
||||
'--log-level',
|
||||
'error',
|
||||
// Appium 3 gates session listing (GET /appium/sessions) behind the
|
||||
// session_discovery insecure feature. We rely on it to poll for
|
||||
// Appium Event Timings, so enable it for all drivers.
|
||||
'--allow-insecure',
|
||||
'*:session_discovery',
|
||||
'--default-capabilities',
|
||||
JSON.stringify({ 'appium:eventTimings': true }),
|
||||
],
|
||||
env: appiumEnv,
|
||||
});
|
||||
try {
|
||||
await waitForAppiumReadyAsync({ appiumProcess, logger });
|
||||
} catch (error) {
|
||||
await appiumProcess.stopAsync();
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
|
||||
const eventCollection = await startAppiumEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
appiumUrl: `http://${APPIUM_HOST}:${APPIUM_PORT}/`,
|
||||
logger,
|
||||
});
|
||||
let appiumTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
const device: AppiumDevice = iosStartup
|
||||
? {
|
||||
platformName: 'iOS',
|
||||
automationName: 'XCUITest',
|
||||
driverName: 'xcuitest',
|
||||
udid: iosStartup.iosSimulatorUdid,
|
||||
}
|
||||
: await resolveAppiumDeviceAsync({ runtimePlatform, env });
|
||||
const startupAbortController = new AbortController();
|
||||
const startupSignal = signal
|
||||
? AbortSignal.any([signal, startupAbortController.signal])
|
||||
: startupAbortController.signal;
|
||||
const setupSignal = iosStartup ? startupSignal : undefined;
|
||||
let hostStartup: Promise<Result<DeviceSessionHost>> | undefined;
|
||||
let sessionHost: DeviceSessionHost | undefined;
|
||||
let appiumProcess: ReturnType<typeof spawnDetached> | undefined;
|
||||
let appiumHome: string | undefined;
|
||||
let eventCollection: Awaited<ReturnType<typeof startAppiumEventCollectionAsync>> | undefined;
|
||||
let appiumTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let sessionFailed = false;
|
||||
const startHost = () =>
|
||||
startDeviceSessionHostAsync(ctx, {
|
||||
...iosStartup,
|
||||
separateLogPhase: true,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
|
||||
signal: startupSignal,
|
||||
onStartupError: iosStartup ? error => startupAbortController.abort(error) : undefined,
|
||||
...launch,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
}).catch(error => {
|
||||
if (iosStartup) {
|
||||
startupAbortController.abort(error);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
if (iosStartup) {
|
||||
hostStartup = asyncResult(startHost());
|
||||
}
|
||||
// Appium's startRecordingScreen runs ffmpeg on this host: XCUITest encodes the
|
||||
// simulator stream with it, and UiAutomator2 merges long recordings with it. The
|
||||
// macOS session image does not ship ffmpeg, so install it concurrently with Appium.
|
||||
// Both owned installs settle before proceeding; a shared FFmpeg install keeps its owner.
|
||||
const [appiumInstall] = await Promise.all([
|
||||
asyncResult(
|
||||
installAppiumAsync({
|
||||
versionSpec,
|
||||
driverName: device.driverName,
|
||||
env,
|
||||
logger,
|
||||
signal: setupSignal,
|
||||
onError: iosStartup ? error => startupAbortController.abort(error) : undefined,
|
||||
}).catch(error => {
|
||||
if (iosStartup) {
|
||||
startupAbortController.abort(error);
|
||||
}
|
||||
throw error;
|
||||
})
|
||||
),
|
||||
asyncResult(
|
||||
ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger, signal: setupSignal })
|
||||
),
|
||||
]);
|
||||
const installation = appiumInstall.enforceValue();
|
||||
appiumHome = installation.appiumHome;
|
||||
setupSignal?.throwIfAborted();
|
||||
const { appiumBinPath, appiumEnv } = installation;
|
||||
const wdaCapabilities = await preparePreinstalledWdaAsync({
|
||||
runtimeIdentifier: iosStartup
|
||||
? (
|
||||
await IosSimulatorUtils.getDeviceAsync({
|
||||
udid: iosStartup.iosSimulatorUdid as IosSimulatorUuid,
|
||||
env,
|
||||
})
|
||||
)?.runtime
|
||||
: device.runtimeIdentifier,
|
||||
appiumHome,
|
||||
appiumBinPath,
|
||||
appiumEnv,
|
||||
logger,
|
||||
signal: setupSignal,
|
||||
});
|
||||
|
||||
setupSignal?.throwIfAborted();
|
||||
appiumProcess = spawnDetached({
|
||||
command: appiumBinPath,
|
||||
args: [
|
||||
'--address',
|
||||
APPIUM_HOST,
|
||||
'--port',
|
||||
String(APPIUM_PORT),
|
||||
'--base-path',
|
||||
'/',
|
||||
'--log-level',
|
||||
'error',
|
||||
// Appium 3 gates session listing (GET /appium/sessions) behind the
|
||||
// session_discovery insecure feature. We rely on it to poll for
|
||||
// Appium Event Timings, so enable it for all drivers.
|
||||
'--allow-insecure',
|
||||
'*:session_discovery',
|
||||
'--default-capabilities',
|
||||
JSON.stringify({ 'appium:eventTimings': true, ...wdaCapabilities }),
|
||||
],
|
||||
env: appiumEnv,
|
||||
logger,
|
||||
});
|
||||
await waitForAppiumReadyAsync({ appiumProcess, logger, signal: setupSignal });
|
||||
if (hostStartup) {
|
||||
sessionHost = (await hostStartup).enforceValue();
|
||||
}
|
||||
setupSignal?.throwIfAborted();
|
||||
eventCollection = await startAppiumEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
appiumUrl: `http://${APPIUM_HOST}:${APPIUM_PORT}/`,
|
||||
logger,
|
||||
});
|
||||
appiumTunnel = await startNgrokTunnelAsync({
|
||||
port: APPIUM_PORT,
|
||||
subdomainPrefix: 'appium',
|
||||
@@ -168,18 +272,11 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
sessionHost = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
|
||||
const webPreview = await sessionHost.openPreviewAsync({
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
});
|
||||
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
|
||||
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
@@ -210,13 +307,15 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
||||
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
sessionFailed = true;
|
||||
throw error;
|
||||
throw setupSignal?.aborted ? setupSignal.reason : error;
|
||||
} finally {
|
||||
startupAbortController.abort(new Error('Appium session ended.'));
|
||||
await finishRemoteSessionAsync({
|
||||
logger,
|
||||
sessionFailed,
|
||||
@@ -226,14 +325,19 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
'Appium server',
|
||||
(async () => {
|
||||
try {
|
||||
await eventCollection.stopAsync();
|
||||
await eventCollection?.stopAsync();
|
||||
} finally {
|
||||
await appiumProcess.stopAsync();
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
try {
|
||||
await appiumProcess?.stopAsync();
|
||||
} finally {
|
||||
if (appiumHome) {
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
})(),
|
||||
],
|
||||
['session host', sessionHost?.finishAsync()],
|
||||
['session host', hostStartup?.then(result => result.value?.finishAsync())],
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -257,20 +361,18 @@ export type AppiumDevice = {
|
||||
automationName: 'XCUITest' | 'UiAutomator2';
|
||||
driverName: 'xcuitest' | 'uiautomator2';
|
||||
udid: string;
|
||||
runtimeIdentifier?: string;
|
||||
};
|
||||
|
||||
export async function resolveAppiumDeviceAsync({
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<AppiumDevice> {
|
||||
switch (runtimePlatform) {
|
||||
case BuildRuntimePlatform.DARWIN: {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
const [bootedDevice] = await IosSimulatorUtils.getAvailableDevicesAsync({
|
||||
env,
|
||||
filter: 'booted',
|
||||
@@ -283,6 +385,7 @@ export async function resolveAppiumDeviceAsync({
|
||||
automationName: 'XCUITest',
|
||||
driverName: 'xcuitest',
|
||||
udid: bootedDevice.udid,
|
||||
runtimeIdentifier: bootedDevice.runtime,
|
||||
};
|
||||
}
|
||||
case BuildRuntimePlatform.LINUX: {
|
||||
@@ -306,65 +409,135 @@ export async function installAppiumAsync({
|
||||
driverName,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
onError,
|
||||
}: {
|
||||
versionSpec: string;
|
||||
driverName: AppiumDevice['driverName'];
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
onError?: (error: unknown) => void;
|
||||
}): Promise<{ appiumHome: string; appiumBinPath: string; appiumEnv: BuildStepEnv }> {
|
||||
signal?.throwIfAborted();
|
||||
const appiumHome = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-appium-home-'));
|
||||
await fs.promises.writeFile(
|
||||
path.join(appiumHome, 'package.json'),
|
||||
`${JSON.stringify({ name: 'eas-appium-home', private: true })}\n`
|
||||
);
|
||||
const appiumEnv: BuildStepEnv = { ...env, APPIUM_HOME: appiumHome };
|
||||
const appiumBinPath = path.join(appiumHome, 'node_modules', '.bin', 'appium');
|
||||
const add = resolvePackageAdd(
|
||||
resolveConfiguredPackageManager(env, PackageManager.NPM),
|
||||
`appium@${versionSpec}`
|
||||
);
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
await fs.promises.writeFile(
|
||||
path.join(appiumHome, 'package.json'),
|
||||
`${JSON.stringify({ name: 'eas-appium-home', private: true })}\n`
|
||||
);
|
||||
const appiumEnv: BuildStepEnv = { ...env, APPIUM_HOME: appiumHome };
|
||||
const appiumBinPath = path.join(appiumHome, 'node_modules', '.bin', 'appium');
|
||||
const add = resolvePackageAdd(
|
||||
resolveConfiguredPackageManager(env, PackageManager.NPM),
|
||||
`appium@${versionSpec}`
|
||||
);
|
||||
|
||||
logger.info(`Installing appium@${versionSpec} with ${add.command}.`);
|
||||
await spawn(add.command, add.args, {
|
||||
cwd: appiumHome,
|
||||
env: appiumEnv,
|
||||
logger,
|
||||
});
|
||||
const { stdout } = await spawn(appiumBinPath, ['driver', 'list', '--installed', '--json'], {
|
||||
env: appiumEnv,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const installedDrivers = AppiumInstalledDriversSchema.parse(JSON.parse(stdout));
|
||||
if (installedDrivers[driverName]?.installed) {
|
||||
logger.info(`Updating the installed Appium ${driverName} driver.`);
|
||||
await spawn(appiumBinPath, ['driver', 'update', driverName], { env: appiumEnv, logger });
|
||||
} else {
|
||||
logger.info(`Installing the Appium ${driverName} driver.`);
|
||||
await spawn(appiumBinPath, ['driver', 'install', driverName], { env: appiumEnv, logger });
|
||||
logger.info(`Installing appium@${versionSpec} with ${add.command}.`);
|
||||
await runAppiumSetupCommandAsync(
|
||||
add.command,
|
||||
add.args,
|
||||
{
|
||||
cwd: appiumHome,
|
||||
env: appiumEnv,
|
||||
logger,
|
||||
},
|
||||
signal
|
||||
);
|
||||
const { stdout } = await runAppiumSetupCommandAsync(
|
||||
appiumBinPath,
|
||||
['driver', 'list', '--installed', '--json'],
|
||||
{
|
||||
env: appiumEnv,
|
||||
stdio: 'pipe',
|
||||
},
|
||||
signal
|
||||
);
|
||||
const installedDrivers = AppiumInstalledDriversSchema.parse(JSON.parse(stdout));
|
||||
if (installedDrivers[driverName]?.installed) {
|
||||
logger.info(`Updating the installed Appium ${driverName} driver.`);
|
||||
await runAppiumSetupCommandAsync(
|
||||
appiumBinPath,
|
||||
['driver', 'update', driverName],
|
||||
{ env: appiumEnv, logger },
|
||||
signal
|
||||
);
|
||||
} else {
|
||||
logger.info(`Installing the Appium ${driverName} driver.`);
|
||||
await runAppiumSetupCommandAsync(
|
||||
appiumBinPath,
|
||||
['driver', 'install', driverName],
|
||||
{ env: appiumEnv, logger },
|
||||
signal
|
||||
);
|
||||
}
|
||||
return { appiumHome, appiumBinPath, appiumEnv };
|
||||
} catch (error) {
|
||||
onError?.(error);
|
||||
if (signal) {
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
signal.throwIfAborted();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function runAppiumSetupCommandAsync(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: SpawnOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<SpawnResult> {
|
||||
if (!signal) {
|
||||
return await spawn(command, args, options);
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
const task = spawn(command, args, { ...options, detached: true });
|
||||
const abort = (): void => killProcessGroup(task.child, 'SIGKILL');
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
if (signal.aborted) {
|
||||
abort();
|
||||
}
|
||||
try {
|
||||
const result = await task;
|
||||
signal.throwIfAborted();
|
||||
return result;
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abort);
|
||||
}
|
||||
return { appiumHome, appiumBinPath, appiumEnv };
|
||||
}
|
||||
|
||||
async function waitForAppiumReadyAsync({
|
||||
appiumProcess,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
appiumProcess: { getOutput: () => string };
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const deadline = Date.now() + APPIUM_STARTUP_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
const response = await turtleFetch(`http://${APPIUM_HOST}:${APPIUM_PORT}/status`, 'GET', {
|
||||
timeout: 2_000,
|
||||
retries: 0,
|
||||
logger,
|
||||
signal,
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
await sleepAsync(1_000);
|
||||
signal?.throwIfAborted();
|
||||
if (signal) {
|
||||
await setTimeoutAsync(1_000, undefined, { signal });
|
||||
} else {
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
}
|
||||
const output = appiumProcess.getOutput();
|
||||
throw new SystemError(
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type Result, asyncResult } from '@expo/results';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import spawn, { type SpawnOptions } from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -19,6 +21,7 @@ import {
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
|
||||
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
|
||||
import {
|
||||
createNetworkCaptureInputProviders,
|
||||
parseNetworkCaptureInputs,
|
||||
@@ -29,11 +32,16 @@ import {
|
||||
resolveConfiguredPackageManager,
|
||||
resolvePackageExec,
|
||||
} from '../../utils/packageManager';
|
||||
import { isProcessDescendantOfAsync } from '../../utils/processes';
|
||||
import { isProcessDescendantOfAsync, killProcessGroup } from '../../utils/processes';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts';
|
||||
import { ARGENT_EVENT_LOG_FILENAME, startArgentEventCollectionAsync } from '../utils/argentEvents';
|
||||
import {
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createIosSessionStartupInputProviders,
|
||||
createPrivatePreviewUrlInputProvider,
|
||||
createPublicPreviewInputProvider,
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
@@ -41,7 +49,9 @@ import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
parseServeSimLaunchInputs,
|
||||
omitNgrokAuthtoken,
|
||||
parseCorsOriginsInput,
|
||||
parseFrameAncestorsInput,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
@@ -55,6 +65,9 @@ export const MIN_ARGENT_REMOTE_SESSION_VERSION = '0.16.0';
|
||||
const ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG = 'artifacts-list-endpoint';
|
||||
// Tells the tool-server to write its structured event log so we can collect session events.
|
||||
const ARGENT_EVENT_LOG_FLAG = 'tool-server-event-log';
|
||||
// On by default in Argent (since 0.17.0); we turn it off so recorded videos have no Argent
|
||||
// watermark. Older versions that don't know the flag treat `disable` as a no-op.
|
||||
const ARGENT_VIDEO_WATERMARK_FLAG = 'video-watermark';
|
||||
const ARGENT_STATE_DIR = path.join(os.homedir(), '.argent');
|
||||
// Pin the event log path explicitly and hand the same value to the tool-server (via
|
||||
// ARGENT_EVENT_LOG) and the collector, so an ambient ARGENT_EVENT_LOG or a future change to
|
||||
@@ -80,7 +93,12 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
__metricsId: 'eas/start_argent_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
...createIosSessionStartupInputProviders(),
|
||||
...createNetworkCaptureInputProviders(),
|
||||
createPublicPreviewInputProvider(),
|
||||
createPrivatePreviewUrlInputProvider(),
|
||||
createFrameAncestorsInputProvider(),
|
||||
createCorsOriginsInputProvider(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -97,14 +115,15 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
|
||||
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env: stepEnv, signal }) => {
|
||||
// Fail fast before any expensive setup if the injected env
|
||||
// vars are missing: DEVICE_RUN_SESSION_ID (to report the remote config
|
||||
// back to the API server), EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain
|
||||
// for our ngrok tunnels), and NGROK_AUTHTOKEN (to authenticate them).
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(stepEnv);
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(stepEnv);
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(stepEnv);
|
||||
const env = omitNgrokAuthtoken(stepEnv);
|
||||
|
||||
const packageVersion = inputs.package_version.value as string | undefined;
|
||||
// A missing or non-positive value disables the idle timeout (opt-in feature).
|
||||
@@ -113,14 +132,21 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
warnIfArgentPackageVersionCannotBeVerified({ packageVersion, logger });
|
||||
const versionSpec = packageVersion ?? 'latest';
|
||||
const { runtimePlatform } = global;
|
||||
const launch = parseServeSimLaunchInputs(
|
||||
{
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value as string | undefined,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value as string | undefined,
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
|
||||
runtimePlatform,
|
||||
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
|
||||
deviceIdentifier: inputs.device_identifier?.value as string | undefined,
|
||||
installAppPath: inputs.install_app_path?.value as string | undefined,
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
});
|
||||
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
|
||||
{
|
||||
networkCapture: inputs.network_capture?.value,
|
||||
@@ -132,10 +158,6 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
`Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
|
||||
// Start the potentially slow installation while Argent is being prepared.
|
||||
// On Linux expo-device-hub calls this again and awaits the same in-flight
|
||||
// setup before launching. On macOS this remains non-blocking, so only a
|
||||
@@ -147,92 +169,171 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
const argentExec = (args: string[]): { command: string; args: string[] } =>
|
||||
resolvePackageExec(packageManager, args);
|
||||
|
||||
logger.info('Enabling the Argent artifacts list endpoint flag.');
|
||||
const enableArtifacts = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
|
||||
]);
|
||||
await spawn(enableArtifacts.command, enableArtifacts.args, { env, logger });
|
||||
|
||||
logger.info('Enabling the Argent tool-server event log flag.');
|
||||
const enableEventLog = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_EVENT_LOG_FLAG,
|
||||
]);
|
||||
await spawn(enableEventLog.command, enableEventLog.args, { env, logger });
|
||||
|
||||
const startServer = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'server',
|
||||
'start',
|
||||
'--port',
|
||||
'0',
|
||||
'--idle-timeout',
|
||||
'0',
|
||||
'--force',
|
||||
]);
|
||||
logger.info(
|
||||
`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via ${startServer.command}.`
|
||||
);
|
||||
// Keep Argent itself in foreground mode under the detached process. This preserves
|
||||
// the npx/bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
|
||||
const argentServer = spawnDetached({
|
||||
command: startServer.command,
|
||||
args: startServer.args,
|
||||
env: { ...env, ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH, ARGENT_EMULATOR_NO_WINDOW: '1' },
|
||||
});
|
||||
if (argentServer.pid === undefined) {
|
||||
throw new SystemError(
|
||||
'Failed to start Argent: could not determine the PID of the launched process.'
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`Waiting for argent tool-server state in ${ARGENT_STATE_DIR}.`);
|
||||
let toolServerPort: number;
|
||||
let toolServerToken: string | undefined;
|
||||
try {
|
||||
const toolServerState = await waitForArgentToolServerStateAsync({
|
||||
stateDir: ARGENT_STATE_DIR,
|
||||
ancestorPid: argentServer.pid,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
getExitError: argentServer.getExitError,
|
||||
});
|
||||
toolServerPort = toolServerState.port;
|
||||
toolServerToken = toolServerState.token;
|
||||
} catch (err) {
|
||||
const output = argentServer.getOutput();
|
||||
throw new SystemError(
|
||||
`${
|
||||
err instanceof Error ? err.message : `Timed out waiting for argent tool-server state.`
|
||||
}${output ? `\nArgent tool-server output:\n${output}` : ''}`
|
||||
);
|
||||
}
|
||||
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
|
||||
const startupAbortController = new AbortController();
|
||||
const startupSignal = signal
|
||||
? AbortSignal.any([signal, startupAbortController.signal])
|
||||
: startupAbortController.signal;
|
||||
const setupSignal = iosStartup ? startupSignal : undefined;
|
||||
const artifactPollAbortController = new AbortController();
|
||||
const artifactPollSignal = signal
|
||||
? AbortSignal.any([signal, artifactPollAbortController.signal])
|
||||
: artifactPollAbortController.signal;
|
||||
const artifactPollingPromise = pollArgentArtifactsForUploadAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
toolsUrl: `http://127.0.0.1:${toolServerPort}`,
|
||||
toolsAuthToken: toolServerToken,
|
||||
logger,
|
||||
signal: artifactPollSignal,
|
||||
});
|
||||
|
||||
const eventCollection = await startArgentEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
eventLogPath: ARGENT_EVENT_LOG_PATH,
|
||||
logger,
|
||||
});
|
||||
|
||||
let artifactPollingPromise: Promise<void> | undefined;
|
||||
let eventCollection: Awaited<ReturnType<typeof startArgentEventCollectionAsync>> | undefined;
|
||||
let toolsTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let argentServer: ReturnType<typeof spawnDetached> | undefined;
|
||||
let hostStartup: Promise<Result<DeviceSessionHost>> | undefined;
|
||||
let sessionHost: DeviceSessionHost | undefined;
|
||||
let sessionFailed = false;
|
||||
const startHost = () =>
|
||||
startDeviceSessionHostAsync(ctx, {
|
||||
...iosStartup,
|
||||
separateLogPhase: true,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
signal: startupSignal,
|
||||
onStartupError: iosStartup ? error => startupAbortController.abort(error) : undefined,
|
||||
...launch,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
}).catch(error => {
|
||||
if (iosStartup) {
|
||||
startupAbortController.abort(error);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
if (iosStartup) {
|
||||
hostStartup = asyncResult(startHost());
|
||||
}
|
||||
logger.info('Enabling the Argent artifacts list endpoint flag.');
|
||||
const enableArtifacts = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
|
||||
]);
|
||||
await runArgentSetupCommandAsync(
|
||||
enableArtifacts.command,
|
||||
enableArtifacts.args,
|
||||
{ env, logger },
|
||||
setupSignal
|
||||
);
|
||||
|
||||
logger.info('Enabling the Argent tool-server event log flag.');
|
||||
const enableEventLog = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_EVENT_LOG_FLAG,
|
||||
]);
|
||||
await runArgentSetupCommandAsync(
|
||||
enableEventLog.command,
|
||||
enableEventLog.args,
|
||||
{ env, logger },
|
||||
setupSignal
|
||||
);
|
||||
|
||||
logger.info('Disabling the Argent video watermark flag.');
|
||||
const disableVideoWatermark = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'disable',
|
||||
ARGENT_VIDEO_WATERMARK_FLAG,
|
||||
]);
|
||||
// The watermark is cosmetic, so a failure here must not fail the session.
|
||||
try {
|
||||
await runArgentSetupCommandAsync(
|
||||
disableVideoWatermark.command,
|
||||
disableVideoWatermark.args,
|
||||
{ env, logger },
|
||||
setupSignal
|
||||
);
|
||||
} catch (err) {
|
||||
setupSignal?.throwIfAborted();
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Could not disable the Argent video watermark, so recorded videos may include it. Continuing the session.'
|
||||
);
|
||||
}
|
||||
|
||||
setupSignal?.throwIfAborted();
|
||||
const startServer = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'server',
|
||||
'start',
|
||||
'--port',
|
||||
'0',
|
||||
'--idle-timeout',
|
||||
'0',
|
||||
'--force',
|
||||
]);
|
||||
logger.info(
|
||||
`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via ${startServer.command}.`
|
||||
);
|
||||
// Keep Argent itself in foreground mode under the detached process. This preserves
|
||||
// the npx/bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
|
||||
const secrets: string[] = [];
|
||||
argentServer = spawnDetached({
|
||||
command: startServer.command,
|
||||
args: startServer.args,
|
||||
env: { ...env, ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH, ARGENT_EMULATOR_NO_WINDOW: '1' },
|
||||
logger,
|
||||
secrets,
|
||||
});
|
||||
if (argentServer.pid === undefined) {
|
||||
throw new SystemError(
|
||||
'Failed to start Argent: could not determine the PID of the launched process.'
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`Waiting for argent tool-server state in ${ARGENT_STATE_DIR}.`);
|
||||
let toolServerPort: number;
|
||||
let toolServerToken: string | undefined;
|
||||
try {
|
||||
const toolServerState = await waitForArgentToolServerStateAsync({
|
||||
stateDir: ARGENT_STATE_DIR,
|
||||
ancestorPid: argentServer.pid,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
getExitError: argentServer.getExitError,
|
||||
signal: setupSignal,
|
||||
});
|
||||
toolServerPort = toolServerState.port;
|
||||
toolServerToken = toolServerState.token;
|
||||
if (toolServerToken) {
|
||||
secrets.push(toolServerToken);
|
||||
}
|
||||
} catch (err) {
|
||||
const output = argentServer.getOutput();
|
||||
throw new SystemError(
|
||||
`${
|
||||
err instanceof Error ? err.message : `Timed out waiting for argent tool-server state.`
|
||||
}${output ? `\nArgent tool-server output:\n${output}` : ''}`
|
||||
);
|
||||
}
|
||||
if (hostStartup) {
|
||||
sessionHost = (await hostStartup).enforceValue();
|
||||
}
|
||||
setupSignal?.throwIfAborted();
|
||||
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
|
||||
const artifactPollSignal = signal
|
||||
? AbortSignal.any([signal, artifactPollAbortController.signal])
|
||||
: artifactPollAbortController.signal;
|
||||
artifactPollingPromise = pollArgentArtifactsForUploadAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
toolsUrl: `http://127.0.0.1:${toolServerPort}`,
|
||||
toolsAuthToken: toolServerToken,
|
||||
logger,
|
||||
signal: artifactPollSignal,
|
||||
});
|
||||
|
||||
eventCollection = await startArgentEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
eventLogPath: ARGENT_EVENT_LOG_PATH,
|
||||
logger,
|
||||
});
|
||||
|
||||
toolsTunnel = await startNgrokTunnelAsync({
|
||||
port: toolServerPort,
|
||||
subdomainPrefix: 'argent',
|
||||
@@ -248,20 +349,15 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
sessionHost = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
|
||||
const webPreview = await sessionHost.openPreviewAsync({
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
});
|
||||
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
|
||||
logger.info(
|
||||
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
|
||||
webPreview.sharePageUrl
|
||||
? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
|
||||
: 'Private preview URL unavailable for this session.'
|
||||
);
|
||||
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
@@ -290,13 +386,15 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
||||
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
sessionFailed = true;
|
||||
throw error;
|
||||
throw setupSignal?.aborted ? setupSignal.reason : error;
|
||||
} finally {
|
||||
startupAbortController.abort(new Error('Argent session ended.'));
|
||||
await finishRemoteSessionAsync({
|
||||
logger,
|
||||
sessionFailed,
|
||||
@@ -306,11 +404,13 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
'Argent tool-server',
|
||||
(async () => {
|
||||
try {
|
||||
await stopArgentEventCollectionSafelyAsync({
|
||||
eventCollection,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
});
|
||||
if (eventCollection) {
|
||||
await stopArgentEventCollectionSafelyAsync({
|
||||
eventCollection,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
artifactPollAbortController.abort();
|
||||
try {
|
||||
await artifactPollingPromise;
|
||||
@@ -326,11 +426,11 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await argentServer.stopAsync();
|
||||
await argentServer?.stopAsync();
|
||||
}
|
||||
})(),
|
||||
],
|
||||
['session host', sessionHost?.finishAsync()],
|
||||
['session host', hostStartup?.then(result => result.value?.finishAsync())],
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -390,6 +490,31 @@ export function warnIfArgentPackageVersionCannotBeVerified({
|
||||
}
|
||||
}
|
||||
|
||||
async function runArgentSetupCommandAsync(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: SpawnOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
if (!signal) {
|
||||
await spawn(command, args, options);
|
||||
return;
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
const task = spawn(command, args, { ...options, detached: true });
|
||||
const abort = (): void => killProcessGroup(task.child, 'SIGKILL');
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
if (signal.aborted) {
|
||||
abort();
|
||||
}
|
||||
try {
|
||||
await task;
|
||||
signal.throwIfAborted();
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abort);
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgentToolServerState(raw: string): ArgentToolServerState {
|
||||
let json: unknown;
|
||||
try {
|
||||
@@ -412,13 +537,16 @@ export async function waitForArgentToolServerStateAsync({
|
||||
timeoutMs,
|
||||
pollIntervalMs = 1_000,
|
||||
getExitError,
|
||||
signal,
|
||||
}: {
|
||||
stateDir: string;
|
||||
ancestorPid: number;
|
||||
timeoutMs: number;
|
||||
pollIntervalMs?: number;
|
||||
getExitError?: () => Error | undefined;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ArgentToolServerState> {
|
||||
signal?.throwIfAborted();
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown;
|
||||
const throwIfExited = (): void => {
|
||||
@@ -429,6 +557,7 @@ export async function waitForArgentToolServerStateAsync({
|
||||
};
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
signal?.throwIfAborted();
|
||||
throwIfExited();
|
||||
let matchingState: ArgentToolServerState | undefined;
|
||||
try {
|
||||
@@ -454,13 +583,19 @@ export async function waitForArgentToolServerStateAsync({
|
||||
lastError = err;
|
||||
}
|
||||
// Keep process failures outside the retryable state-file read errors.
|
||||
signal?.throwIfAborted();
|
||||
throwIfExited();
|
||||
if (matchingState) {
|
||||
return matchingState;
|
||||
}
|
||||
await sleepAsync(pollIntervalMs);
|
||||
if (signal) {
|
||||
await setTimeoutAsync(pollIntervalMs, undefined, { signal });
|
||||
} else {
|
||||
await sleepAsync(pollIntervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
signal?.throwIfAborted();
|
||||
throw new SystemError(
|
||||
`Timed out waiting for an argent tool-server state file belonging to process ${ancestorPid} in ${stateDir}${
|
||||
lastError instanceof Error ? `: ${lastError.message}` : ''
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
findAvailablePortAsync,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
omitNgrokAuthtoken,
|
||||
startNgrokTunnelAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
@@ -76,11 +77,11 @@ function awaitLocalEgressAcquisitionAsync<T>(
|
||||
|
||||
/**
|
||||
* Points the device host's system HTTP(S) proxy at the EAS CLI's egress client.
|
||||
* Must run before `eas/start_ios_simulator`: the simulator reads the system proxy
|
||||
* at boot. Once a simulator boots, `eas/start_ios_simulator` also sets proxy
|
||||
* environment variables inside it for clients that read them (gRPC, libcurl)
|
||||
* and installs the local egress guard, which refuses connections that ignore
|
||||
* both. On Linux it starts the Android emulator relay and fence instead; see
|
||||
* Must run before iOS simulator startup: the simulator reads the system proxy
|
||||
* at boot. Startup supplies proxy variables for gRPC/libcurl and the guard that
|
||||
* refuses connections bypassing the proxy. Serve-sim-owned startup passes this
|
||||
* boot configuration to serve-sim.
|
||||
* On Linux it starts the Android emulator relay and fence instead; see
|
||||
* androidLocalEgress.ts. The shared session cleanup releases the resources
|
||||
* started here when the session ends, with a job finalizer as a fallback.
|
||||
*/
|
||||
@@ -91,9 +92,10 @@ export function createStartLocalEgressBuildFunction(): BuildFunction {
|
||||
name: 'Start local egress',
|
||||
__metricsId: 'eas/start_local_egress',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX],
|
||||
fn: async ({ logger, global }, { env, signal }) => {
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
|
||||
fn: async ({ logger, global }, { env: stepEnv, signal }) => {
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(stepEnv);
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(stepEnv);
|
||||
const env = omitNgrokAuthtoken(stepEnv);
|
||||
const isAndroid = global?.runtimePlatform === BuildRuntimePlatform.LINUX;
|
||||
let workDir: string | undefined;
|
||||
let server: DetachedProcessHandle | undefined;
|
||||
@@ -156,6 +158,7 @@ export function createStartLocalEgressBuildFunction(): BuildFunction {
|
||||
controlPort,
|
||||
authfilePath,
|
||||
env,
|
||||
logger,
|
||||
signal: startupSignal,
|
||||
});
|
||||
server = started.process;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import { graphql } from 'gql.tada';
|
||||
|
||||
@@ -32,6 +32,11 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'max_duration_seconds',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs, signal, env }) => {
|
||||
// The daemon needs this credential, but shell commands do not.
|
||||
@@ -44,6 +49,18 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
throw new SystemError('MCP server URL is required to start the sandbox daemon.');
|
||||
}
|
||||
const sandboxId = String(inputs.sandbox_id.value);
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value;
|
||||
if (
|
||||
maxDurationSeconds !== undefined &&
|
||||
(typeof maxDurationSeconds !== 'number' ||
|
||||
!Number.isFinite(maxDurationSeconds) ||
|
||||
maxDurationSeconds <= 0)
|
||||
) {
|
||||
throw new UserError(
|
||||
'EAS_SANDBOX_INVALID_DURATION',
|
||||
`Invalid sandbox maximum duration: ${String(maxDurationSeconds)}. Provide a finite number greater than zero, or omit max_duration_seconds.`
|
||||
);
|
||||
}
|
||||
const daemon = await startSandboxDaemonAsync({
|
||||
credential: sandboxToken,
|
||||
serverUrl: mcpServerUrl,
|
||||
@@ -52,6 +69,8 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
signal,
|
||||
workingDirectory: stepCtx.workingDirectory,
|
||||
env: commandEnv,
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
sandboxId,
|
||||
});
|
||||
try {
|
||||
await daemon.ready;
|
||||
@@ -61,7 +80,21 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
stepCtx.logger.info('Sandbox daemon started.');
|
||||
|
||||
await withLogPhaseAsync(stepCtx.logger, AWAIT_SANDBOX_STEP_NAME, async () => {
|
||||
await waitForCancellationAsync(signal);
|
||||
const timeoutSignal =
|
||||
maxDurationSeconds === undefined
|
||||
? undefined
|
||||
: AbortSignal.timeout(Math.ceil(maxDurationSeconds * 1000));
|
||||
await waitForCompletionAsync(
|
||||
AbortSignal.any([
|
||||
...(signal ? [signal] : []),
|
||||
...(timeoutSignal ? [timeoutSignal] : []),
|
||||
])
|
||||
);
|
||||
if (timeoutSignal?.aborted) {
|
||||
stepCtx.logger.info(
|
||||
`Sandbox reached its maximum runtime of ${maxDurationSeconds} seconds. Stopping.`
|
||||
);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
await daemon.stopAsync();
|
||||
@@ -87,11 +120,11 @@ export async function markSandboxReadyAsync(
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForCancellationAsync(signal?: AbortSignal): Promise<void> {
|
||||
if (signal?.aborted) {
|
||||
async function waitForCompletionAsync(signal: AbortSignal): Promise<void> {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>(resolve => {
|
||||
signal?.addEventListener('abort', () => resolve(), { once: true });
|
||||
signal.addEventListener('abort', () => resolve(), { once: true });
|
||||
});
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
|
||||
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
|
||||
import {
|
||||
createNetworkCaptureInputProviders,
|
||||
parseNetworkCaptureInputs,
|
||||
@@ -16,11 +17,19 @@ import {
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import {
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createIosSessionStartupInputProviders,
|
||||
createPrivatePreviewUrlInputProvider,
|
||||
createPublicPreviewInputProvider,
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
parseServeSimLaunchInputs,
|
||||
omitNgrokAuthtoken,
|
||||
parseCorsOriginsInput,
|
||||
parseFrameAncestorsInput,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
@@ -37,7 +46,12 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
__metricsId: 'eas/start_serve_sim_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
...createIosSessionStartupInputProviders(),
|
||||
...createNetworkCaptureInputProviders(),
|
||||
createPublicPreviewInputProvider(),
|
||||
createPrivatePreviewUrlInputProvider(),
|
||||
createFrameAncestorsInputProvider(),
|
||||
createCorsOriginsInputProvider(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -49,20 +63,29 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env: stepEnv, signal }) => {
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(stepEnv);
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(stepEnv);
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(stepEnv);
|
||||
const env = omitNgrokAuthtoken(stepEnv);
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
|
||||
const packageVersion = inputs.package_version?.value as string | undefined;
|
||||
const { runtimePlatform } = global;
|
||||
const launch = parseServeSimLaunchInputs(
|
||||
{
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value as string | undefined,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value as string | undefined,
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
|
||||
runtimePlatform,
|
||||
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
|
||||
deviceIdentifier: inputs.device_identifier?.value as string | undefined,
|
||||
installAppPath: inputs.install_app_path?.value as string | undefined,
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
});
|
||||
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
|
||||
{
|
||||
networkCapture: inputs.network_capture?.value,
|
||||
@@ -77,16 +100,18 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
|
||||
const sessionHost = await startDeviceSessionHostAsync(ctx, {
|
||||
...iosStartup,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
signal,
|
||||
packageVersion,
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
@@ -95,8 +120,15 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
});
|
||||
|
||||
try {
|
||||
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
|
||||
logger.info(`Preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`);
|
||||
const webPreview = await sessionHost.openPreviewAsync({
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
});
|
||||
logger.info(
|
||||
webPreview.sharePageUrl
|
||||
? `Preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
|
||||
: 'Private preview URL unavailable for this session.'
|
||||
);
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { GooglePlayAuthUtils } from '../utils/android/GooglePlayAuthUtils';
|
||||
import { GooglePlayClient, ReleaseStatus, ReleaseStatusZ } from '../utils/android/GooglePlayClient';
|
||||
import {
|
||||
GooglePlayCommitOutcomeUnknownError,
|
||||
GooglePlayUtils,
|
||||
} from '../utils/android/GooglePlayUtils';
|
||||
import { AndroidArtifactType } from '../utils/android/appArtifact';
|
||||
|
||||
const InputsZ = z
|
||||
.object({
|
||||
artifact_path: z.string().min(1),
|
||||
artifact_type: z.enum(['apk', 'aab']),
|
||||
package_name: z.string().regex(/^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+$/),
|
||||
service_account_key_path: z.string().min(1),
|
||||
track: z.string().trim().min(1).default('internal'),
|
||||
release_status: ReleaseStatusZ.exclude(['halted']).default('completed'),
|
||||
rollout: z.number().finite().gt(0).max(1).optional(),
|
||||
changelog: z.string().optional(),
|
||||
changes_not_sent_for_review: z.boolean().default(false),
|
||||
})
|
||||
.superRefine(({ release_status, rollout }, ctx) => {
|
||||
if (release_status === 'draft' && rollout !== undefined) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['rollout'],
|
||||
message: 'A draft release cannot have a rollout.',
|
||||
});
|
||||
}
|
||||
if (release_status === 'inProgress' && rollout === undefined) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['rollout'],
|
||||
message: 'An inProgress release requires a rollout greater than 0 and at most 1.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Does not inspect the binary. The caller must run eas/read_android_app_info and
|
||||
// compare its package_name with the intended application ID before calling this.
|
||||
export function createSubmitToGooglePlayBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'submit_to_google_play',
|
||||
name: 'Submit to Google Play',
|
||||
__metricsId: 'eas/submit_to_google_play',
|
||||
__hookId: 'submit',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'artifact_path',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'artifact_type',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_name',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'service_account_key_path',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'track',
|
||||
required: false,
|
||||
defaultValue: 'internal',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'release_status',
|
||||
required: false,
|
||||
defaultValue: 'completed',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'rollout',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'changelog',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'changes_not_sent_for_review',
|
||||
required: false,
|
||||
defaultValue: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
}),
|
||||
],
|
||||
outputProviders: [BuildStepOutput.createProvider({ id: 'version_code', required: true })],
|
||||
fn: async (ctx, { inputs, outputs, signal }) => {
|
||||
signal?.throwIfAborted();
|
||||
const parsed = InputsZ.safeParse(
|
||||
Object.fromEntries(Object.entries(inputs).map(([key, input]) => [key, input.value]))
|
||||
);
|
||||
if (!parsed.success) {
|
||||
throw new UserError(
|
||||
'EAS_GOOGLE_PLAY_INVALID_INPUT',
|
||||
'Invalid Google Play submission inputs:\n' + z.prettifyError(parsed.error)
|
||||
);
|
||||
}
|
||||
const submission = parsed.data;
|
||||
const { release_status: requested, rollout } = submission;
|
||||
// Match Fastlane: a partial rollout overrides the requested status to inProgress.
|
||||
const release: {
|
||||
status: ReleaseStatus;
|
||||
userFraction?: number;
|
||||
} =
|
||||
rollout !== undefined && rollout < 1
|
||||
? { status: 'inProgress', userFraction: rollout }
|
||||
: { status: requested === 'inProgress' ? 'completed' : requested };
|
||||
const credentials = await GooglePlayAuthUtils.loadGoogleServiceAccountAsync({
|
||||
keyPath: path.resolve(ctx.workingDirectory, submission.service_account_key_path),
|
||||
});
|
||||
const artifactPath = path.resolve(ctx.workingDirectory, submission.artifact_path);
|
||||
const client = new GooglePlayClient(credentials, ctx.logger);
|
||||
const versionCode = await submitToGooglePlayAsync({
|
||||
artifactType: submission.artifact_type,
|
||||
client,
|
||||
submission: {
|
||||
package_name: submission.package_name,
|
||||
track: submission.track,
|
||||
release_status: submission.release_status,
|
||||
rollout: submission.rollout,
|
||||
changelog: submission.changelog,
|
||||
changes_not_sent_for_review: submission.changes_not_sent_for_review,
|
||||
artifact_path: artifactPath,
|
||||
},
|
||||
release,
|
||||
logger: ctx.logger,
|
||||
signal,
|
||||
});
|
||||
outputs.version_code.set(String(versionCode));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function submitToGooglePlayAsync({
|
||||
artifactType,
|
||||
client,
|
||||
submission,
|
||||
release,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
artifactType: AndroidArtifactType;
|
||||
client: GooglePlayClient;
|
||||
submission: {
|
||||
package_name: string;
|
||||
track: string;
|
||||
release_status: ReleaseStatus;
|
||||
rollout?: number;
|
||||
changelog?: string;
|
||||
changes_not_sent_for_review: boolean;
|
||||
artifact_path: string;
|
||||
};
|
||||
release: { status: ReleaseStatus; userFraction?: number };
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<number> {
|
||||
let editId: string | undefined;
|
||||
let committed = false;
|
||||
let commitOutcomeUnknown = false;
|
||||
try {
|
||||
const packageName = submission.package_name;
|
||||
logger.info(
|
||||
`Submitting ${packageName} (${artifactType}) to track ${JSON.stringify(submission.track)}.`
|
||||
);
|
||||
logger.info(
|
||||
`Release status: ${release.status}. Rollout: ${release.userFraction === undefined ? 'none' : new Intl.NumberFormat('en-US', { style: 'percent', maximumSignificantDigits: 15 }).format(release.userFraction)}.`
|
||||
);
|
||||
if (submission.release_status !== release.status) {
|
||||
logger.info(
|
||||
release.status === 'completed'
|
||||
? 'A 100% rollout is submitted as a completed release.'
|
||||
: 'A partial rollout is submitted as an in-progress release.'
|
||||
);
|
||||
}
|
||||
logger.info(
|
||||
submission.changes_not_sent_for_review
|
||||
? 'Save changes without sending them for review.'
|
||||
: 'Send changes for review.'
|
||||
);
|
||||
if (submission.changelog) {
|
||||
logger.info(`Changelog (en-US): ${JSON.stringify(submission.changelog)}`);
|
||||
}
|
||||
editId = (await GooglePlayUtils.createEditAsync(client, { packageName, signal })).id;
|
||||
let lastPercent: number | undefined;
|
||||
const { versionCode } = await GooglePlayUtils.uploadApplicationAsync(client, {
|
||||
packageName,
|
||||
editId,
|
||||
artifactPath: submission.artifact_path,
|
||||
artifactType,
|
||||
signal,
|
||||
onProgress: (uploaded, total) => {
|
||||
const percent = Math.floor((uploaded / total) * 100);
|
||||
if (percent !== lastPercent) {
|
||||
logger.info(`Upload: ${percent}% (${uploaded}/${total} bytes).`);
|
||||
lastPercent = percent;
|
||||
}
|
||||
},
|
||||
});
|
||||
logger.info(`Uploaded version code: ${versionCode}.`);
|
||||
await GooglePlayUtils.updateTrackAsync(client, {
|
||||
packageName,
|
||||
editId,
|
||||
track: submission.track,
|
||||
release,
|
||||
versionCode,
|
||||
changelog: submission.changelog ? { locale: 'en-US', text: submission.changelog } : undefined,
|
||||
signal,
|
||||
});
|
||||
logger.info(`Updated Google Play track ${JSON.stringify(submission.track)} in the edit.`);
|
||||
logger.info('Committing the Google Play edit...');
|
||||
// Commit also validates the edit.
|
||||
await GooglePlayUtils.commitEditAsync(client, {
|
||||
packageName,
|
||||
editId,
|
||||
changesNotSentForReview: submission.changes_not_sent_for_review,
|
||||
logger,
|
||||
signal,
|
||||
});
|
||||
committed = true;
|
||||
logger.info(
|
||||
`Submitted ${packageName}, version ${versionCode}, to ${JSON.stringify(submission.track)}.`
|
||||
);
|
||||
return versionCode;
|
||||
} catch (error) {
|
||||
commitOutcomeUnknown = error instanceof GooglePlayCommitOutcomeUnknownError;
|
||||
throw GooglePlayUtils.mapGooglePlayError(error, submission.package_name);
|
||||
} finally {
|
||||
if (editId && !committed && !commitOutcomeUnknown) {
|
||||
try {
|
||||
await GooglePlayUtils.deleteEditAsync(client, {
|
||||
packageName: submission.package_name,
|
||||
editId,
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
} catch {
|
||||
logger.warn(
|
||||
'Could not discard the pending Google Play changes. Check the release in Play Console before submitting again.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,14 +19,22 @@ import { isProcessGroupRunning, killProcessGroup } from '../../utils/processes';
|
||||
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
|
||||
|
||||
const IOS_SIMULATOR_RECORDING_POLL_INTERVAL_MS = 2_000;
|
||||
// Once the poller stops, each recording stops in parallel within 60 + 130 + 5 + 30 = 225 s.
|
||||
// Once the poller stops, each recording stops in parallel within 60 + 430 + 5 + 310 = 805 s.
|
||||
const SERVE_SIM_START_TIMEOUT_MS = 60_000;
|
||||
/** serve-sim finalizes an active recording within this grace period after SIGTERM. */
|
||||
export const SERVE_SIM_STOP_GRACE_PERIOD_MS = 90_000;
|
||||
const SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS = 30_000;
|
||||
// serve-sim may be stopping at the same time: its grace period, the manifest wait, and 10 s.
|
||||
// Match record-video's control request, recording lease, and finalization allowances.
|
||||
const SERVE_SIM_CONTROL_REQUEST_TIMEOUT_MS = 120_000;
|
||||
const SERVE_SIM_RECORDING_LEASE_MS = 180_000;
|
||||
const SERVE_SIM_FINALIZATION_TIMEOUT_MS = 120_000;
|
||||
const SERVE_SIM_SHUTDOWN_MARGIN_MS = 10_000;
|
||||
/** Give the session host time to finalize its active writer before SIGKILL. */
|
||||
export const SERVE_SIM_STOP_GRACE_PERIOD_MS =
|
||||
SERVE_SIM_FINALIZATION_TIMEOUT_MS + SERVE_SIM_SHUTDOWN_MARGIN_MS;
|
||||
// A recorder can exit before its lease expires and the host writes the manifest.
|
||||
const SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS =
|
||||
SERVE_SIM_RECORDING_LEASE_MS + SERVE_SIM_FINALIZATION_TIMEOUT_MS + SERVE_SIM_SHUTDOWN_MARGIN_MS;
|
||||
// An ambiguous stop can use a full control request before lease/finalization reconciliation.
|
||||
const SERVE_SIM_FINISH_TIMEOUT_MS =
|
||||
SERVE_SIM_STOP_GRACE_PERIOD_MS + SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS + 10_000;
|
||||
SERVE_SIM_CONTROL_REQUEST_TIMEOUT_MS + SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS;
|
||||
const SERVE_SIM_FORCE_STOP_TIMEOUT_MS = 5_000;
|
||||
const SERVE_SIM_RECORDING_STARTED = 'serve-sim:recording-started';
|
||||
// The serve-sim package the session host started; every recorder runs `record-video` from it.
|
||||
@@ -46,6 +54,7 @@ type IosSimulatorRecording = {
|
||||
type ActiveIosSimulatorRecording = IosSimulatorRecording & {
|
||||
recordingProcess: ChildProcess;
|
||||
completionPromise: Promise<void>;
|
||||
hasExited: () => boolean;
|
||||
};
|
||||
|
||||
type IosSimulatorRecordingSession = {
|
||||
@@ -128,6 +137,7 @@ export namespace IosSimulatorRecordingUtils {
|
||||
logger.info(`Stopping screen recording for ${recording.deviceName}.`);
|
||||
const startState = await waitForRecorderStartAsync(recording, SERVE_SIM_START_TIMEOUT_MS);
|
||||
if (startState === 'exited') {
|
||||
await recording.completionPromise;
|
||||
return;
|
||||
}
|
||||
if (startState === 'timeout') {
|
||||
@@ -357,6 +367,7 @@ async function startIosSimulatorRecordingAsync(
|
||||
});
|
||||
return true;
|
||||
};
|
||||
let processExited = false;
|
||||
const completionPromise = (async () => {
|
||||
let spawnError: unknown;
|
||||
try {
|
||||
@@ -367,6 +378,7 @@ async function startIosSimulatorRecordingAsync(
|
||||
// A package-manager wrapper can exit while its record-video child still
|
||||
// runs in the detached group. Keep this device active until the group exits.
|
||||
await waitForRecordingProcessGroupExitAsync(recordingSpawn.child);
|
||||
processExited = true;
|
||||
// Host shutdown can end the recorder's lease after its video has been saved.
|
||||
if ((hasStarted() || !spawnError) && (await saveFinalizedRecordingAsync())) {
|
||||
return;
|
||||
@@ -400,6 +412,7 @@ async function startIosSimulatorRecordingAsync(
|
||||
outputDirectory,
|
||||
recordingProcess: recordingSpawn.child,
|
||||
completionPromise,
|
||||
hasExited: () => processExited,
|
||||
startedAt,
|
||||
getOutput,
|
||||
hasStarted,
|
||||
@@ -428,6 +441,9 @@ async function waitForRecorderStartAsync(
|
||||
): Promise<'started' | 'exited' | 'timeout'> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (recording.hasExited()) {
|
||||
return 'exited';
|
||||
}
|
||||
if (recording.hasStarted()) {
|
||||
return 'started';
|
||||
}
|
||||
@@ -439,7 +455,7 @@ async function waitForRecorderStartAsync(
|
||||
return 'exited';
|
||||
}
|
||||
}
|
||||
return 'timeout';
|
||||
return recording.hasExited() ? 'exited' : recording.hasStarted() ? 'started' : 'timeout';
|
||||
}
|
||||
|
||||
function reportLostRecording(logger: bunyan, deviceName: string, recorderOutput: string): void {
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
import { type Env } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { type ChildProcess } from 'node:child_process';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { IosSimulatorRecordingUtils } from '../IosSimulatorRecordingUtils';
|
||||
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../serveSimMetricsRecorder');
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('node:timers/promises', () => ({
|
||||
setTimeout: (ms: number, value?: unknown, options?: { signal?: AbortSignal }) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const abort = (): void => {
|
||||
clearTimeout(timer);
|
||||
reject(Object.assign(new Error('Aborted'), { name: 'AbortError' }));
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
options?.signal?.removeEventListener('abort', abort);
|
||||
resolve(value);
|
||||
}, ms);
|
||||
if (options?.signal?.aborted) {
|
||||
abort();
|
||||
} else {
|
||||
options?.signal?.addEventListener('abort', abort, { once: true });
|
||||
}
|
||||
}),
|
||||
}));
|
||||
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
} as unknown as bunyan;
|
||||
const udid = '06B546AC-7B06-4BED-83DB-83603E8EB537';
|
||||
let exit: () => void;
|
||||
let child: ChildProcess;
|
||||
let finishing: Promise<unknown> | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] });
|
||||
IosSimulatorRecordingUtils.useServeSimPackage('@expo/serve-sim@next');
|
||||
jest
|
||||
.spyOn(IosSimulatorUtils, 'getAvailableDevicesAsync')
|
||||
.mockResolvedValue([{ udid, name: 'iPhone 17 Pro', runtimeDisplayName: 'iOS 26.4' } as never]);
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
child = Object.assign(new EventEmitter(), {
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
kill: jest.fn(),
|
||||
}) as unknown as ChildProcess;
|
||||
const done = new Promise<void>(resolve => {
|
||||
exit = resolve;
|
||||
});
|
||||
jest.mocked(spawn).mockReturnValue(Object.assign(done, { child }) as never);
|
||||
await IosSimulatorRecordingUtils.startAsync({ env: {} as Env, logger });
|
||||
for (let attempt = 0; attempt < 100 && jest.mocked(spawn).mock.calls.length === 0; attempt++) {
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
}
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
child.stderr?.emit('data', 'serve-sim:recording-started\n');
|
||||
});
|
||||
|
||||
async function saveManifest(): Promise<void> {
|
||||
const directory = jest.mocked(spawn).mock.calls[0][1][6];
|
||||
await writeFile(`${directory}/session.json`, JSON.stringify({ recording: 'recording.mp4' }));
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
if (jest.mocked(spawn).mock.calls.length) {
|
||||
await saveManifest();
|
||||
}
|
||||
exit();
|
||||
finishing ??= IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
await jest.advanceTimersByTimeAsync(1_000);
|
||||
await finishing;
|
||||
finishing = undefined;
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
jest.mocked(spawn).mockReset();
|
||||
});
|
||||
|
||||
test('keeps the recorder alive through a 120-second stop request and 300-second reconciliation', async () => {
|
||||
finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGINT');
|
||||
|
||||
await jest.advanceTimersByTimeAsync(420_000);
|
||||
expect(child.kill).not.toHaveBeenCalledWith('SIGKILL');
|
||||
await saveManifest();
|
||||
exit();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(await finishing).toHaveLength(1);
|
||||
expect(child.kill).not.toHaveBeenCalledWith('SIGKILL');
|
||||
});
|
||||
|
||||
test('force-kills an unresponsive started recorder after the bounded shutdown allowance', async () => {
|
||||
jest.mocked(child.kill).mockImplementation(signal => {
|
||||
if (signal === 'SIGKILL') {
|
||||
exit();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
await jest.advanceTimersByTimeAsync(429_999);
|
||||
expect(child.kill).not.toHaveBeenCalledWith('SIGKILL');
|
||||
await saveManifest();
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL');
|
||||
expect(await finishing).toHaveLength(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.stringContaining('did not finish within 430 seconds')
|
||||
);
|
||||
});
|
||||
|
||||
test('retains a manifest saved after a 180-second lease and 120-second finalization', async () => {
|
||||
exit();
|
||||
finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
await jest.advanceTimersByTimeAsync(299_999);
|
||||
await saveManifest();
|
||||
await jest.advanceTimersByTimeAsync(1_001);
|
||||
expect(await finishing).toHaveLength(1);
|
||||
expect(child.kill).not.toHaveBeenCalledWith('SIGKILL');
|
||||
});
|
||||
@@ -186,7 +186,7 @@ test('reports a started recording that ends without a manifest to Sentry', async
|
||||
child.stderr?.emit('data', 'GET http://127.0.0.1:43563/record?token=session-token failed\n');
|
||||
exitRecorder();
|
||||
await setTimeout(50);
|
||||
currentTime += 31_000;
|
||||
currentTime += 311_000;
|
||||
await setTimeout(1_100);
|
||||
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
@@ -226,7 +226,7 @@ test('says when a recorder never reported its start before it is stopped', async
|
||||
await setTimeout(50);
|
||||
currentTime += 61_000;
|
||||
await setTimeout(200);
|
||||
currentTime += 31_000;
|
||||
currentTime += 311_000;
|
||||
|
||||
expect(await finishing).toEqual([]);
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGINT');
|
||||
@@ -377,7 +377,7 @@ test('does not signal a recorder that exits before it reports its start', async
|
||||
recorder.exit();
|
||||
await setTimeout(50);
|
||||
// Past the manifest wait, so the recorder's completion settles.
|
||||
currentTime += 31_000;
|
||||
currentTime += 311_000;
|
||||
|
||||
expect(await finishing).toEqual([]);
|
||||
expect(recorder.child.kill).not.toHaveBeenCalled();
|
||||
@@ -435,7 +435,7 @@ test('reports a finalized recording whose manifest is gone at finish', async ()
|
||||
await rm(manifest);
|
||||
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
await setTimeout(50);
|
||||
currentTime += 31_000;
|
||||
currentTime += 311_000;
|
||||
|
||||
expect(await finishing).toEqual([]);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
@@ -493,7 +493,7 @@ test('reports a started recorder that fails without a manifest', async () => {
|
||||
recorder.child.stderr?.emit('data', 'serve-sim:recording-started\n');
|
||||
recorder.fail(new Error('record-video exited with code 1'));
|
||||
await setTimeout(50);
|
||||
currentTime += 31_000;
|
||||
currentTime += 311_000;
|
||||
await setTimeout(1_100);
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
@@ -534,6 +534,6 @@ test('records every booted device with the first serve-sim package', async () =>
|
||||
|
||||
recorder.exit();
|
||||
await setTimeout(50);
|
||||
currentTime += 31_000;
|
||||
currentTime += 311_000;
|
||||
await IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
});
|
||||
@@ -153,8 +153,8 @@ describe(uploadAgentDeviceArtifactAsync, () => {
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { stream }) => {
|
||||
await readStreamAsync(stream);
|
||||
.mockImplementationOnce(async (_ctx, { openStream }) => {
|
||||
await readStreamAsync(openStream());
|
||||
});
|
||||
|
||||
await uploadAgentDeviceArtifactAsync(ctx, {
|
||||
@@ -179,7 +179,7 @@ describe(uploadAgentDeviceArtifactAsync, () => {
|
||||
filename: 'report.json',
|
||||
kind: 'agent-device-test-report',
|
||||
size: data.length,
|
||||
stream: expect.anything(),
|
||||
openStream: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -191,8 +191,8 @@ describe(uploadAgentDeviceArtifactAsync, () => {
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { stream }) => {
|
||||
await readStreamAsync(stream);
|
||||
.mockImplementationOnce(async (_ctx, { openStream }) => {
|
||||
await readStreamAsync(openStream());
|
||||
});
|
||||
|
||||
await uploadAgentDeviceArtifactAsync(ctx, {
|
||||
@@ -222,8 +222,8 @@ describe(uploadAgentDeviceArtifactAsync, () => {
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { stream }) => {
|
||||
await readStreamAsync(stream);
|
||||
.mockImplementationOnce(async (_ctx, { openStream }) => {
|
||||
await readStreamAsync(openStream());
|
||||
});
|
||||
|
||||
await uploadAgentDeviceArtifactAsync(ctx, {
|
||||
@@ -255,8 +255,8 @@ describe(uploadAgentDeviceArtifactAsync, () => {
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { stream }) => {
|
||||
await readStreamAsync(stream);
|
||||
.mockImplementationOnce(async (_ctx, { openStream }) => {
|
||||
await readStreamAsync(openStream());
|
||||
});
|
||||
|
||||
await uploadAgentDeviceArtifactAsync(ctx, {
|
||||
@@ -345,8 +345,8 @@ describe(pollAgentDeviceArtifactsForUploadAsync, () => {
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockRejectedValueOnce(new Error('upload failed'))
|
||||
.mockImplementationOnce(async (_ctx, { stream }) => {
|
||||
await readStreamAsync(stream);
|
||||
.mockImplementationOnce(async (_ctx, { openStream }) => {
|
||||
await readStreamAsync(openStream());
|
||||
});
|
||||
|
||||
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
|
||||
|
||||
@@ -7,7 +7,12 @@ import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import HttpLogStream from '../../../logging/HttpLogStream';
|
||||
import RemoteLoggerStream from '../../../logging/RemoteLoggerStream';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../agentDeviceEvents';
|
||||
import {
|
||||
DEVICE_RUN_SESSION_TOKEN_HEADER,
|
||||
DeviceRunSessionWorkerApiError,
|
||||
} from '../deviceRunSessionWorkerApi';
|
||||
|
||||
const mockEventLogStream = {
|
||||
init: jest.fn(async () => undefined),
|
||||
@@ -28,10 +33,18 @@ jest.mock('../../../logging/RemoteLoggerStream', () => ({
|
||||
default: jest.fn(() => mockEventLogStream),
|
||||
}));
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
const DEVICE_RUN_SESSION_TOKEN = 'device-run-session-token-value';
|
||||
const EVENT_LOG_UPLOAD_SESSION_URL =
|
||||
'https://api.expo.test/v2/device-run-session-worker/event-log-upload-session';
|
||||
|
||||
describe(startAgentDeviceEventCollectionAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(turtleFetch).mockImplementation(async () => eventLogUploadSessionResponse());
|
||||
});
|
||||
|
||||
it('uploads normalized events to the session event artifact once', async () => {
|
||||
@@ -88,9 +101,13 @@ describe(startAgentDeviceEventCollectionAsync, () => {
|
||||
await fs.promises.rm(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
expect(ctx.graphqlClient.mutation).toHaveBeenCalledWith(expect.anything(), {
|
||||
deviceRunSessionId: 'session-id',
|
||||
});
|
||||
expect(turtleFetch).toHaveBeenCalledWith(
|
||||
EVENT_LOG_UPLOAD_SESSION_URL,
|
||||
'POST',
|
||||
expect.objectContaining({
|
||||
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: DEVICE_RUN_SESSION_TOKEN },
|
||||
})
|
||||
);
|
||||
expect(RemoteLoggerStream).toHaveBeenCalledWith({
|
||||
logger,
|
||||
uploadMethod: {
|
||||
@@ -535,11 +552,13 @@ describe(startAgentDeviceEventCollectionAsync, () => {
|
||||
});
|
||||
|
||||
it('does not fail the session when event log setup fails', async () => {
|
||||
const error = new Error('WWW unavailable');
|
||||
const ctx = createContext({ realtimeLogs: true });
|
||||
jest.mocked(ctx.graphqlClient.mutation).mockReturnValueOnce({
|
||||
toPromise: async () => ({ error }),
|
||||
} as unknown as ReturnType<CustomBuildContext['graphqlClient']['mutation']>);
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ errors: [{ message: 'WWW unavailable' }] }), {
|
||||
status: 503,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
);
|
||||
const logger = createLogger();
|
||||
|
||||
const collection = await startAgentDeviceEventCollectionAsync({
|
||||
@@ -554,8 +573,11 @@ describe(startAgentDeviceEventCollectionAsync, () => {
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{
|
||||
err: expect.objectContaining({
|
||||
message: 'Failed to create device run session event log upload session: WWW unavailable',
|
||||
cause: error,
|
||||
message:
|
||||
'Failed to create device run session event log upload session: ' +
|
||||
'POST /v2/device-run-session-worker/event-log-upload-session failed with status 503: ' +
|
||||
'WWW unavailable',
|
||||
cause: expect.any(DeviceRunSessionWorkerApiError),
|
||||
}),
|
||||
},
|
||||
'Could not persist device run session events to the artifact.'
|
||||
@@ -563,8 +585,11 @@ describe(startAgentDeviceEventCollectionAsync, () => {
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Could not persist device run session events',
|
||||
expect.objectContaining({
|
||||
message: 'Failed to create device run session event log upload session: WWW unavailable',
|
||||
cause: error,
|
||||
message:
|
||||
'Failed to create device run session event log upload session: ' +
|
||||
'POST /v2/device-run-session-worker/event-log-upload-session failed with status 503: ' +
|
||||
'WWW unavailable',
|
||||
cause: expect.any(DeviceRunSessionWorkerApiError),
|
||||
}),
|
||||
{
|
||||
level: 'warning',
|
||||
@@ -669,32 +694,35 @@ describe(startAgentDeviceEventCollectionAsync, () => {
|
||||
function createContext({
|
||||
realtimeLogs = false,
|
||||
}: { realtimeLogs?: boolean } = {}): CustomBuildContext {
|
||||
const mutation = jest.fn().mockReturnValue({
|
||||
toPromise: async () => ({
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createEventLogUploadSession: {
|
||||
uploadSession: {
|
||||
url: 'https://uploads.expo.test/events',
|
||||
headers: {
|
||||
'content-disposition': 'attachment; filename="events.ndjson"',
|
||||
'x-goog-content-length-range': '0,104857600',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
return {
|
||||
graphqlClient: { mutation },
|
||||
env: realtimeLogs ? { EXPO_STAGING: '1', EAS_BUILD_ID: 'job-run-id' } : {},
|
||||
env: {
|
||||
EAS_DEVICE_RUN_SESSION_TOKEN: DEVICE_RUN_SESSION_TOKEN,
|
||||
...(realtimeLogs ? { EXPO_STAGING: '1', EAS_BUILD_ID: 'job-run-id' } : {}),
|
||||
},
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
job: {
|
||||
secrets: realtimeLogs ? { robotAccessToken: 'robot-access-token' } : {},
|
||||
},
|
||||
} as unknown as CustomBuildContext;
|
||||
}
|
||||
|
||||
function eventLogUploadSessionResponse(): InstanceType<typeof Response> {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
uploadSession: {
|
||||
url: 'https://uploads.expo.test/events',
|
||||
headers: {
|
||||
'content-disposition': 'attachment; filename="events.ndjson"',
|
||||
'x-goog-content-length-range': '0,104857600',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ headers: { 'content-type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
function createLogger(): bunyan {
|
||||
return { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,628 @@
|
||||
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 os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { killProcessGroup } from '../../../utils/processes';
|
||||
import { runAgentAsync } from '../agentRun';
|
||||
import {
|
||||
type AgentRunProviderCredentials,
|
||||
leaseAgentRunProviderCredentialsAsync,
|
||||
} from '../agentRunLease';
|
||||
import { CLAUDE_CODE_CLI } from '../agents/claude';
|
||||
|
||||
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('../agentRunLease');
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../../utils/processes', () => ({
|
||||
...jest.requireActual('../../../utils/processes'),
|
||||
killProcessGroup: jest.fn(),
|
||||
}));
|
||||
|
||||
const installMock = jest.mocked(turtleSpawn);
|
||||
const spawnMock = jest.mocked(spawn);
|
||||
const leaseMock = jest.mocked(leaseAgentRunProviderCredentialsAsync);
|
||||
const killProcessGroupMock = jest.mocked(killProcessGroup);
|
||||
|
||||
const agentRunId = '0199b0a0-1111-7222-8333-444455556666';
|
||||
const anthropicLease: AgentRunProviderCredentials = {
|
||||
provider: 'anthropic',
|
||||
accessToken: 'anthropic-access-token',
|
||||
};
|
||||
const openaiLease: AgentRunProviderCredentials = {
|
||||
provider: 'openai',
|
||||
idToken: 'openai-id-token',
|
||||
accessToken: 'openai-access-token',
|
||||
accountId: 'openai-account-id',
|
||||
};
|
||||
|
||||
interface AgentInvocation {
|
||||
command: string;
|
||||
args: string[];
|
||||
env: Record<string, string>;
|
||||
cwd: string;
|
||||
stdin: jest.Mock;
|
||||
child: EventEmitter & { stdout: PassThrough; stderr: PassThrough };
|
||||
/** The run removes its files when it ends, so call this while the agent is running. */
|
||||
readHomeFile(relativePath: string): { content: string; mode: number };
|
||||
exit(status: number | null, signal?: NodeJS.Signals, options?: { closeOutput: boolean }): void;
|
||||
closeOutput(): void;
|
||||
}
|
||||
|
||||
function mockSpawn({
|
||||
onAgent,
|
||||
onInstall,
|
||||
}: {
|
||||
onAgent: (invocation: AgentInvocation) => void;
|
||||
onInstall?: () => void;
|
||||
}): AgentInvocation[] {
|
||||
installMock.mockImplementation((() => {
|
||||
try {
|
||||
onInstall?.();
|
||||
return Promise.resolve({});
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}) as any);
|
||||
const invocations: AgentInvocation[] = [];
|
||||
spawnMock.mockImplementation(((command: string, args: string[], spawnOptions: any) => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
pid: 4242,
|
||||
stdin: { on: jest.fn(), end: jest.fn() },
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
});
|
||||
// Like a real child: `close` comes after everything it printed has been read.
|
||||
const closeOutput = (): void => {
|
||||
child.stdout.end();
|
||||
child.stderr.end();
|
||||
void Promise.all([once(child.stdout, 'end'), once(child.stderr, 'end')]).then(() =>
|
||||
child.emit('close')
|
||||
);
|
||||
};
|
||||
const invocation: AgentInvocation = {
|
||||
command,
|
||||
args,
|
||||
env: spawnOptions.env,
|
||||
cwd: spawnOptions.cwd,
|
||||
stdin: child.stdin.end,
|
||||
child,
|
||||
readHomeFile(relativePath) {
|
||||
const filePath = path.join(spawnOptions.env.HOME, relativePath);
|
||||
return {
|
||||
content: vol.readFileSync(filePath, 'utf8') as string,
|
||||
mode: vol.statSync(filePath).mode & 0o777,
|
||||
};
|
||||
},
|
||||
exit(status, signal, options = { closeOutput: true }) {
|
||||
process.nextTick(() => {
|
||||
child.emit('exit', status, signal ?? null);
|
||||
if (options.closeOutput) {
|
||||
closeOutput();
|
||||
}
|
||||
});
|
||||
},
|
||||
closeOutput,
|
||||
};
|
||||
invocations.push(invocation);
|
||||
onAgent(invocation);
|
||||
return child;
|
||||
}) as any);
|
||||
return invocations;
|
||||
}
|
||||
|
||||
function runDirectories(): string[] {
|
||||
return (vol.readdirSync(os.tmpdir()) as string[]).filter(name =>
|
||||
name.startsWith('eas-agent-run-')
|
||||
);
|
||||
}
|
||||
|
||||
function createOptions(
|
||||
overrides: Partial<Parameters<typeof runAgentAsync>[0]> = {}
|
||||
): Parameters<typeof runAgentAsync>[0] {
|
||||
return {
|
||||
agentKind: 'claude-code',
|
||||
agentRunId,
|
||||
prompt: 'Fix the failing test.',
|
||||
expoToken: 'expo-token',
|
||||
expoApiV2BaseUrl: 'https://staging-api.expo.test/v2/',
|
||||
mcpUrl: 'https://staging-mcp.expo.test/mcp',
|
||||
pathEnv: '/usr/local/bin:/usr/bin',
|
||||
npmCacheUrl: 'https://npm-cache.expo.test',
|
||||
logger: createMockLogger(),
|
||||
addBuildLogMask: jest.fn(),
|
||||
signal: new AbortController().signal,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe(runAgentAsync, () => {
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
expect(runDirectories()).toEqual([]);
|
||||
});
|
||||
|
||||
it('runs Claude Code with MCP as its only tool source and an allowlisted environment', async () => {
|
||||
leaseMock.mockResolvedValue(anthropicLease);
|
||||
const invocations = mockSpawn({
|
||||
onAgent: agent => {
|
||||
const mcpConfig = agent.readHomeFile('mcp.json');
|
||||
expect(JSON.parse(mcpConfig.content)).toEqual({
|
||||
mcpServers: {
|
||||
expo: {
|
||||
type: 'http',
|
||||
url: 'https://staging-mcp.expo.test/mcp',
|
||||
headers: {
|
||||
Authorization: 'Bearer expo-token',
|
||||
'X-Expo-Agent-Run-Id': agentRunId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mcpConfig.mode).toBe(0o600);
|
||||
agent.exit(0);
|
||||
},
|
||||
});
|
||||
|
||||
await runAgentAsync(createOptions());
|
||||
|
||||
expect(installMock.mock.calls[0].slice(0, 2)).toEqual([
|
||||
'npm',
|
||||
[
|
||||
'install',
|
||||
'--prefix',
|
||||
expect.stringMatching(/eas-agent-run-.*\/cli$/),
|
||||
'--no-save',
|
||||
'--package-lock=false',
|
||||
'--no-audit',
|
||||
'--no-fund',
|
||||
'--registry',
|
||||
'https://npm-cache.expo.test',
|
||||
'@anthropic-ai/[email protected]',
|
||||
],
|
||||
]);
|
||||
expect(invocations).toHaveLength(1);
|
||||
const [agent] = invocations;
|
||||
const runDirectory = path.dirname(agent.env.HOME);
|
||||
expect(path.dirname(runDirectory)).toBe(os.tmpdir());
|
||||
expect(agent.command).toBe(path.join(runDirectory, 'cli', 'node_modules', '.bin', 'claude'));
|
||||
expect(agent.args).toEqual([
|
||||
'-p',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
'--tools',
|
||||
'',
|
||||
'--allowedTools',
|
||||
'mcp__expo',
|
||||
'--mcp-config',
|
||||
path.join(agent.env.HOME, 'mcp.json'),
|
||||
'--strict-mcp-config',
|
||||
]);
|
||||
expect(agent.env).toEqual({
|
||||
PATH: '/usr/local/bin:/usr/bin',
|
||||
HOME: path.join(runDirectory, 'home'),
|
||||
TMPDIR: path.join(runDirectory, 'tmp'),
|
||||
LANG: 'C.UTF-8',
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'anthropic-access-token',
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
|
||||
});
|
||||
expect(agent.cwd).toBe(path.join(runDirectory, 'cwd'));
|
||||
expect(agent.stdin).toHaveBeenCalledWith('Fix the failing test.');
|
||||
});
|
||||
|
||||
it('writes the agent output as log lines', async () => {
|
||||
leaseMock.mockResolvedValue(anthropicLease);
|
||||
const logger = createMockLogger();
|
||||
mockSpawn({
|
||||
onAgent: agent => {
|
||||
const toolResultEvent =
|
||||
'{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"3 tests passed"}]}}\n';
|
||||
agent.child.stdout.write(toolResultEvent.slice(0, 40));
|
||||
agent.child.stdout.write(toolResultEvent.slice(40));
|
||||
agent.child.stdout.write(
|
||||
'{"type":"assistant","message":{"content":[{"type":"text","text":"Done.\\nThe tests pass.\\n"}]}}\n'
|
||||
);
|
||||
agent.child.stdout.write(
|
||||
'{"type":"result","subtype":"error_during_execution","is_error":true,"errors":["boom"]}\n'
|
||||
);
|
||||
agent.child.stderr.write('warning: deprecated flag\n');
|
||||
agent.exit(0);
|
||||
},
|
||||
});
|
||||
|
||||
await runAgentAsync(createOptions({ logger }));
|
||||
|
||||
const child = jest.mocked(logger.child);
|
||||
expect(child.mock.calls).toEqual([[{ source: 'stdout' }], [{ source: 'stderr' }]]);
|
||||
const [stdoutLogger, stderrLogger] = child.mock.results.map(result => result.value);
|
||||
expect(stdoutLogger.info.mock.calls).toEqual([
|
||||
['Tool result: 3 tests passed'],
|
||||
['Agent: Done.\nThe tests pass.'],
|
||||
]);
|
||||
expect(stdoutLogger.error.mock.calls).toEqual([['Agent failed: boom']]);
|
||||
expect(stderrLogger.info.mock.calls).toEqual([['warning: deprecated flag']]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['claude-code', anthropicLease, ['anthropic-access-token']],
|
||||
['codex', openaiLease, ['openai-access-token', 'openai-id-token']],
|
||||
] as const)(
|
||||
'registers the credentials leased for %s with the build log secrets filter before starting the agent',
|
||||
async (agentKind, lease, secrets) => {
|
||||
leaseMock.mockResolvedValue(lease);
|
||||
const addBuildLogMask = jest.fn();
|
||||
const invocations = mockSpawn({
|
||||
onAgent: agent => {
|
||||
expect(addBuildLogMask.mock.calls).toEqual(secrets.map(secret => [secret]));
|
||||
agent.exit(0);
|
||||
},
|
||||
});
|
||||
|
||||
await runAgentAsync(createOptions({ agentKind, addBuildLogMask }));
|
||||
|
||||
expect(invocations).toHaveLength(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('registers renewed credentials before resuming the agent', async () => {
|
||||
leaseMock
|
||||
.mockResolvedValueOnce(anthropicLease)
|
||||
.mockResolvedValueOnce({ ...anthropicLease, accessToken: 'renewed-access-token' });
|
||||
const addBuildLogMask = jest.fn();
|
||||
const invocations = mockSpawn({
|
||||
onAgent: agent => {
|
||||
if (invocations.length === 2) {
|
||||
expect(addBuildLogMask.mock.calls).toEqual([
|
||||
['anthropic-access-token'],
|
||||
['renewed-access-token'],
|
||||
]);
|
||||
}
|
||||
agent.exit(invocations.length === 1 ? 1 : 0);
|
||||
},
|
||||
});
|
||||
|
||||
await runAgentAsync(createOptions({ addBuildLogMask }));
|
||||
|
||||
expect(invocations).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('reports a line the formatter cannot handle instead of ending the run', async () => {
|
||||
leaseMock.mockResolvedValue(anthropicLease);
|
||||
const logger = createMockLogger();
|
||||
const formatOutputLine = jest
|
||||
.spyOn(CLAUDE_CODE_CLI, 'formatOutputLine')
|
||||
.mockImplementationOnce(() => {
|
||||
throw new RangeError('Maximum call stack size exceeded');
|
||||
});
|
||||
mockSpawn({
|
||||
onAgent: agent => {
|
||||
agent.child.stdout.write(
|
||||
'{"type":"assistant","message":{"content":[{"type":"tool_use","name":"t","input":{}}]}}\n'
|
||||
);
|
||||
agent.child.stdout.write(
|
||||
'{"type":"assistant","message":{"content":[{"type":"text","text":"Still here."}]}}\n'
|
||||
);
|
||||
agent.exit(0);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await runAgentAsync(createOptions({ logger }));
|
||||
} finally {
|
||||
formatOutputLine.mockRestore();
|
||||
}
|
||||
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Could not format a line of the agent output',
|
||||
expect.any(RangeError)
|
||||
);
|
||||
const [stdoutLogger] = jest.mocked(logger.child).mock.results.map(result => result.value);
|
||||
expect(stdoutLogger.info.mock.calls).toEqual([['Agent: Still here.']]);
|
||||
});
|
||||
|
||||
it('runs Codex with its shell tool off and the credentials in a private home', async () => {
|
||||
leaseMock.mockResolvedValue(openaiLease);
|
||||
const invocations = mockSpawn({
|
||||
onAgent: agent => {
|
||||
const config = agent.readHomeFile('.codex/config.toml');
|
||||
expect(config.content).toContain(
|
||||
'[features]\nshell_tool = false\nview_image = false\nimage_generation = false\napps = false\nplugins = false\n'
|
||||
);
|
||||
expect(config.content).toContain('sandbox_mode = "read-only"');
|
||||
expect(config.content).toContain('[agents]\nenabled = false\n');
|
||||
expect(config.content).toContain(
|
||||
[
|
||||
'[mcp_servers.expo]',
|
||||
'url = "http://localhost:8787/mcp"',
|
||||
'default_tools_approval_mode = "approve"',
|
||||
'',
|
||||
'[mcp_servers.expo.http_headers]',
|
||||
'"Authorization" = "Bearer expo-token"',
|
||||
`"X-Expo-Agent-Run-Id" = "${agentRunId}"`,
|
||||
].join('\n')
|
||||
);
|
||||
expect(config.mode).toBe(0o600);
|
||||
const auth = agent.readHomeFile('.codex/auth.json');
|
||||
expect(JSON.parse(auth.content)).toEqual({
|
||||
auth_mode: 'chatgptAuthTokens',
|
||||
tokens: {
|
||||
id_token: 'openai-id-token',
|
||||
access_token: 'openai-access-token',
|
||||
refresh_token: '',
|
||||
account_id: 'openai-account-id',
|
||||
},
|
||||
last_refresh: expect.any(String),
|
||||
});
|
||||
expect(auth.mode).toBe(0o600);
|
||||
agent.exit(0);
|
||||
},
|
||||
});
|
||||
|
||||
await runAgentAsync(
|
||||
createOptions({
|
||||
agentKind: 'codex',
|
||||
mcpUrl: 'http://localhost:8787/mcp',
|
||||
npmCacheUrl: undefined,
|
||||
})
|
||||
);
|
||||
|
||||
const npmArgs = installMock.mock.calls[0][1]!;
|
||||
expect(npmArgs).not.toContain('--registry');
|
||||
expect(npmArgs.at(-1)).toBe('@openai/[email protected]');
|
||||
const [agent] = invocations;
|
||||
const runDirectory = path.dirname(agent.env.HOME);
|
||||
expect(agent.command).toBe(path.join(runDirectory, 'cli', 'node_modules', '.bin', 'codex'));
|
||||
expect(agent.args).toEqual(['exec', '--strict-config', '--json', '--skip-git-repo-check']);
|
||||
expect(agent.cwd).toBe(path.join(runDirectory, 'cwd'));
|
||||
expect(agent.env).toEqual({
|
||||
PATH: '/usr/local/bin:/usr/bin',
|
||||
HOME: path.join(runDirectory, 'home'),
|
||||
TMPDIR: path.join(runDirectory, 'tmp'),
|
||||
LANG: 'C.UTF-8',
|
||||
});
|
||||
expect(agent.stdin).toHaveBeenCalledWith('Fix the failing test.');
|
||||
});
|
||||
|
||||
it('escalates to SIGKILL when the agent ignores the stop', async () => {
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
|
||||
leaseMock.mockResolvedValue(anthropicLease);
|
||||
const controller = new AbortController();
|
||||
let agentStarted!: () => void;
|
||||
const started = new Promise<void>(resolve => {
|
||||
agentStarted = resolve;
|
||||
});
|
||||
const invocations = mockSpawn({ onAgent: () => agentStarted() });
|
||||
|
||||
const run = runAgentAsync(createOptions({ signal: controller.signal }));
|
||||
const rejection = expect(run).rejects.toMatchObject({ name: 'AbortError' });
|
||||
await started;
|
||||
const [agent] = invocations;
|
||||
|
||||
controller.abort();
|
||||
expect(killProcessGroupMock.mock.calls).toEqual([[agent.child, 'SIGTERM']]);
|
||||
await jest.advanceTimersByTimeAsync(10_000);
|
||||
expect(killProcessGroupMock.mock.calls).toEqual([
|
||||
[agent.child, 'SIGTERM'],
|
||||
[agent.child, 'SIGKILL'],
|
||||
]);
|
||||
agent.exit(null, 'SIGKILL');
|
||||
|
||||
await rejection;
|
||||
expect(leaseMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ends what the agent left running and waits a bounded time for its output', async () => {
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
|
||||
leaseMock.mockResolvedValue(anthropicLease);
|
||||
let agentStarted!: () => void;
|
||||
const started = new Promise<void>(resolve => {
|
||||
agentStarted = resolve;
|
||||
});
|
||||
const invocations = mockSpawn({ onAgent: () => agentStarted() });
|
||||
|
||||
const run = runAgentAsync(createOptions());
|
||||
await started;
|
||||
invocations[0].exit(0, undefined, { closeOutput: false });
|
||||
await jest.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
await expect(run).resolves.toBeUndefined();
|
||||
expect(killProcessGroupMock.mock.calls).toEqual([[invocations[0].child, 'SIGKILL']]);
|
||||
});
|
||||
|
||||
it('stops the agent when the step is aborted and fails with the abort reason', async () => {
|
||||
leaseMock.mockResolvedValue(anthropicLease);
|
||||
const controller = new AbortController();
|
||||
const reason = new Error('the step was canceled');
|
||||
const invocations = mockSpawn({
|
||||
onAgent: agent => {
|
||||
queueMicrotask(() => {
|
||||
controller.abort(reason);
|
||||
agent.exit(null, 'SIGTERM');
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await expect(runAgentAsync(createOptions({ signal: controller.signal }))).rejects.toBe(reason);
|
||||
|
||||
expect(killProcessGroupMock.mock.calls).toEqual([
|
||||
[invocations[0].child, 'SIGTERM'],
|
||||
[invocations[0].child, 'SIGKILL'],
|
||||
]);
|
||||
expect(leaseMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('resumes Claude Code with new credentials when the lease changed during the run', async () => {
|
||||
const renewedLease: AgentRunProviderCredentials = {
|
||||
...anthropicLease,
|
||||
accessToken: 'renewed-access-token',
|
||||
};
|
||||
leaseMock.mockResolvedValueOnce(anthropicLease).mockResolvedValueOnce(renewedLease);
|
||||
const logger = createMockLogger();
|
||||
const invocations = mockSpawn({
|
||||
onAgent: agent => agent.exit(invocations.length === 1 ? 1 : 0),
|
||||
});
|
||||
|
||||
await runAgentAsync(createOptions({ logger }));
|
||||
|
||||
expect(installMock).toHaveBeenCalledTimes(1);
|
||||
expect(invocations).toHaveLength(2);
|
||||
const [first, resumed] = invocations;
|
||||
expect(resumed.args).toEqual(['-p', '--continue', ...first.args.slice(1)]);
|
||||
expect(resumed.env.CLAUDE_CODE_OAUTH_TOKEN).toBe('renewed-access-token');
|
||||
expect(resumed.stdin).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^Your previous run of this task stopped.*\n\nFix the failing test\.$/s)
|
||||
);
|
||||
const child = jest.mocked(logger.child);
|
||||
expect(child.mock.calls).toEqual([
|
||||
[{ source: 'stdout' }],
|
||||
[{ source: 'stderr' }],
|
||||
[{ source: 'stdout' }],
|
||||
[{ source: 'stderr' }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('resumes Codex with new credentials when the lease changed during the run', async () => {
|
||||
const renewedLease: AgentRunProviderCredentials = {
|
||||
...openaiLease,
|
||||
accessToken: 'renewed-access-token',
|
||||
idToken: 'renewed-id-token',
|
||||
};
|
||||
leaseMock.mockResolvedValueOnce(openaiLease).mockResolvedValueOnce(renewedLease);
|
||||
const invocations = mockSpawn({
|
||||
onAgent: agent => {
|
||||
if (invocations.length === 1) {
|
||||
agent.exit(1);
|
||||
return;
|
||||
}
|
||||
expect(JSON.parse(agent.readHomeFile('.codex/auth.json').content).tokens).toMatchObject({
|
||||
id_token: 'renewed-id-token',
|
||||
access_token: 'renewed-access-token',
|
||||
});
|
||||
agent.exit(0);
|
||||
},
|
||||
});
|
||||
|
||||
await runAgentAsync(createOptions({ agentKind: 'codex' }));
|
||||
|
||||
expect(invocations).toHaveLength(2);
|
||||
expect(invocations[1].args).toEqual([
|
||||
'exec',
|
||||
'--strict-config',
|
||||
'--json',
|
||||
'--skip-git-repo-check',
|
||||
'resume',
|
||||
'--last',
|
||||
'-',
|
||||
]);
|
||||
expect(invocations[1].stdin).toHaveBeenCalledWith(
|
||||
expect.stringContaining('\n\nFix the failing test.')
|
||||
);
|
||||
expect(invocations[1].cwd).toBe(invocations[0].cwd);
|
||||
});
|
||||
|
||||
it('does not resume an agent when the step was aborted while its credentials were renewed', async () => {
|
||||
const controller = new AbortController();
|
||||
leaseMock.mockResolvedValueOnce(anthropicLease).mockImplementationOnce(async () => {
|
||||
controller.abort();
|
||||
return { ...anthropicLease, accessToken: 'renewed-access-token' };
|
||||
});
|
||||
const invocations = mockSpawn({ onAgent: agent => agent.exit(1) });
|
||||
|
||||
await expect(runAgentAsync(createOptions({ signal: controller.signal }))).rejects.toMatchObject(
|
||||
{ name: 'AbortError' }
|
||||
);
|
||||
|
||||
expect(invocations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[2, undefined, 'exited with code 2'],
|
||||
[null, 'SIGKILL' as const, 'was terminated by SIGKILL'],
|
||||
])(
|
||||
'fails when the agent fails and the lease is unchanged (status %p, signal %p)',
|
||||
async (status, signal, description) => {
|
||||
leaseMock.mockResolvedValue(anthropicLease);
|
||||
const invocations = mockSpawn({ onAgent: agent => agent.exit(status, signal) });
|
||||
|
||||
await expect(runAgentAsync(createOptions())).rejects.toMatchObject({
|
||||
errorCode: 'EAS_RUN_AGENT_FAILED',
|
||||
message: expect.stringContaining(description),
|
||||
});
|
||||
|
||||
expect(invocations).toHaveLength(1);
|
||||
expect(leaseMock).toHaveBeenCalledTimes(2);
|
||||
}
|
||||
);
|
||||
|
||||
it('fails with the lease error when the lease cannot be renewed after the agent failed', async () => {
|
||||
leaseMock
|
||||
.mockResolvedValueOnce(anthropicLease)
|
||||
.mockRejectedValueOnce(new Error('lease refused'));
|
||||
const invocations = mockSpawn({ onAgent: agent => agent.exit(1) });
|
||||
|
||||
await expect(runAgentAsync(createOptions())).rejects.toThrow('lease refused');
|
||||
|
||||
expect(invocations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports an agent CLI that cannot start', async () => {
|
||||
leaseMock.mockResolvedValue(anthropicLease);
|
||||
mockSpawn({
|
||||
onAgent: agent => {
|
||||
process.nextTick(() => agent.child.emit('error', new Error('spawn claude ENOENT')));
|
||||
},
|
||||
});
|
||||
|
||||
await expect(runAgentAsync(createOptions())).rejects.toThrow(
|
||||
'Could not start the agent CLI on the worker: spawn claude ENOENT.'
|
||||
);
|
||||
|
||||
expect(leaseMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('cleans up when the installation fails', async () => {
|
||||
leaseMock.mockResolvedValue(anthropicLease);
|
||||
const invocations = mockSpawn({
|
||||
onInstall: () => {
|
||||
throw new Error('npm ERR! network');
|
||||
},
|
||||
onAgent: agent => agent.exit(0),
|
||||
});
|
||||
|
||||
await expect(runAgentAsync(createOptions())).rejects.toBeInstanceOf(SystemError);
|
||||
|
||||
expect(invocations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not install or start anything when the lease is refused', async () => {
|
||||
leaseMock.mockRejectedValue(new Error('lease refused'));
|
||||
mockSpawn({ onAgent: agent => agent.exit(0) });
|
||||
|
||||
await expect(runAgentAsync(createOptions())).rejects.toThrow('lease refused');
|
||||
|
||||
expect(installMock).not.toHaveBeenCalled();
|
||||
expect(spawnMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses credentials of the wrong provider', async () => {
|
||||
leaseMock.mockResolvedValue(openaiLease);
|
||||
const invocations = mockSpawn({ onAgent: agent => agent.exit(0) });
|
||||
|
||||
await expect(runAgentAsync(createOptions({ agentKind: 'claude-code' }))).rejects.toBeInstanceOf(
|
||||
SystemError
|
||||
);
|
||||
|
||||
expect(invocations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import fetch, { type Response } from 'node-fetch';
|
||||
|
||||
import { leaseAgentRunProviderCredentialsAsync } from '../agentRunLease';
|
||||
|
||||
jest.mock('node-fetch');
|
||||
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
const agentRunId = '0199b0a0-1111-7222-8333-444455556666';
|
||||
const options = {
|
||||
expoApiV2BaseUrl: 'https://staging-api.expo.test/v2/',
|
||||
expoToken: 'expo-token',
|
||||
agentRunId,
|
||||
};
|
||||
|
||||
function createResponse(status: number, body: unknown): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => {
|
||||
if (body === undefined) {
|
||||
throw new Error('not JSON');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
describe(leaseAgentRunProviderCredentialsAsync, () => {
|
||||
it('leases Anthropic credentials with the job token', async () => {
|
||||
const lease = { provider: 'anthropic', accessToken: 'access-token' };
|
||||
fetchMock.mockResolvedValue(
|
||||
createResponse(200, { data: { ...lease, expiresAt: '2026-10-06T12:00:00.000Z' } })
|
||||
);
|
||||
|
||||
await expect(leaseAgentRunProviderCredentialsAsync(options)).resolves.toEqual(lease);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe(`https://staging-api.expo.test/v2/agent-runs/${agentRunId}/auth-lease`);
|
||||
expect(init).toMatchObject({
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer expo-token' },
|
||||
});
|
||||
});
|
||||
|
||||
it('leases OpenAI credentials', async () => {
|
||||
const lease = {
|
||||
provider: 'openai',
|
||||
idToken: 'id-token',
|
||||
accessToken: 'access-token',
|
||||
accountId: 'account-id',
|
||||
};
|
||||
fetchMock.mockResolvedValue(createResponse(200, { data: lease }));
|
||||
|
||||
await expect(leaseAgentRunProviderCredentialsAsync(options)).resolves.toEqual(lease);
|
||||
});
|
||||
|
||||
it('reports a 4xx with the message from the server and does not retry', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
createResponse(400, {
|
||||
errors: [
|
||||
{
|
||||
code: 'AGENT_RUN_PROVIDER_CONNECTION_DELETED',
|
||||
message: 'The provider connection for this agent run was deleted.',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const lease = leaseAgentRunProviderCredentialsAsync(options);
|
||||
|
||||
await expect(lease).rejects.toBeInstanceOf(UserError);
|
||||
await expect(lease).rejects.toMatchObject({
|
||||
errorCode: 'EAS_RUN_AGENT_CREDENTIALS_UNAVAILABLE',
|
||||
message: 'The provider connection for this agent run was deleted.',
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports a 4xx without a readable body', async () => {
|
||||
fetchMock.mockResolvedValue(createResponse(401, undefined));
|
||||
|
||||
await expect(leaseAgentRunProviderCredentialsAsync(options)).rejects.toThrow(
|
||||
'Expo refused to issue provider credentials for this agent run (HTTP 401).'
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries server errors and network failures', async () => {
|
||||
jest.useFakeTimers();
|
||||
const lease = { provider: 'anthropic', accessToken: 'access-token' };
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(createResponse(503, undefined))
|
||||
.mockRejectedValueOnce(new Error('socket hang up'))
|
||||
.mockResolvedValueOnce(createResponse(200, { data: lease }));
|
||||
|
||||
const assertion = expect(leaseAgentRunProviderCredentialsAsync(options)).resolves.toEqual(
|
||||
lease
|
||||
);
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('retries when the body of a successful response cannot be read', async () => {
|
||||
jest.useFakeTimers();
|
||||
const lease = { provider: 'anthropic', accessToken: 'access-token' };
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(createResponse(200, undefined))
|
||||
.mockResolvedValueOnce(createResponse(200, { data: lease }));
|
||||
|
||||
const assertion = expect(leaseAgentRunProviderCredentialsAsync(options)).resolves.toEqual(
|
||||
lease
|
||||
);
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('gives up after three failed attempts', async () => {
|
||||
jest.useFakeTimers();
|
||||
fetchMock.mockResolvedValue(createResponse(500, undefined));
|
||||
|
||||
const assertion = expect(leaseAgentRunProviderCredentialsAsync(options)).rejects.toBeInstanceOf(
|
||||
SystemError
|
||||
);
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('rejects a successful response that is not a lease', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
createResponse(200, { data: { provider: 'anthropic', refreshToken: 'nope' } })
|
||||
);
|
||||
|
||||
await expect(leaseAgentRunProviderCredentialsAsync(options)).rejects.toBeInstanceOf(
|
||||
SystemError
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -124,8 +124,8 @@ describe(uploadArgentArtifactAsync, () => {
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { stream }) => {
|
||||
await readStreamAsync(stream);
|
||||
.mockImplementationOnce(async (_ctx, { openStream }) => {
|
||||
await readStreamAsync(openStream());
|
||||
});
|
||||
|
||||
await uploadArgentArtifactAsync(ctx, {
|
||||
@@ -151,7 +151,7 @@ describe(uploadArgentArtifactAsync, () => {
|
||||
filename: 'report.json',
|
||||
kind: undefined,
|
||||
size: data.length,
|
||||
stream: expect.anything(),
|
||||
openStream: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -168,8 +168,8 @@ describe(uploadArgentArtifactAsync, () => {
|
||||
.mockResolvedValueOnce(new Response(Readable.from([Buffer.from('artifact-data')])));
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { stream }) => {
|
||||
await readStreamAsync(stream);
|
||||
.mockImplementationOnce(async (_ctx, { openStream }) => {
|
||||
await readStreamAsync(openStream());
|
||||
});
|
||||
|
||||
await uploadArgentArtifactAsync(ctx, {
|
||||
@@ -345,8 +345,8 @@ describe(pollArgentArtifactsForUploadAsync, () => {
|
||||
});
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementation(async (_ctx, { stream }) => {
|
||||
await readStreamAsync(stream);
|
||||
.mockImplementation(async (_ctx, { openStream }) => {
|
||||
await readStreamAsync(openStream());
|
||||
});
|
||||
|
||||
const pollingPromise = pollArgentArtifactsForUploadAsync(ctx, {
|
||||
@@ -371,7 +371,7 @@ describe(pollArgentArtifactsForUploadAsync, () => {
|
||||
name: 'b.json (artifact-b)',
|
||||
filename: 'b.json',
|
||||
size: 'artifact-b-data'.length,
|
||||
stream: expect.anything(),
|
||||
openStream: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import HttpLogStream from '../../../logging/HttpLogStream';
|
||||
import RemoteLoggerStream from '../../../logging/RemoteLoggerStream';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { startArgentEventCollectionAsync } from '../argentEvents';
|
||||
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
|
||||
|
||||
const mockEventLogStream = {
|
||||
init: jest.fn(async () => undefined),
|
||||
@@ -28,12 +30,20 @@ jest.mock('../../../logging/RemoteLoggerStream', () => ({
|
||||
default: jest.fn(() => mockEventLogStream),
|
||||
}));
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
const DEVICE_RUN_SESSION_TOKEN = 'device-run-session-token-value';
|
||||
const EVENT_LOG_UPLOAD_SESSION_URL =
|
||||
'https://api.expo.test/v2/device-run-session-worker/event-log-upload-session';
|
||||
|
||||
const EVENT_LOG_FILENAME = 'tool-server-events.jsonl';
|
||||
|
||||
describe(startArgentEventCollectionAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(turtleFetch).mockImplementation(async () => eventLogUploadSessionResponse());
|
||||
});
|
||||
|
||||
it('maps tool lifecycle records onto the shared operation vocabulary', async () => {
|
||||
@@ -84,9 +94,13 @@ describe(startArgentEventCollectionAsync, () => {
|
||||
await fs.promises.rm(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
expect(ctx.graphqlClient.mutation).toHaveBeenCalledWith(expect.anything(), {
|
||||
deviceRunSessionId: 'session-id',
|
||||
});
|
||||
expect(turtleFetch).toHaveBeenCalledWith(
|
||||
EVENT_LOG_UPLOAD_SESSION_URL,
|
||||
'POST',
|
||||
expect.objectContaining({
|
||||
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: DEVICE_RUN_SESSION_TOKEN },
|
||||
})
|
||||
);
|
||||
expect(RemoteLoggerStream).toHaveBeenCalledWith({
|
||||
logger,
|
||||
uploadMethod: {
|
||||
@@ -318,29 +332,32 @@ function bunyanRecord(fields: Record<string, unknown>): Record<string, unknown>
|
||||
function createContext({
|
||||
realtimeLogs = false,
|
||||
}: { realtimeLogs?: boolean } = {}): CustomBuildContext {
|
||||
const mutation = jest.fn().mockReturnValue({
|
||||
toPromise: async () => ({
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createEventLogUploadSession: {
|
||||
uploadSession: {
|
||||
url: 'https://uploads.expo.test/events',
|
||||
headers: { 'content-disposition': 'attachment; filename="events.ndjson"' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
return {
|
||||
graphqlClient: { mutation },
|
||||
env: realtimeLogs ? { EXPO_STAGING: '1', EAS_BUILD_ID: 'job-run-id' } : {},
|
||||
env: {
|
||||
EAS_DEVICE_RUN_SESSION_TOKEN: DEVICE_RUN_SESSION_TOKEN,
|
||||
...(realtimeLogs ? { EXPO_STAGING: '1', EAS_BUILD_ID: 'job-run-id' } : {}),
|
||||
},
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
job: {
|
||||
secrets: realtimeLogs ? { robotAccessToken: 'robot-access-token' } : {},
|
||||
},
|
||||
} as unknown as CustomBuildContext;
|
||||
}
|
||||
|
||||
function eventLogUploadSessionResponse(): InstanceType<typeof Response> {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
uploadSession: {
|
||||
url: 'https://uploads.expo.test/events',
|
||||
headers: { 'content-disposition': 'attachment; filename="events.ndjson"' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ headers: { 'content-type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
function createLogger(): bunyan {
|
||||
return { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
}
|
||||
|
||||
@@ -2,46 +2,56 @@ import fetch from 'node-fetch';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
|
||||
|
||||
jest.mock('node-fetch');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
const TOKEN = 'device-run-session-token-value';
|
||||
|
||||
function jsonResponse(body: unknown): InstanceType<typeof Response> {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function createCtx(): CustomBuildContext {
|
||||
return {
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: TOKEN },
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
} as unknown as CustomBuildContext;
|
||||
}
|
||||
|
||||
describe(uploadDeviceRunSessionArtifactAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(fetch).mockReset();
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
});
|
||||
|
||||
it('streams an artifact through a signed upload URL', async () => {
|
||||
const stream = Readable.from(Buffer.from('artifact-data'));
|
||||
const reportedSize = 1024;
|
||||
const mutation = jest.fn().mockReturnValue({
|
||||
toPromise: async () => ({
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createArtifactUploadSession: {
|
||||
uploadSession: {
|
||||
url: 'https://uploads.expo.test/artifact',
|
||||
headers: {
|
||||
'Content-Length': String(reportedSize),
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
},
|
||||
uploadSession: {
|
||||
url: 'https://uploads.expo.test/artifact',
|
||||
headers: {
|
||||
'Content-Length': String(reportedSize),
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
const ctx = {
|
||||
graphqlClient: {
|
||||
mutation,
|
||||
},
|
||||
} as unknown as CustomBuildContext;
|
||||
|
||||
})
|
||||
);
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 200 }));
|
||||
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
await uploadDeviceRunSessionArtifactAsync(createCtx(), {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
artifactId: 'artifact-id',
|
||||
name: 'Artifact report.json (artifact-id)',
|
||||
@@ -49,22 +59,23 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
|
||||
kind: 'agent-device-test-report',
|
||||
metadata: { firstFrameRecordAt: 'test-time' },
|
||||
size: reportedSize,
|
||||
stream,
|
||||
openStream: () => stream,
|
||||
});
|
||||
|
||||
expect(mutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
|
||||
'https://api.expo.test/v2/device-run-session-worker/artifact-upload-sessions',
|
||||
'POST',
|
||||
expect.objectContaining({
|
||||
deviceRunSessionId: 'drs-id',
|
||||
input: {
|
||||
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: TOKEN },
|
||||
json: {
|
||||
name: 'Artifact report.json (artifact-id)',
|
||||
filename: 'report.json',
|
||||
kind: 'agent-device-test-report',
|
||||
metadata: { firstFrameRecordAt: 'test-time' },
|
||||
size: reportedSize,
|
||||
},
|
||||
}),
|
||||
{ fetch: expect.any(Function) }
|
||||
signal: expect.any(AbortSignal),
|
||||
})
|
||||
);
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
'https://uploads.expo.test/artifact',
|
||||
@@ -77,49 +88,32 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
|
||||
|
||||
it('forwards cancellation through upload allocation and skips PUT when allocation aborts', async () => {
|
||||
const controller = new AbortController();
|
||||
const mutation = jest.fn().mockReturnValue({
|
||||
toPromise: async () => {
|
||||
controller.abort();
|
||||
return {
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createArtifactUploadSession: {
|
||||
uploadSession: {
|
||||
url: 'https://uploads.expo.test/artifact',
|
||||
headers: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
jest.mocked(turtleFetch).mockImplementationOnce(async () => {
|
||||
controller.abort();
|
||||
return jsonResponse({
|
||||
data: {
|
||||
uploadSession: {
|
||||
url: 'https://uploads.expo.test/artifact',
|
||||
headers: {},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
const ctx = { graphqlClient: { mutation } } as unknown as CustomBuildContext;
|
||||
await expect(
|
||||
uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
uploadDeviceRunSessionArtifactAsync(createCtx(), {
|
||||
deviceRunSessionId: 'session',
|
||||
artifactId: 'id',
|
||||
name: 'Logs',
|
||||
filename: 'logs.ndjson',
|
||||
kind: 'simulator-log',
|
||||
size: 0,
|
||||
stream: Readable.from([]),
|
||||
openStream: () => Readable.from([]),
|
||||
signal: controller.signal,
|
||||
})
|
||||
).rejects.toThrow();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
const allocationFetch = mutation.mock.calls[0][2].fetch;
|
||||
const mockFetch = jest.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('aborted'));
|
||||
try {
|
||||
await expect(allocationFetch('https://api.expo.test/graphql', {})).rejects.toThrow('aborted');
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://api.expo.test/graphql',
|
||||
expect.objectContaining({
|
||||
signal: expect.objectContaining({ aborted: true }),
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
mockFetch.mockRestore();
|
||||
}
|
||||
const allocationSignal = jest.mocked(turtleFetch).mock.calls[0][2].signal;
|
||||
expect(allocationSignal).toBeInstanceOf(AbortSignal);
|
||||
expect(allocationSignal?.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,27 +1,52 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { Client, fetchExchange } from '@urql/core';
|
||||
import { getEventListeners } from 'node:events';
|
||||
import { readFile, readdir, writeFile } from 'node:fs/promises';
|
||||
import { Readable } from 'node:stream';
|
||||
import os from 'node:os';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import * as processes from '../../../utils/processes';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import {
|
||||
captureDeviceRunSessionPreviewAsync,
|
||||
ensureMacosPreviewEncoderInstalledAsync,
|
||||
startDeviceRunSessionPreview,
|
||||
} from '../deviceRunSessionPreview';
|
||||
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
|
||||
|
||||
jest.mock('node-fetch');
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
const DEVICE_RUN_SESSION_TOKEN = 'device-run-session-token-value';
|
||||
const PREVIEW_UPLOAD_SESSION_URL =
|
||||
'https://api.expo.test/v2/device-run-session-worker/preview-upload-session';
|
||||
const uploadSession = {
|
||||
url: 'https://uploads.expo.test/preview',
|
||||
headers: { 'content-type': 'image/webp' },
|
||||
};
|
||||
|
||||
function createCtx(): CustomBuildContext {
|
||||
return {
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: DEVICE_RUN_SESSION_TOKEN },
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
} as unknown as CustomBuildContext;
|
||||
}
|
||||
|
||||
function uploadSessionResponse(): InstanceType<typeof Response> {
|
||||
return new Response(JSON.stringify({ data: { uploadSession } }), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
describe(startDeviceRunSessionPreview, () => {
|
||||
const image = Buffer.from('webp-image');
|
||||
let captureAsync: jest.Mock<Promise<Buffer>, [AbortSignal]>;
|
||||
let mutation: jest.Mock;
|
||||
let logger: bunyan;
|
||||
let ctx: CustomBuildContext;
|
||||
let preview: ReturnType<typeof startDeviceRunSessionPreview> | undefined;
|
||||
@@ -29,21 +54,11 @@ describe(startDeviceRunSessionPreview, () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
captureAsync = jest.fn().mockResolvedValue(image);
|
||||
mutation = jest.fn().mockReturnValue({
|
||||
toPromise: async () => ({
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createPreviewUploadSession: {
|
||||
uploadSession: {
|
||||
url: 'https://uploads.expo.test/preview',
|
||||
headers: { 'content-type': 'image/webp' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
ctx = { graphqlClient: { mutation } } as unknown as CustomBuildContext;
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockReset()
|
||||
.mockImplementation(async () => uploadSessionResponse());
|
||||
ctx = createCtx();
|
||||
logger = { warn: jest.fn() } as unknown as bunyan;
|
||||
jest
|
||||
.mocked(fetch)
|
||||
@@ -80,7 +95,7 @@ describe(startDeviceRunSessionPreview, () => {
|
||||
expect(captureAsync).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(captureAsync).toHaveBeenCalledTimes(2);
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
await preview!.stopAsync();
|
||||
await jest.advanceTimersByTimeAsync(120_000);
|
||||
expect(captureAsync).toHaveBeenCalledTimes(2);
|
||||
@@ -90,7 +105,7 @@ describe(startDeviceRunSessionPreview, () => {
|
||||
captureAsync.mockRejectedValueOnce(new Error('device not ready'));
|
||||
start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
expect(turtleFetch).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
@@ -100,7 +115,7 @@ describe(startDeviceRunSessionPreview, () => {
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 403 }));
|
||||
start();
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -124,7 +139,7 @@ describe(startDeviceRunSessionPreview, () => {
|
||||
it('reuses the upload URL for the full session', async () => {
|
||||
start();
|
||||
await jest.advanceTimersByTimeAsync(119 * 60_000);
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not overlap captures and aborts pending work when stopped', async () => {
|
||||
@@ -154,29 +169,20 @@ describe(startDeviceRunSessionPreview, () => {
|
||||
const stopping = handle.stopAsync();
|
||||
finish(image);
|
||||
await stopping;
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
expect(turtleFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('session preview with the real GraphQL client', () => {
|
||||
const uploadSession = {
|
||||
url: 'https://uploads.expo.test/preview',
|
||||
headers: { 'content-type': 'image/webp' },
|
||||
};
|
||||
let graphqlFetch: jest.SpyInstance;
|
||||
describe('session preview upload session from the worker API', () => {
|
||||
let preview: ReturnType<typeof startDeviceRunSessionPreview> | undefined;
|
||||
let logger: bunyan;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
graphqlFetch = jest.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new globalThis.Response(
|
||||
JSON.stringify({
|
||||
data: { deviceRunSession: { createPreviewUploadSession: { uploadSession } } },
|
||||
}),
|
||||
{ headers: { 'content-type': 'application/json' } }
|
||||
)
|
||||
);
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockReset()
|
||||
.mockImplementation(async () => uploadSessionResponse());
|
||||
logger = { warn: jest.fn() } as unknown as bunyan;
|
||||
jest
|
||||
.mocked(fetch)
|
||||
@@ -191,14 +197,9 @@ describe('session preview with the real GraphQL client', () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
const start = () => {
|
||||
const graphqlClient = new Client({
|
||||
url: 'https://api.expo.test/graphql',
|
||||
exchanges: [fetchExchange],
|
||||
fetchOptions: { headers: { Authorization: 'Bearer worker-token' } },
|
||||
});
|
||||
const start = (ctx: CustomBuildContext = createCtx()) => {
|
||||
preview = startDeviceRunSessionPreview({
|
||||
ctx: { graphqlClient } as CustomBuildContext,
|
||||
ctx,
|
||||
deviceRunSessionId: 'session-id',
|
||||
captureAsync: async () => Buffer.from('webp-image'),
|
||||
logger,
|
||||
@@ -206,25 +207,49 @@ describe('session preview with the real GraphQL client', () => {
|
||||
return preview;
|
||||
};
|
||||
|
||||
it('preserves the worker authentication when creating the upload session', async () => {
|
||||
it('sends the device run session token when creating the upload session', async () => {
|
||||
start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
const [, options] = graphqlFetch.mock.calls[0];
|
||||
expect(new Headers(options.headers).get('authorization')).toBe('Bearer worker-token');
|
||||
expect(turtleFetch).toHaveBeenCalledWith(
|
||||
PREVIEW_UPLOAD_SESSION_URL,
|
||||
'POST',
|
||||
expect.objectContaining({
|
||||
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: DEVICE_RUN_SESSION_TOKEN },
|
||||
signal: expect.any(AbortSignal),
|
||||
})
|
||||
);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each(['shutdown', 'timeout'])('aborts a stalled GraphQL request on %s', async reason => {
|
||||
it('logs a failure and uploads nothing when the job has no device run session token', async () => {
|
||||
start({
|
||||
env: {},
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
} as unknown as CustomBuildContext);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.any(SystemError) },
|
||||
'Could not refresh the session preview; retrying in 60 seconds.'
|
||||
);
|
||||
const [{ err }] = jest.mocked(logger.warn).mock.calls[0] as unknown as [{ err: Error }];
|
||||
expect(err.message).toMatch(
|
||||
/^This device run session job has no EAS_DEVICE_RUN_SESSION_TOKEN secret\./
|
||||
);
|
||||
expect(turtleFetch).not.toHaveBeenCalled();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['shutdown', 'timeout'])('aborts a stalled worker API request on %s', async reason => {
|
||||
const timeout = new AbortController();
|
||||
jest.spyOn(AbortSignal, 'timeout').mockReturnValue(timeout.signal);
|
||||
let requestSignal: AbortSignal | undefined;
|
||||
let rejectRequest!: (error: Error) => void;
|
||||
graphqlFetch.mockImplementation(
|
||||
(_url, options) =>
|
||||
jest.mocked(turtleFetch).mockImplementation(
|
||||
(_url, _method, options) =>
|
||||
new Promise((_, reject) => {
|
||||
rejectRequest = reject;
|
||||
requestSignal = options.signal;
|
||||
requestSignal!.addEventListener('abort', () => reject(requestSignal!.reason), {
|
||||
requestSignal = options.signal as AbortSignal;
|
||||
requestSignal.addEventListener('abort', () => reject(requestSignal!.reason), {
|
||||
once: true,
|
||||
});
|
||||
})
|
||||
@@ -253,14 +278,7 @@ describe('session preview with the real GraphQL client', () => {
|
||||
await stopping;
|
||||
} else {
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
graphqlFetch.mockResolvedValue(
|
||||
new globalThis.Response(
|
||||
JSON.stringify({
|
||||
data: { deviceRunSession: { createPreviewUploadSession: { uploadSession } } },
|
||||
}),
|
||||
{ headers: { 'content-type': 'application/json' } }
|
||||
)
|
||||
);
|
||||
jest.mocked(turtleFetch).mockImplementation(async () => uploadSessionResponse());
|
||||
jest.spyOn(AbortSignal, 'timeout').mockReturnValue(new AbortController().signal);
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
@@ -383,18 +401,106 @@ describe(ensureMacosPreviewEncoderInstalledAsync, () => {
|
||||
[
|
||||
'brew',
|
||||
['install', 'webp'],
|
||||
{ env: { HOME: '/Users/expo', HOMEBREW_NO_AUTO_UPDATE: '1' }, logger },
|
||||
{
|
||||
env: { HOME: '/Users/expo', HOMEBREW_NO_AUTO_UPDATE: '1' },
|
||||
logger,
|
||||
detached: true,
|
||||
},
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects when webp cannot be installed', async () => {
|
||||
it('does not start a command when setup was already canceled', async () => {
|
||||
jest.mocked(spawn).mockReset();
|
||||
jest.mocked(spawn).mockRejectedValue(new Error('brew failed'));
|
||||
await expect(ensureMacosPreviewEncoderInstalledAsync({ env: {}, logger })).rejects.toThrow(
|
||||
'brew failed'
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const reason = new Error('session ended');
|
||||
controller.abort(reason);
|
||||
await expect(
|
||||
ensureMacosPreviewEncoderInstalledAsync({ env: {}, logger, signal: controller.signal })
|
||||
).rejects.toBe(reason);
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(getEventListeners(controller.signal, 'abort')).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(['cwebp', 'brew'])(
|
||||
'waits for canceled %s to close and never starts another command',
|
||||
async command => {
|
||||
jest.mocked(spawn).mockReset();
|
||||
const controller = new AbortController();
|
||||
const child = { pid: 12345, kill: jest.fn() };
|
||||
const killed = jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {});
|
||||
const closeError = new Error(`${command} closed after cancellation`);
|
||||
let entered!: () => void;
|
||||
const commandEntered = new Promise<void>(resolve => {
|
||||
entered = resolve;
|
||||
});
|
||||
let close!: (error: Error) => void;
|
||||
jest.mocked(spawn).mockImplementation(current => {
|
||||
if (current !== command) {
|
||||
return Promise.reject(new Error('cwebp missing')) as ReturnType<typeof spawn>;
|
||||
}
|
||||
entered();
|
||||
return Object.assign(
|
||||
new Promise((_, reject) => {
|
||||
close = reject;
|
||||
}),
|
||||
{ child }
|
||||
) as unknown as ReturnType<typeof spawn>;
|
||||
});
|
||||
let settled = false;
|
||||
const result = ensureMacosPreviewEncoderInstalledAsync({
|
||||
env: {},
|
||||
logger,
|
||||
signal: controller.signal,
|
||||
}).then(
|
||||
() => {
|
||||
settled = true;
|
||||
},
|
||||
error => {
|
||||
settled = true;
|
||||
return error;
|
||||
}
|
||||
);
|
||||
try {
|
||||
await commandEntered;
|
||||
controller.abort(new Error('session ended'));
|
||||
await Promise.resolve();
|
||||
expect(killed).toHaveBeenCalledWith(child, 'SIGKILL');
|
||||
expect(settled).toBe(false);
|
||||
expect(jest.mocked(spawn).mock.calls.at(-1)).toEqual([
|
||||
command,
|
||||
command === 'cwebp' ? ['-version'] : ['install', 'webp'],
|
||||
expect.objectContaining({ detached: true }),
|
||||
]);
|
||||
expect(jest.mocked(spawn).mock.calls.every(([, , options]) => !options?.signal)).toBe(true);
|
||||
} finally {
|
||||
close(closeError);
|
||||
await result;
|
||||
killed.mockRestore();
|
||||
}
|
||||
expect(spawn).toHaveBeenCalledTimes(command === 'cwebp' ? 1 : 2);
|
||||
expect(getEventListeners(controller.signal, 'abort')).toEqual([]);
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects an installation failure and removes its abort listeners', async () => {
|
||||
jest.mocked(spawn).mockReset();
|
||||
const controller = new AbortController();
|
||||
const failure = new Error('brew failed');
|
||||
jest.mocked(spawn).mockRejectedValue(failure);
|
||||
await expect(
|
||||
ensureMacosPreviewEncoderInstalledAsync({ env: {}, logger, signal: controller.signal })
|
||||
).rejects.toBe(failure);
|
||||
expect(getEventListeners(controller.signal, 'abort')).toEqual([]);
|
||||
});
|
||||
|
||||
it('removes its abort listener after a successful probe', async () => {
|
||||
jest.mocked(spawn).mockReset();
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: '', stderr: '' } as never);
|
||||
const controller = new AbortController();
|
||||
await ensureMacosPreviewEncoderInstalledAsync({ env: {}, logger, signal: controller.signal });
|
||||
expect(getEventListeners(controller.signal, 'abort')).toEqual([]);
|
||||
});
|
||||
});
|
||||
+38
-17
@@ -1,8 +1,7 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import type { BuildStepEnv } from '@expo/steps';
|
||||
import { Client, fetchExchange } from '@urql/core';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { copyFile, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -25,25 +24,27 @@ let ctx: CustomBuildContext;
|
||||
let url: string;
|
||||
let directory: string;
|
||||
let creationBodies: string[];
|
||||
let creationTokens: (string | undefined)[];
|
||||
|
||||
beforeEach(async () => {
|
||||
creationBodies = [];
|
||||
creationTokens = [];
|
||||
directory = await mkdtemp(path.join(tmpdir(), 'recording-recovery-'));
|
||||
server = createServer((request, response) => {
|
||||
if (request.url === '/graphql') {
|
||||
if (request.url === '/v2/device-run-session-worker/artifact-upload-sessions') {
|
||||
request.setEncoding('utf8');
|
||||
let body = '';
|
||||
request.on('data', chunk => (body += chunk));
|
||||
request.on('end', () => {
|
||||
creationBodies.push(body);
|
||||
creationTokens.push(request.headers['expo-device-run-session-token'] as string | undefined);
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createArtifactUploadSession: {
|
||||
uploadSession: { url: `${url}/artifact`, headers: {} },
|
||||
},
|
||||
uploadSession: {
|
||||
url: `${url}/artifact/${encodeURIComponent(JSON.parse(body).filename)}`,
|
||||
headers: {},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -60,12 +61,9 @@ beforeEach(async () => {
|
||||
}
|
||||
url = `http://127.0.0.1:${address.port}`;
|
||||
ctx = {
|
||||
graphqlClient: new Client({
|
||||
url: `${url}/graphql`,
|
||||
exchanges: [fetchExchange],
|
||||
fetchOptions: { headers: { Authorization: 'Bearer test-auth' } },
|
||||
}),
|
||||
} as CustomBuildContext;
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'session-token' },
|
||||
expoApiV2BaseUrl: `${url}/v2/`,
|
||||
} as unknown as CustomBuildContext;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -165,10 +163,14 @@ it('recovers unlisted recordings from a killed host and flags the unfinished one
|
||||
]);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('empty/recording.mp4.partial'));
|
||||
|
||||
let putBytes = 0;
|
||||
const uploaded = new Map<string, Buffer>();
|
||||
handle = (request, response) => {
|
||||
request.on('data', chunk => (putBytes += chunk.length));
|
||||
const chunks: Buffer[] = [];
|
||||
request.on('data', chunk => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
request.on('end', () => {
|
||||
uploaded.set(request.url!, Buffer.concat(chunks));
|
||||
response.statusCode = 200;
|
||||
response.end();
|
||||
});
|
||||
@@ -180,9 +182,18 @@ it('recovers unlisted recordings from a killed host and flags the unfinished one
|
||||
recordings,
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
expect(putBytes).toBe(3 * (await stat(path.join(cut, 'recording.mp4.partial'))).size);
|
||||
for (const recording of recordings) {
|
||||
const manifest = await readFile(path.join(recording.directory, 'session.json'));
|
||||
const recordingId = path.basename(recording.directory);
|
||||
expect(uploaded.get(`/artifact/${recordingId}.json`)).toEqual(manifest);
|
||||
expect(uploaded.get(`/artifact/${recordingId}.mp4`)).toEqual(
|
||||
await readFile(path.join(recording.directory, JSON.parse(manifest.toString()).recording))
|
||||
);
|
||||
}
|
||||
expect(creationTokens).toEqual(Array(6).fill('session-token'));
|
||||
const inputs = creationBodies
|
||||
.map(body => JSON.parse(body).variables.input)
|
||||
.map(body => JSON.parse(body))
|
||||
.filter(input => input.kind === 'screen-recording')
|
||||
.sort((a, b) => a.filename.localeCompare(b.filename));
|
||||
expect(inputs.map(input => input.filename)).toEqual(['cut.mp4', 'done.mp4', 'failed.mp4']);
|
||||
expect(inputs.map(input => input.name.includes(', partial)'))).toEqual([true, false, true]);
|
||||
@@ -193,6 +204,16 @@ it('recovers unlisted recordings from a killed host and flags the unfinished one
|
||||
width: 128,
|
||||
height: 96,
|
||||
});
|
||||
const manifests = creationBodies
|
||||
.map(body => JSON.parse(body))
|
||||
.filter(input => input.kind === 'screen-recording-metadata')
|
||||
.sort((a, b) => a.filename.localeCompare(b.filename));
|
||||
expect(manifests.map(input => input.filename)).toEqual(['cut.json', 'done.json', 'failed.json']);
|
||||
expect(manifests.map(input => input.metadata.recordingFilename)).toEqual([
|
||||
'cut.mp4',
|
||||
'done.mp4',
|
||||
'failed.mp4',
|
||||
]);
|
||||
expect(inputs[2].metadata).toMatchObject({
|
||||
partial: true,
|
||||
partialReason: 'Recording finalization exceeded 30000 ms.',
|
||||
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import { vol } from 'memfs';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
|
||||
import type { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
import { uploadDeviceRunSessionScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
|
||||
|
||||
jest.mock('../deviceRunSessionArtifacts');
|
||||
jest.mock('../../../sentry');
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const recording = {
|
||||
udid: 'simulator-id',
|
||||
deviceName: 'iPhone Duo',
|
||||
runtimeDisplayName: 'iOS 27.1',
|
||||
directory: '/recordings/recording-id',
|
||||
};
|
||||
const manifest = {
|
||||
firstFrameWallClock: { unixMs: 1_791_072_000_000, iso8601: '2026-10-04T00:00:00.000Z' },
|
||||
width: 1440,
|
||||
height: 1200,
|
||||
recording: 'recording.mp4',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{},
|
||||
{
|
||||
deviceStates: [
|
||||
{
|
||||
timeMs: 12.5,
|
||||
state: {
|
||||
width: 1200,
|
||||
height: 1440,
|
||||
orientation: 'landscape-left',
|
||||
screen: 'inner',
|
||||
hingeAngle: 90,
|
||||
physicalOrientation: 'portrait',
|
||||
tableMode: false,
|
||||
contentRect: { x: 220, y: 0, width: 1000, height: 1200 },
|
||||
},
|
||||
},
|
||||
],
|
||||
interruptions: [{ reason: 'simulatorReboot', pausedAtSeconds: 1, resumedAtSeconds: 2 }],
|
||||
futureField: { label: '서울 🌲' },
|
||||
},
|
||||
])('uploads the complete manifest unchanged beside its video (%j)', async extra => {
|
||||
const contents = Buffer.from(JSON.stringify({ ...manifest, ...extra }, null, 2) + '\n');
|
||||
vol.fromJSON({
|
||||
[recording.directory + '/session.json']: contents.toString(),
|
||||
[recording.directory + '/recording.mp4']: 'video',
|
||||
});
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_, input) => {
|
||||
if (input.kind === 'screen-recording') {
|
||||
await writeFile(recording.directory + '/session.json', '{}');
|
||||
}
|
||||
});
|
||||
|
||||
await expect(
|
||||
uploadDeviceRunSessionScreenRecordingsAsync(ctx, {
|
||||
logger,
|
||||
deviceRunSessionId: 'session-id',
|
||||
recordings: [recording],
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
|
||||
const inputs = jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mock.calls.map(([, input]) => input);
|
||||
expect(inputs.map(input => input.filename)).toEqual(['recording-id.mp4', 'recording-id.json']);
|
||||
expect(inputs[1]).toMatchObject({
|
||||
deviceRunSessionId: 'session-id',
|
||||
artifactId: 'recording-id-metadata',
|
||||
kind: 'screen-recording-metadata',
|
||||
metadata: {
|
||||
__eas_type: 'screen-recording-metadata',
|
||||
recordingFilename: 'recording-id.mp4',
|
||||
udid: recording.udid,
|
||||
},
|
||||
size: contents.length,
|
||||
});
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of inputs[1].openStream()) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
expect(Buffer.concat(chunks)).toEqual(contents);
|
||||
}
|
||||
});
|
||||
|
||||
it('reports a metadata upload failure while keeping the video and uploading other recordings', async () => {
|
||||
const other = { ...recording, directory: '/recordings/other-recording', udid: 'other-device' };
|
||||
for (const item of [recording, other]) {
|
||||
vol.fromJSON({
|
||||
[item.directory + '/session.json']: JSON.stringify(manifest),
|
||||
[item.directory + '/recording.mp4']: 'video',
|
||||
});
|
||||
}
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_, input) => {
|
||||
if (input.filename === 'recording-id.json') {
|
||||
throw new Error('Storage unavailable');
|
||||
}
|
||||
});
|
||||
|
||||
await expect(
|
||||
uploadDeviceRunSessionScreenRecordingsAsync(ctx, {
|
||||
logger,
|
||||
deviceRunSessionId: 'session-id',
|
||||
recordings: [recording, other],
|
||||
})
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mock.calls.map(([, input]) => input.filename)
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
'recording-id.mp4',
|
||||
'recording-id.json',
|
||||
'other-recording.mp4',
|
||||
'other-recording.json',
|
||||
])
|
||||
);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Could not upload device screen recording metadata',
|
||||
expect.any(Error)
|
||||
);
|
||||
expect(Sentry.capture).not.toHaveBeenCalledWith(
|
||||
'Could not upload device screen recording',
|
||||
expect.any(Error)
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
`Could not upload screen recording metadata for ${recording.deviceName}.`
|
||||
);
|
||||
});
|
||||
+35
@@ -45,3 +45,38 @@ it('accepts a full SHA-256 commit hash', () => {
|
||||
expect(result.filename).toBe(`my-app-ios-bbbbbbb-${timestamp}.png`);
|
||||
expect(result.metadata.gitCommitHash).toBe(gitCommitHash);
|
||||
});
|
||||
|
||||
describe('without an app slug (device run session worker API)', () => {
|
||||
const sessionWithoutSlug = {
|
||||
platform: 'IOS' as const,
|
||||
app: null,
|
||||
build: { id: 'installed-build', gitCommitHash, app: null },
|
||||
};
|
||||
|
||||
it('starts the filename with the platform and omits appSlug from the metadata', () => {
|
||||
expect(screenshotArtifactDetails(timestamp, sessionWithoutSlug)).toEqual({
|
||||
name,
|
||||
filename: `ios-a1b2c3d-${timestamp}.png`,
|
||||
metadata: {
|
||||
__eas_type: 'screenshot',
|
||||
platform: 'ios',
|
||||
buildId: 'installed-build',
|
||||
gitCommitHash,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('omits the commit when the session has no build', () => {
|
||||
expect(
|
||||
screenshotArtifactDetails(timestamp, {
|
||||
...sessionWithoutSlug,
|
||||
platform: 'ANDROID',
|
||||
build: null,
|
||||
})
|
||||
).toEqual({
|
||||
name,
|
||||
filename: `android-${timestamp}.png`,
|
||||
metadata: { __eas_type: 'screenshot', platform: 'android' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -27,18 +27,17 @@ beforeEach(async () => {
|
||||
directory = await mkdtemp(path.join(os.tmpdir(), 'screenshots-test-'));
|
||||
uploaded = [];
|
||||
failedUploads = new Map();
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_ctx, { stream }) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
uploaded.push(Buffer.concat(chunks));
|
||||
});
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementation(async (_ctx, { openStream }) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of openStream()) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
uploaded.push(Buffer.concat(chunks));
|
||||
});
|
||||
});
|
||||
afterEach(async () => {
|
||||
for (const [, { stream }] of jest.mocked(uploadDeviceRunSessionArtifactAsync).mock.calls) {
|
||||
stream.destroy();
|
||||
}
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
const flush = () =>
|
||||
|
||||
+33
-55
@@ -1,5 +1,5 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import { Client, fetchExchange } from '@urql/core';
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
screenshotArtifactDetails,
|
||||
} from '../deviceRunSessionScreenshotNames';
|
||||
import { uploadDeviceRunSessionScreenshotsAsync } from '../deviceRunSessionScreenshots';
|
||||
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
|
||||
jest.unmock('node-fetch');
|
||||
jest.mock('../../../sentry');
|
||||
function deferred<T>() {
|
||||
@@ -26,7 +27,7 @@ let ctx: CustomBuildContext;
|
||||
let url: string;
|
||||
let directory: string;
|
||||
let creates: number;
|
||||
let authorization: string | undefined;
|
||||
let deviceRunSessionToken: string | string[] | undefined;
|
||||
let stallCreation: boolean;
|
||||
let creationStarted: ReturnType<typeof deferred<IncomingMessage>>;
|
||||
let creationBodies: string[];
|
||||
@@ -40,42 +41,32 @@ beforeEach(async () => {
|
||||
sessionLookups = 0;
|
||||
sessionLookupFails = false;
|
||||
uploadQuery = '';
|
||||
authorization = undefined;
|
||||
deviceRunSessionToken = undefined;
|
||||
stallCreation = false;
|
||||
creationBodies = [];
|
||||
creationStarted = deferred();
|
||||
directory = await mkdtemp(path.join(tmpdir(), 'recording-upload-http-'));
|
||||
server = createServer((request, response) => {
|
||||
if (request.url?.startsWith('/graphql')) {
|
||||
authorization = request.headers.authorization;
|
||||
if (request.url?.startsWith('/v2/device-run-session-worker/')) {
|
||||
deviceRunSessionToken = request.headers[DEVICE_RUN_SESSION_TOKEN_HEADER];
|
||||
request.setEncoding('utf8');
|
||||
let body = '';
|
||||
if (request.method === 'GET') {
|
||||
const requestUrl = new URL(request.url, 'http://localhost');
|
||||
body = JSON.stringify({ query: requestUrl.searchParams.get('query') });
|
||||
}
|
||||
request.on('data', chunk => (body += chunk));
|
||||
request.on('end', () => {
|
||||
const operation = JSON.parse(body);
|
||||
if (operation.query.includes('query ScreenshotSession')) {
|
||||
if (request.url === '/v2/device-run-session-worker/application') {
|
||||
sessionLookups++;
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
if (sessionLookupFails) {
|
||||
response.statusCode = 503;
|
||||
response.end(JSON.stringify({ errors: [{ message: 'Lookup unavailable' }] }));
|
||||
} else {
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
deviceRunSessions: {
|
||||
byId: {
|
||||
platform: 'IOS',
|
||||
app: { slug: 'session-project' },
|
||||
build: {
|
||||
id: 'installed-build',
|
||||
gitCommitHash: installedCommit,
|
||||
app: { slug: 'my-app' },
|
||||
},
|
||||
},
|
||||
build: {
|
||||
id: 'installed-build',
|
||||
gitCommitHash: installedCommit,
|
||||
applicationArchiveUrl: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -92,13 +83,7 @@ beforeEach(async () => {
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createArtifactUploadSession: {
|
||||
uploadSession: { url: `${url}/artifact${uploadQuery}`, headers: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
data: { uploadSession: { url: `${url}/artifact${uploadQuery}`, headers: {} } },
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -113,12 +98,10 @@ beforeEach(async () => {
|
||||
}
|
||||
url = `http://127.0.0.1:${address.port}`;
|
||||
ctx = {
|
||||
graphqlClient: new Client({
|
||||
url: `${url}/graphql`,
|
||||
exchanges: [fetchExchange],
|
||||
fetchOptions: { headers: { Authorization: 'Bearer test-auth' } },
|
||||
}),
|
||||
} as CustomBuildContext;
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'test-device-run-session-token' },
|
||||
expoApiV2BaseUrl: `${url}/v2/`,
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
} as unknown as CustomBuildContext;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -127,7 +110,7 @@ afterEach(async () => {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('publishes a manual preview PNG through the artifact GraphQL and HTTP upload path', async () => {
|
||||
it('publishes a manual preview PNG through the worker API and HTTP upload path', async () => {
|
||||
const payload = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aF9sAAAAASUVORK5CYII=',
|
||||
'base64'
|
||||
@@ -157,25 +140,20 @@ it('publishes a manual preview PNG through the artifact GraphQL and HTTP upload
|
||||
expect(bodies).toEqual([payload]);
|
||||
expect(creates).toBe(1);
|
||||
expect(sessionLookups).toBe(1);
|
||||
expect(authorization).toBe('Bearer test-auth');
|
||||
expect(JSON.parse(creationBodies[0]).variables).toEqual(
|
||||
expect.objectContaining({
|
||||
deviceRunSessionId: 'drs-id',
|
||||
input: expect.objectContaining({
|
||||
name: 'Screenshot 2026-09-24 08:45:59 UTC',
|
||||
filename: 'my-app-ios-a1b2c3d-2026-09-24T08-45-59-123Z.png',
|
||||
kind: 'screenshot',
|
||||
size: payload.length,
|
||||
metadata: {
|
||||
__eas_type: 'screenshot',
|
||||
appSlug: 'my-app',
|
||||
platform: 'ios',
|
||||
buildId: 'installed-build',
|
||||
gitCommitHash: installedCommit,
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(deviceRunSessionToken).toBe('test-device-run-session-token');
|
||||
// The worker API does not return the app slug, so the filename starts with the platform.
|
||||
expect(JSON.parse(creationBodies[0])).toEqual({
|
||||
name: 'Screenshot 2026-09-24 08:45:59 UTC',
|
||||
filename: 'ios-a1b2c3d-2026-09-24T08-45-59-123Z.png',
|
||||
kind: 'screenshot',
|
||||
size: payload.length,
|
||||
metadata: {
|
||||
__eas_type: 'screenshot',
|
||||
platform: 'ios',
|
||||
buildId: 'installed-build',
|
||||
gitCommitHash: installedCommit,
|
||||
},
|
||||
});
|
||||
await expect(stat(path.join(directory, filename))).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
});
|
||||
|
||||
@@ -206,7 +184,7 @@ it('rereads the PNG when the artifact PUT is retried', async () => {
|
||||
await expect(stat(path.join(directory, filename))).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
});
|
||||
|
||||
it('aborts a stalled GraphQL upload request and keeps the PNG', async () => {
|
||||
it('aborts a stalled worker API upload session request and keeps the PNG', async () => {
|
||||
stallCreation = true;
|
||||
const filename = 'screenshot-2026-09-24T08-45-59-123Z-a1b2c3d4e5f6.png';
|
||||
await writeFile(path.join(directory, filename), 'png');
|
||||
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
import { EnvironmentSecretType, Generic } from '@expo/eas-build-job';
|
||||
import { BuildStepGlobalContext } from '@expo/steps';
|
||||
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../../context';
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import {
|
||||
DEVICE_RUN_SESSION_TOKEN_ENV,
|
||||
DEVICE_RUN_SESSION_TOKEN_HEADER,
|
||||
getDeviceRunSessionTokenFromEnvironmentSecrets,
|
||||
getDeviceRunSessionWorkerApi,
|
||||
} from '../deviceRunSessionWorkerApi';
|
||||
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
const API_V2_BASE_URL = 'https://api.expo.test/v2/';
|
||||
|
||||
function createSessionJob(
|
||||
environmentSecrets: { name: string; type: EnvironmentSecretType; value: string }[]
|
||||
): Generic.Job {
|
||||
return {
|
||||
triggeredBy: 'GIT_BASED_INTEGRATION',
|
||||
steps: [],
|
||||
secrets: { robotAccessToken: 'robot-access-token', environmentSecrets },
|
||||
builderEnvironment: { env: { DEVICE_RUN_SESSION_ID: 'drs-id' } },
|
||||
} as unknown as Generic.Job;
|
||||
}
|
||||
|
||||
function createCustomBuildContext(job: Generic.Job): CustomBuildContext<Generic.Job> {
|
||||
const buildCtx = new BuildContext(job, {
|
||||
env: { __API_SERVER_URL: 'https://api.expo.test' },
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
uploadArtifact: jest.fn(),
|
||||
workingdir: '',
|
||||
expoApiV2BaseUrl: API_V2_BASE_URL,
|
||||
});
|
||||
return new CustomBuildContext(buildCtx);
|
||||
}
|
||||
|
||||
async function getSentTokenAsync(ctx: CustomBuildContext): Promise<string | undefined> {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ data: { id: 'drs-id', status: 'in-progress' } }), {
|
||||
status: 200,
|
||||
})
|
||||
);
|
||||
await getDeviceRunSessionWorkerApi(ctx)!.getSessionAsync();
|
||||
const [url, , options] = jest.mocked(turtleFetch).mock.calls[0];
|
||||
expect(url).toBe(`${API_V2_BASE_URL}device-run-session-worker/session`);
|
||||
return (options.headers as Record<string, string>)[DEVICE_RUN_SESSION_TOKEN_HEADER];
|
||||
}
|
||||
|
||||
describe('device run session token propagation', () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
});
|
||||
|
||||
it('makes the environment secret reach the worker API client and the step env', async () => {
|
||||
const job = createSessionJob([
|
||||
{ name: 'NGROK_AUTHTOKEN', type: EnvironmentSecretType.STRING, value: 'ngrok' },
|
||||
{ name: DEVICE_RUN_SESSION_TOKEN_ENV, type: EnvironmentSecretType.STRING, value: 'token-a' },
|
||||
]);
|
||||
const ctx = createCustomBuildContext(job);
|
||||
|
||||
expect(new BuildStepGlobalContext(ctx, false).env[DEVICE_RUN_SESSION_TOKEN_ENV]).toBe(
|
||||
'token-a'
|
||||
);
|
||||
await expect(getSentTokenAsync(ctx)).resolves.toBe('token-a');
|
||||
});
|
||||
|
||||
it('resolves duplicate secrets to the same token in the build env and the worker', async () => {
|
||||
const environmentSecrets = [
|
||||
{ name: DEVICE_RUN_SESSION_TOKEN_ENV, type: EnvironmentSecretType.STRING, value: 'token-a' },
|
||||
{ name: DEVICE_RUN_SESSION_TOKEN_ENV, type: EnvironmentSecretType.STRING, value: 'token-b' },
|
||||
];
|
||||
const ctx = createCustomBuildContext(createSessionJob(environmentSecrets));
|
||||
|
||||
const workerToken = getDeviceRunSessionTokenFromEnvironmentSecrets(environmentSecrets);
|
||||
expect(workerToken).toBe('token-b');
|
||||
await expect(getSentTokenAsync(ctx)).resolves.toBe(workerToken);
|
||||
});
|
||||
|
||||
it('returns no client when the job has no session token', () => {
|
||||
const ctx = createCustomBuildContext(
|
||||
createSessionJob([
|
||||
{ name: 'NGROK_AUTHTOKEN', type: EnvironmentSecretType.STRING, value: 'ngrok' },
|
||||
])
|
||||
);
|
||||
|
||||
expect(getDeviceRunSessionWorkerApi(ctx)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(getDeviceRunSessionTokenFromEnvironmentSecrets, () => {
|
||||
it('returns null without secrets or without the token', () => {
|
||||
expect(getDeviceRunSessionTokenFromEnvironmentSecrets(undefined)).toBeNull();
|
||||
expect(
|
||||
getDeviceRunSessionTokenFromEnvironmentSecrets([
|
||||
{ name: 'OTHER', type: EnvironmentSecretType.STRING, value: 'x' },
|
||||
])
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the last secret with the name is a file secret', () => {
|
||||
expect(
|
||||
getDeviceRunSessionTokenFromEnvironmentSecrets([
|
||||
{ name: DEVICE_RUN_SESSION_TOKEN_ENV, type: EnvironmentSecretType.STRING, value: 'a' },
|
||||
{ name: DEVICE_RUN_SESSION_TOKEN_ENV, type: EnvironmentSecretType.FILE, value: 'Yg==' },
|
||||
])
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import type { BuildStepEnv } from '@expo/steps';
|
||||
import { Client, fetchExchange } from '@urql/core';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
|
||||
@@ -13,6 +12,7 @@ import { setTimeout as delay } from 'node:timers/promises';
|
||||
import type { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
import { findUnlistedDeviceScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
|
||||
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
|
||||
|
||||
jest.unmock('node-fetch');
|
||||
jest.unmock('node:fs');
|
||||
@@ -40,20 +40,20 @@ let ctx: CustomBuildContext;
|
||||
let url: string;
|
||||
let directory: string;
|
||||
let creates: number;
|
||||
let authorization: string | undefined;
|
||||
let deviceRunSessionToken: string | string[] | undefined;
|
||||
let stallCreation: boolean;
|
||||
let creationStarted: ReturnType<typeof deferred<IncomingMessage>>;
|
||||
|
||||
beforeEach(async () => {
|
||||
creates = 0;
|
||||
authorization = undefined;
|
||||
deviceRunSessionToken = undefined;
|
||||
stallCreation = false;
|
||||
creationStarted = deferred();
|
||||
directory = await mkdtemp(path.join(tmpdir(), 'recording-upload-http-'));
|
||||
server = createServer((request, response) => {
|
||||
if (request.url === '/graphql') {
|
||||
if (request.url === '/v2/device-run-session-worker/artifact-upload-sessions') {
|
||||
creates++;
|
||||
authorization = request.headers.authorization;
|
||||
deviceRunSessionToken = request.headers[DEVICE_RUN_SESSION_TOKEN_HEADER];
|
||||
creationStarted.resolve(request);
|
||||
request.resume();
|
||||
request.on('end', () => {
|
||||
@@ -63,13 +63,7 @@ beforeEach(async () => {
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createArtifactUploadSession: {
|
||||
uploadSession: { url: `${url}/artifact`, headers: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
data: { uploadSession: { url: `${url}/artifact`, headers: {} } },
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -84,12 +78,9 @@ beforeEach(async () => {
|
||||
}
|
||||
url = `http://127.0.0.1:${address.port}`;
|
||||
ctx = {
|
||||
graphqlClient: new Client({
|
||||
url: `${url}/graphql`,
|
||||
exchanges: [fetchExchange],
|
||||
fetchOptions: { headers: { Authorization: 'Bearer test-auth' } },
|
||||
}),
|
||||
} as CustomBuildContext;
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'test-device-run-session-token' },
|
||||
expoApiV2BaseUrl: `${url}/v2/`,
|
||||
} as unknown as CustomBuildContext;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -122,11 +113,11 @@ it('replays the full file after a transient PUT failure without recreating the u
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
...metadata,
|
||||
size: payload.length,
|
||||
stream: open(),
|
||||
reopenStream: open,
|
||||
openStream: open,
|
||||
attempts: 3,
|
||||
});
|
||||
expect(creates).toBe(1);
|
||||
expect(authorization).toBe('Bearer test-auth');
|
||||
expect(deviceRunSessionToken).toBe('test-device-run-session-token');
|
||||
expect(bodies).toEqual([payload, payload]);
|
||||
expect(streams).toHaveLength(2);
|
||||
expect(streams.every(stream => stream.destroyed)).toBe(true);
|
||||
@@ -156,7 +147,11 @@ it('keeps a slow PUT alive while its body is still flowing', async () => {
|
||||
onChunk();
|
||||
});
|
||||
const stream = new Readable({ read() {} });
|
||||
const upload = uploadDeviceRunSessionArtifactAsync(ctx, { ...metadata, size: 3, stream });
|
||||
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
...metadata,
|
||||
size: 3,
|
||||
openStream: () => stream,
|
||||
});
|
||||
const settled = expect(upload).resolves.toBeUndefined();
|
||||
stream.push('a');
|
||||
await waitForBytes(1);
|
||||
@@ -196,8 +191,8 @@ it('retries a PUT that stalled past the idle deadline with a fresh stream', asyn
|
||||
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
...metadata,
|
||||
size: 9,
|
||||
stream: open(),
|
||||
reopenStream: open,
|
||||
openStream: open,
|
||||
attempts: 3,
|
||||
});
|
||||
const settled = expect(upload).resolves.toBeUndefined();
|
||||
await firstBodyRead.promise;
|
||||
@@ -222,11 +217,11 @@ it('cancels an active PUT socket and destroys its source without retrying', asyn
|
||||
const controller = new AbortController();
|
||||
const stream = new Readable({ read() {} });
|
||||
stream.push(Buffer.alloc(1024));
|
||||
const reopenStream = jest.fn(() => Readable.from('unexpected retry'));
|
||||
const openStream = jest.fn(() => stream);
|
||||
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
...metadata,
|
||||
stream,
|
||||
reopenStream,
|
||||
openStream,
|
||||
attempts: 3,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const rejected = expect(upload).rejects.toThrow('test cancellation');
|
||||
@@ -235,7 +230,7 @@ it('cancels an active PUT socket and destroys its source without retrying', asyn
|
||||
await rejected;
|
||||
await closed.promise;
|
||||
expect(stream.destroyed).toBe(true);
|
||||
expect(reopenStream).not.toHaveBeenCalled();
|
||||
expect(openStream).toHaveBeenCalledTimes(1);
|
||||
expect(puts).toBe(1);
|
||||
});
|
||||
|
||||
@@ -259,8 +254,8 @@ it.each([200, 400, 503])(
|
||||
};
|
||||
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
...metadata,
|
||||
stream: Readable.from('recording'),
|
||||
reopenStream: () => Readable.from('recording'),
|
||||
openStream: () => Readable.from('recording'),
|
||||
attempts: 3,
|
||||
});
|
||||
if (status === 200) {
|
||||
await expect(upload).resolves.toBeUndefined();
|
||||
@@ -277,15 +272,15 @@ it.each([200, 400, 503])(
|
||||
}
|
||||
);
|
||||
|
||||
it('cancels urql session creation with auth intact and never starts a PUT', async () => {
|
||||
it('cancels worker API session creation with the token intact and never starts a PUT', async () => {
|
||||
stallCreation = true;
|
||||
const puts = jest.fn();
|
||||
handle = puts;
|
||||
const controller = new AbortController();
|
||||
const stream = Readable.from('recording');
|
||||
const openStream = jest.fn(() => Readable.from('recording'));
|
||||
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
...metadata,
|
||||
stream,
|
||||
openStream,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const rejected = expect(upload).rejects.toThrow('test cancellation');
|
||||
@@ -294,10 +289,10 @@ it('cancels urql session creation with auth intact and never starts a PUT', asyn
|
||||
controller.abort(new Error('test cancellation'));
|
||||
await rejected;
|
||||
await closed;
|
||||
expect(authorization).toBe('Bearer test-auth');
|
||||
expect(deviceRunSessionToken).toBe('test-device-run-session-token');
|
||||
expect(creates).toBe(1);
|
||||
expect(puts).not.toHaveBeenCalled();
|
||||
expect(stream.destroyed).toBe(true);
|
||||
expect(openStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('says ffprobe is missing instead of calling an unlisted recording undecodable', async () => {
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import {
|
||||
DEVICE_RUN_SESSION_TOKEN_HEADER,
|
||||
DeviceRunSessionWorkerApi,
|
||||
DeviceRunSessionWorkerApiError,
|
||||
getDeviceRunSessionWorkerApi,
|
||||
requireDeviceRunSessionWorkerApi,
|
||||
} from '../deviceRunSessionWorkerApi';
|
||||
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
type Response = InstanceType<typeof Response>;
|
||||
|
||||
const BASE_URL = 'https://api.expo.test/v2/';
|
||||
const TOKEN = 'device-run-session-token-value';
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function createApi(): DeviceRunSessionWorkerApi {
|
||||
return new DeviceRunSessionWorkerApi(BASE_URL, TOKEN);
|
||||
}
|
||||
|
||||
describe(getDeviceRunSessionWorkerApi, () => {
|
||||
it('returns null when the job has no device run session token', () => {
|
||||
expect(
|
||||
getDeviceRunSessionWorkerApi({
|
||||
env: { __API_SERVER_URL: 'https://api.expo.test' },
|
||||
expoApiV2BaseUrl: BASE_URL,
|
||||
} as unknown as CustomBuildContext)
|
||||
).toBeNull();
|
||||
expect(
|
||||
getDeviceRunSessionWorkerApi({
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: '' },
|
||||
expoApiV2BaseUrl: BASE_URL,
|
||||
} as unknown as CustomBuildContext)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('uses the API v2 base URL of the context', async () => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'in-progress' } }));
|
||||
const api = getDeviceRunSessionWorkerApi({
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: TOKEN, __API_SERVER_URL: 'https://other.expo.test' },
|
||||
expoApiV2BaseUrl: BASE_URL,
|
||||
} as unknown as CustomBuildContext);
|
||||
|
||||
await api!.getSessionAsync();
|
||||
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
|
||||
'https://api.expo.test/v2/device-run-session-worker/session',
|
||||
'GET',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('derives the API v2 base URL from __API_SERVER_URL when the context has none', async () => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'in-progress' } }));
|
||||
const api = getDeviceRunSessionWorkerApi({
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: TOKEN, __API_SERVER_URL: 'https://api.expo.test' },
|
||||
} as unknown as CustomBuildContext);
|
||||
|
||||
await api!.getSessionAsync();
|
||||
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
|
||||
'https://api.expo.test/v2/device-run-session-worker/session',
|
||||
'GET',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a system error when the token is set but no API URL is known', () => {
|
||||
expect(() =>
|
||||
getDeviceRunSessionWorkerApi({
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: TOKEN },
|
||||
} as unknown as CustomBuildContext)
|
||||
).toThrow(SystemError);
|
||||
});
|
||||
});
|
||||
|
||||
describe(requireDeviceRunSessionWorkerApi, () => {
|
||||
it('returns the client when the job has a device run session token', () => {
|
||||
expect(
|
||||
requireDeviceRunSessionWorkerApi({
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: TOKEN },
|
||||
expoApiV2BaseUrl: BASE_URL,
|
||||
} as unknown as CustomBuildContext)
|
||||
).toBeInstanceOf(DeviceRunSessionWorkerApi);
|
||||
});
|
||||
|
||||
it('throws a system error when the job has no device run session token', () => {
|
||||
const error = (() => {
|
||||
try {
|
||||
requireDeviceRunSessionWorkerApi({
|
||||
env: { __API_SERVER_URL: 'https://api.expo.test' },
|
||||
expoApiV2BaseUrl: BASE_URL,
|
||||
} as unknown as CustomBuildContext);
|
||||
} catch (err) {
|
||||
return err;
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
expect(error).toBeInstanceOf(SystemError);
|
||||
expect((error as Error).message).toMatch(
|
||||
/^This device run session job has no EAS_DEVICE_RUN_SESSION_TOKEN secret\./
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(DeviceRunSessionWorkerApi, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'getSessionAsync',
|
||||
call: (api: DeviceRunSessionWorkerApi) => api.getSessionAsync(),
|
||||
method: 'GET',
|
||||
route: 'session',
|
||||
json: undefined,
|
||||
data: { id: 'drs-id', status: 'in-progress' },
|
||||
expected: { id: 'drs-id', status: 'in-progress' },
|
||||
},
|
||||
{
|
||||
name: 'startAsync',
|
||||
call: (api: DeviceRunSessionWorkerApi) =>
|
||||
api.startAsync({ remoteConfig: { type: 'agent-device', url: 'https://x.test' } }),
|
||||
method: 'POST',
|
||||
route: 'start',
|
||||
json: { remoteConfig: { type: 'agent-device', url: 'https://x.test' } },
|
||||
data: { id: 'drs-id', status: 'in-progress' },
|
||||
expected: { id: 'drs-id', status: 'in-progress' },
|
||||
},
|
||||
{
|
||||
name: 'stopAsync',
|
||||
call: (api: DeviceRunSessionWorkerApi) => api.stopAsync(),
|
||||
method: 'POST',
|
||||
route: 'stop',
|
||||
json: undefined,
|
||||
data: { id: 'drs-id', status: 'stopped' },
|
||||
expected: { id: 'drs-id', status: 'stopped' },
|
||||
},
|
||||
{
|
||||
name: 'createArtifactUploadSessionAsync',
|
||||
call: (api: DeviceRunSessionWorkerApi) =>
|
||||
api.createArtifactUploadSessionAsync({
|
||||
name: 'Screenshot',
|
||||
filename: 'screenshot.png',
|
||||
kind: 'screenshot',
|
||||
size: 12,
|
||||
metadata: { __eas_type: 'screenshot' },
|
||||
}),
|
||||
method: 'POST',
|
||||
route: 'artifact-upload-sessions',
|
||||
json: {
|
||||
name: 'Screenshot',
|
||||
filename: 'screenshot.png',
|
||||
kind: 'screenshot',
|
||||
size: 12,
|
||||
metadata: { __eas_type: 'screenshot' },
|
||||
},
|
||||
data: { uploadSession: { url: 'https://uploads.test/a', headers: { 'X-A': '1' } } },
|
||||
expected: { url: 'https://uploads.test/a', headers: { 'X-A': '1' } },
|
||||
},
|
||||
{
|
||||
name: 'createEventLogUploadSessionAsync',
|
||||
call: (api: DeviceRunSessionWorkerApi) => api.createEventLogUploadSessionAsync(),
|
||||
method: 'POST',
|
||||
route: 'event-log-upload-session',
|
||||
json: undefined,
|
||||
data: { uploadSession: { url: 'https://uploads.test/events', headers: {} } },
|
||||
expected: { url: 'https://uploads.test/events', headers: {} },
|
||||
},
|
||||
{
|
||||
name: 'createPreviewUploadSessionAsync',
|
||||
call: (api: DeviceRunSessionWorkerApi) => api.createPreviewUploadSessionAsync(),
|
||||
method: 'POST',
|
||||
route: 'preview-upload-session',
|
||||
json: undefined,
|
||||
data: { uploadSession: { url: 'https://uploads.test/preview', headers: {} } },
|
||||
expected: { url: 'https://uploads.test/preview', headers: {} },
|
||||
},
|
||||
{
|
||||
name: 'fetchTurnIceServersAsync',
|
||||
call: (api: DeviceRunSessionWorkerApi) => api.fetchTurnIceServersAsync(),
|
||||
method: 'POST',
|
||||
route: 'turn-ice-servers',
|
||||
json: undefined,
|
||||
data: {
|
||||
iceServers: [
|
||||
{ urls: ['stun:stun.test:3478'] },
|
||||
{ urls: ['turn:turn.test:3478'], username: 'user', credential: 'secret' },
|
||||
],
|
||||
},
|
||||
expected: [
|
||||
{ urls: ['stun:stun.test:3478'] },
|
||||
{ urls: ['turn:turn.test:3478'], username: 'user', credential: 'secret' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'getApplicationAsync',
|
||||
call: (api: DeviceRunSessionWorkerApi) => api.getApplicationAsync(),
|
||||
method: 'GET',
|
||||
route: 'application',
|
||||
json: undefined,
|
||||
data: {
|
||||
build: {
|
||||
id: 'build-id',
|
||||
gitCommitHash: null,
|
||||
applicationArchiveUrl: 'https://expo.test/artifacts/eas/a.tar.gz',
|
||||
},
|
||||
},
|
||||
expected: {
|
||||
build: {
|
||||
id: 'build-id',
|
||||
gitCommitHash: null,
|
||||
applicationArchiveUrl: 'https://expo.test/artifacts/eas/a.tar.gz',
|
||||
},
|
||||
},
|
||||
},
|
||||
])(
|
||||
'$name calls $method /v2/device-run-session-worker/$route with the session token header',
|
||||
async ({ call, method, route, json, data, expected }) => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data }));
|
||||
|
||||
await expect(call(createApi())).resolves.toEqual(expected);
|
||||
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledTimes(1);
|
||||
const [url, calledMethod, options] = jest.mocked(turtleFetch).mock.calls[0];
|
||||
expect(url).toBe(`https://api.expo.test/v2/device-run-session-worker/${route}`);
|
||||
expect(calledMethod).toBe(method);
|
||||
expect(options.headers).toEqual({ [DEVICE_RUN_SESSION_TOKEN_HEADER]: TOKEN });
|
||||
expect(options.json).toEqual(json);
|
||||
expect(options).toEqual(expect.objectContaining({ retries: 0, shouldThrowOnNotOk: false }));
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['new', 'queued', 'starting', 'in-progress', 'stopped', 'errored'])(
|
||||
'returns the www status %s unchanged',
|
||||
async status => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status } }));
|
||||
|
||||
await expect(createApi().getSessionAsync()).resolves.toEqual({ id: 'drs-id', status });
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects an application response without the build field', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { platform: 'ios' } }));
|
||||
|
||||
await expect(createApi().getApplicationAsync()).rejects.toThrow(
|
||||
'GET /v2/device-run-session-worker/application returned an unexpected response'
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the signal, timeout, and retries to the request', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { iceServers: [] } }));
|
||||
const controller = new AbortController();
|
||||
|
||||
await createApi().fetchTurnIceServersAsync({
|
||||
signal: controller.signal,
|
||||
timeoutMs: 5000,
|
||||
retries: 1,
|
||||
});
|
||||
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'POST',
|
||||
expect.objectContaining({ signal: controller.signal, timeout: 5000, retries: 1 })
|
||||
);
|
||||
});
|
||||
|
||||
it('reports the API v2 error message and status without the token', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{
|
||||
errors: [
|
||||
{
|
||||
code: 'UNAUTHORIZED_ERROR',
|
||||
message: 'This device run session no longer accepts worker requests.',
|
||||
},
|
||||
],
|
||||
},
|
||||
403
|
||||
)
|
||||
);
|
||||
|
||||
const error = await createApi()
|
||||
.stopAsync()
|
||||
.catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
|
||||
expect(error.status).toBe(403);
|
||||
expect(error.message).toBe(
|
||||
'POST /v2/device-run-session-worker/stop failed with status 403: This device run session no longer accepts worker requests.'
|
||||
);
|
||||
expect(error.message).not.toContain(TOKEN);
|
||||
});
|
||||
|
||||
it('reports the status text when the error response is not an API v2 error', async () => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(
|
||||
new Response('<html>bad gateway</html>', { status: 502, statusText: 'Bad Gateway' })
|
||||
);
|
||||
|
||||
const error = await createApi()
|
||||
.getSessionAsync()
|
||||
.catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
|
||||
expect(error.status).toBe(502);
|
||||
expect(error.message).toBe(
|
||||
'GET /v2/device-run-session-worker/session failed with status 502: Bad Gateway'
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a request that failed before www responded', async () => {
|
||||
const reason = new Error('socket hang up');
|
||||
jest.mocked(turtleFetch).mockRejectedValueOnce(reason);
|
||||
|
||||
const error = await createApi()
|
||||
.getSessionAsync()
|
||||
.catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
|
||||
expect(error.status).toBeUndefined();
|
||||
expect(error.cause).toBe(reason);
|
||||
expect(error.message).toContain('failed before www responded: socket hang up');
|
||||
});
|
||||
|
||||
it('rejects a response that does not match the route contract', async () => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'STOPPED' } }));
|
||||
|
||||
await expect(createApi().getSessionAsync()).rejects.toThrow(
|
||||
'GET /v2/device-run-session-worker/session returned an unexpected response'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a successful response that is not JSON', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('ok', { status: 200 }));
|
||||
|
||||
await expect(createApi().getSessionAsync()).rejects.toThrow(
|
||||
'GET /v2/device-run-session-worker/session returned a response that is not JSON.'
|
||||
);
|
||||
});
|
||||
});
|
||||
+599
@@ -0,0 +1,599 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import fetch from 'node-fetch';
|
||||
import { Readable } from 'node:stream';
|
||||
import { setImmediate } from 'node:timers/promises';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import RemoteLoggerStream from '../../../logging/RemoteLoggerStream';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { downloadBuildAsync } from '../../functions/downloadBuild';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
import { startDeviceRunSessionEventCollectionAsync } from '../deviceRunSessionEvents';
|
||||
import { startDeviceRunSessionPreview } from '../deviceRunSessionPreview';
|
||||
import { loadScreenshotSessionAsync } from '../deviceRunSessionScreenshotNames';
|
||||
import {
|
||||
DEVICE_RUN_SESSION_TOKEN_HEADER,
|
||||
DeviceRunSessionWorkerApi,
|
||||
} from '../deviceRunSessionWorkerApi';
|
||||
import {
|
||||
fetchWebPreviewTurnArgsAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../remoteDeviceRunSession';
|
||||
|
||||
jest.mock('node-fetch');
|
||||
jest.mock('@ngrok/ngrok');
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../../logging/HttpLogStream', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => ({ write: jest.fn(), cleanUp: jest.fn(async () => undefined) })),
|
||||
}));
|
||||
jest.mock('../../../logging/RemoteLoggerStream', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => ({
|
||||
init: jest.fn(async () => undefined),
|
||||
write: jest.fn(),
|
||||
cleanUp: jest.fn(async () => undefined),
|
||||
})),
|
||||
}));
|
||||
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
const TOKEN = 'device-run-session-token-value';
|
||||
const ROBOT_ACCESS_TOKEN = 'robot-access-token-value';
|
||||
const API_V2_BASE_URL = 'https://api.expo.test/v2/';
|
||||
const BUILD_ID = '0a8b5f3c-7c38-4a2f-9c0c-2a1f0e7d8b11';
|
||||
const APPLICATION_ARCHIVE_URL = 'https://expo.test/artifacts/eas/app.tar.gz';
|
||||
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
child: jest.fn(),
|
||||
} as unknown as bunyan;
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): InstanceType<typeof Response> {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function createCtx({ withToken }: { withToken: boolean }): CustomBuildContext {
|
||||
return {
|
||||
env: {
|
||||
__API_SERVER_URL: 'https://api.expo.test',
|
||||
EAS_BUILD_ID: 'job-run-id',
|
||||
...(withToken ? { EAS_DEVICE_RUN_SESSION_TOKEN: TOKEN } : {}),
|
||||
},
|
||||
expoApiV2BaseUrl: API_V2_BASE_URL,
|
||||
job: { secrets: { robotAccessToken: ROBOT_ACCESS_TOKEN } },
|
||||
graphqlClient: {
|
||||
query: jest.fn(),
|
||||
mutation: jest.fn(),
|
||||
},
|
||||
} as unknown as CustomBuildContext;
|
||||
}
|
||||
|
||||
function graphqlResult(data: unknown): { toPromise: () => Promise<{ data: unknown }> } {
|
||||
return { toPromise: async () => ({ data }) };
|
||||
}
|
||||
|
||||
function expectWorkerApiCall(
|
||||
route: string,
|
||||
method: 'GET' | 'POST',
|
||||
options: Record<string, unknown> = {}
|
||||
): void {
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
|
||||
`${API_V2_BASE_URL}device-run-session-worker/${route}`,
|
||||
method,
|
||||
expect.objectContaining({
|
||||
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: TOKEN },
|
||||
...options,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForCallAsync(mock: jest.Mock): Promise<void> {
|
||||
for (let i = 0; i < 100 && mock.mock.calls.length === 0; i++) {
|
||||
await setImmediate();
|
||||
}
|
||||
}
|
||||
|
||||
const eventSource = {
|
||||
producer: 'test-producer',
|
||||
findEventFilesAsync: async () => [],
|
||||
sourceKeyForFile: () => 'source',
|
||||
parseLine: () => ({}),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
jest.mocked(fetch).mockReset();
|
||||
});
|
||||
|
||||
describe('with a device run session token', () => {
|
||||
it('polls the session status through the worker API', async () => {
|
||||
const ctx = createCtx({ withToken: true });
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'stopped' } }));
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({ ctx, deviceRunSessionId: 'drs-id', logger });
|
||||
|
||||
expectWorkerApiCall('session', 'GET', {
|
||||
retries: 0,
|
||||
timeout: 30_000,
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(ctx.graphqlClient.query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops an idle session through the worker API', async () => {
|
||||
const ctx = createCtx({ withToken: true });
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'stopped' } }));
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger,
|
||||
idleTimeout: { maxIdleTimeMinutes: 0, getLastEventObservedAt: () => undefined },
|
||||
});
|
||||
|
||||
expectWorkerApiCall('stop', 'POST', { timeout: 30_000 });
|
||||
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts the session through the worker API', async () => {
|
||||
const ctx = createCtx({ withToken: true });
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'in-progress' } }));
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
remoteConfig: { type: 'agent-device' },
|
||||
logger,
|
||||
});
|
||||
|
||||
expectWorkerApiCall('start', 'POST', { json: { remoteConfig: { type: 'agent-device' } } });
|
||||
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails the start with a system error that does not contain the token', async () => {
|
||||
const ctx = createCtx({ withToken: true });
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(jsonResponse({ errors: [{ message: 'The session finished.' }] }, 403));
|
||||
|
||||
const error = await uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
remoteConfig: {},
|
||||
logger,
|
||||
}).catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(SystemError);
|
||||
expect(error.message).toBe(
|
||||
'Failed to start device run session drs-id: POST /v2/device-run-session-worker/start failed with status 403: The session finished.'
|
||||
);
|
||||
expect(error.message).not.toContain(TOKEN);
|
||||
});
|
||||
|
||||
it('fetches TURN ICE servers through the worker API', async () => {
|
||||
const ctx = createCtx({ withToken: true });
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
data: {
|
||||
iceServers: [
|
||||
{ urls: ['stun:stun.test:3478'] },
|
||||
{ urls: ['turn:turn.test:3478'], username: 'user', credential: 'credential' },
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const args = await fetchWebPreviewTurnArgsAsync(ctx, { logger });
|
||||
|
||||
expect(args).toEqual([
|
||||
'--stun-url',
|
||||
'stun:stun.test:3478',
|
||||
'--turn-url',
|
||||
'turn:turn.test:3478',
|
||||
'--turn-username',
|
||||
'user',
|
||||
'--turn-credential',
|
||||
'credential',
|
||||
]);
|
||||
expectWorkerApiCall('turn-ice-servers', 'POST', { timeout: 5000, retries: 1 });
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('creates artifact upload sessions through the worker API', async () => {
|
||||
const ctx = createCtx({ withToken: true });
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
data: { uploadSession: { url: 'https://uploads.expo.test/artifact', headers: {} } },
|
||||
})
|
||||
);
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 200 }));
|
||||
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
artifactId: 'artifact-id',
|
||||
name: 'Report',
|
||||
filename: 'report.json',
|
||||
kind: 'test-report',
|
||||
metadata: { a: 'b' },
|
||||
size: 4,
|
||||
openStream: () => Readable.from(Buffer.from('data')),
|
||||
});
|
||||
|
||||
expectWorkerApiCall('artifact-upload-sessions', 'POST', {
|
||||
json: {
|
||||
name: 'Report',
|
||||
filename: 'report.json',
|
||||
kind: 'test-report',
|
||||
metadata: { a: 'b' },
|
||||
size: 4,
|
||||
},
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
'https://uploads.expo.test/artifact',
|
||||
expect.objectContaining({ method: 'PUT' })
|
||||
);
|
||||
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates the event log upload session through the worker API', async () => {
|
||||
const ctx = createCtx({ withToken: true });
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
data: {
|
||||
uploadSession: { url: 'https://uploads.expo.test/events', headers: { 'X-A': '1' } },
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const collection = await startDeviceRunSessionEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
source: eventSource,
|
||||
logger,
|
||||
});
|
||||
await collection.stopAsync();
|
||||
|
||||
expectWorkerApiCall('event-log-upload-session', 'POST');
|
||||
expect(jest.mocked(RemoteLoggerStream)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
uploadMethod: {
|
||||
signedUrl: { url: 'https://uploads.expo.test/events', headers: { 'X-A': '1' } },
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates the preview upload session through the worker API', async () => {
|
||||
const ctx = createCtx({ withToken: true });
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
data: { uploadSession: { url: 'https://uploads.expo.test/preview', headers: {} } },
|
||||
})
|
||||
);
|
||||
jest.mocked(fetch).mockResolvedValue(new Response('', { status: 200 }));
|
||||
|
||||
const preview = startDeviceRunSessionPreview({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
captureAsync: async () => Buffer.from('webp'),
|
||||
logger,
|
||||
});
|
||||
await waitForCallAsync(jest.mocked(fetch));
|
||||
await preview.stopAsync();
|
||||
|
||||
expectWorkerApiCall('preview-upload-session', 'POST', { signal: expect.any(AbortSignal) });
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
'https://uploads.expo.test/preview',
|
||||
expect.objectContaining({ method: 'PUT' })
|
||||
);
|
||||
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[BuildRuntimePlatform.DARWIN, 'IOS'],
|
||||
[BuildRuntimePlatform.LINUX, 'ANDROID'],
|
||||
] as const)(
|
||||
'loads the screenshot naming details through the worker API on a %s worker',
|
||||
async (runtimePlatform, platform) => {
|
||||
const ctx = { ...createCtx({ withToken: true }), runtimePlatform } as CustomBuildContext;
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
data: {
|
||||
build: {
|
||||
id: BUILD_ID,
|
||||
gitCommitHash: 'abc',
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await expect(loadScreenshotSessionAsync(ctx, 'drs-id', logger)).resolves.toEqual({
|
||||
platform,
|
||||
app: null,
|
||||
build: { id: BUILD_ID, gitCommitHash: 'abc', app: null },
|
||||
});
|
||||
expectWorkerApiCall('application', 'GET', { signal: expect.any(AbortSignal), retries: 0 });
|
||||
expect(ctx.graphqlClient.query).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('downloads the session build from the worker API archive URL without an Authorization header', async () => {
|
||||
const ctx = createCtx({ withToken: true });
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
data: {
|
||||
build: {
|
||||
id: BUILD_ID,
|
||||
gitCommitHash: null,
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response('stop here', { status: 500 }));
|
||||
|
||||
await expect(
|
||||
downloadBuildAsync({
|
||||
logger,
|
||||
buildId: BUILD_ID,
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
deviceRunSessionWorkerApi: new DeviceRunSessionWorkerApi(API_V2_BASE_URL, TOKEN),
|
||||
robotAccessToken: ROBOT_ACCESS_TOKEN,
|
||||
extensions: ['app'],
|
||||
})
|
||||
).rejects.toThrow('Unexpected response from server (500): stop here');
|
||||
|
||||
expectWorkerApiCall('application', 'GET');
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
APPLICATION_ARCHIVE_URL,
|
||||
expect.objectContaining({ headers: undefined })
|
||||
);
|
||||
expect(ctx.graphqlClient.query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses to download a build that is not the session build', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { build: null } }));
|
||||
|
||||
const error = await downloadBuildAsync({
|
||||
logger,
|
||||
buildId: BUILD_ID,
|
||||
graphqlClient: createCtx({ withToken: true }).graphqlClient,
|
||||
deviceRunSessionWorkerApi: new DeviceRunSessionWorkerApi(API_V2_BASE_URL, TOKEN),
|
||||
robotAccessToken: ROBOT_ACCESS_TOKEN,
|
||||
extensions: ['app'],
|
||||
}).catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(SystemError);
|
||||
expect(error.message).toContain(
|
||||
`Could not fetch build ${BUILD_ID}: this device run session runs no build`
|
||||
);
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a rejected build lookup as a system error, because the job sends no user input', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{
|
||||
errors: [
|
||||
{
|
||||
code: 'UNAUTHORIZED_ERROR',
|
||||
message: 'This device run session no longer accepts worker requests.',
|
||||
},
|
||||
],
|
||||
},
|
||||
403
|
||||
)
|
||||
);
|
||||
|
||||
const error = await downloadBuildAsync({
|
||||
logger,
|
||||
buildId: BUILD_ID,
|
||||
graphqlClient: createCtx({ withToken: true }).graphqlClient,
|
||||
deviceRunSessionWorkerApi: new DeviceRunSessionWorkerApi(API_V2_BASE_URL, TOKEN),
|
||||
robotAccessToken: ROBOT_ACCESS_TOKEN,
|
||||
extensions: ['app'],
|
||||
}).catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(SystemError);
|
||||
expect(error.message).toContain(
|
||||
`Could not fetch build ${BUILD_ID} for this device run session: GET /v2/device-run-session-worker/application failed with status 403: This device run session no longer accepts worker requests.`
|
||||
);
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('without a device run session token', () => {
|
||||
const MISSING_TOKEN_MESSAGE =
|
||||
/^This device run session job has no EAS_DEVICE_RUN_SESSION_TOKEN secret\./;
|
||||
|
||||
function expectMissingTokenError(error: unknown): void {
|
||||
expect(error).toBeInstanceOf(SystemError);
|
||||
expect((error as Error).message).toMatch(MISSING_TOKEN_MESSAGE);
|
||||
}
|
||||
|
||||
function expectMissingTokenWarning(message: string): void {
|
||||
const warning = (jest.mocked(logger.warn).mock.calls as unknown[][]).find(
|
||||
([, warningMessage]) => warningMessage === message
|
||||
);
|
||||
expect(warning).toBeDefined();
|
||||
expectMissingTokenError((warning![0] as { err: unknown }).err);
|
||||
}
|
||||
|
||||
it('fails the status poll with a system error', async () => {
|
||||
const ctx = createCtx({ withToken: false });
|
||||
|
||||
const error = await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger,
|
||||
}).catch(err => err);
|
||||
|
||||
expectMissingTokenError(error);
|
||||
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
|
||||
expect(ctx.graphqlClient.query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs the failed idle stop and ends the wait', async () => {
|
||||
const ctx = createCtx({ withToken: false });
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger,
|
||||
idleTimeout: { maxIdleTimeMinutes: 0, getLastEventObservedAt: () => undefined },
|
||||
});
|
||||
|
||||
expectMissingTokenWarning(
|
||||
'Could not mark device run session drs-id as stopped. The session job ends anyway.'
|
||||
);
|
||||
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
|
||||
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails the start with a system error', async () => {
|
||||
const ctx = createCtx({ withToken: false });
|
||||
|
||||
const error = await uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
remoteConfig: { type: 'agent-device' },
|
||||
logger,
|
||||
}).catch(err => err);
|
||||
|
||||
expectMissingTokenError(error);
|
||||
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
|
||||
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to no TURN ICE servers', async () => {
|
||||
const ctx = createCtx({ withToken: false });
|
||||
|
||||
await expect(fetchWebPreviewTurnArgsAsync(ctx, { logger })).resolves.toEqual([]);
|
||||
|
||||
expectMissingTokenWarning(
|
||||
'Could not fetch Cloudflare TURN ICE servers; the web preview will fall back to P2P/STUN.'
|
||||
);
|
||||
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails the artifact upload with a system error before any PUT', async () => {
|
||||
const ctx = createCtx({ withToken: false });
|
||||
|
||||
const error = await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
artifactId: 'artifact-id',
|
||||
name: 'Report',
|
||||
filename: 'report.json',
|
||||
kind: undefined,
|
||||
size: 4,
|
||||
openStream: () => Readable.from(Buffer.from('data')),
|
||||
}).catch(err => err);
|
||||
|
||||
expectMissingTokenError(error);
|
||||
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs the session without persisting events', async () => {
|
||||
const ctx = createCtx({ withToken: false });
|
||||
|
||||
const collection = await startDeviceRunSessionEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
source: eventSource,
|
||||
logger,
|
||||
});
|
||||
await collection.stopAsync();
|
||||
|
||||
expectMissingTokenWarning('Could not persist device run session events to the artifact.');
|
||||
expect(jest.mocked(RemoteLoggerStream)).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
|
||||
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs the failed preview upload session and uploads no preview', async () => {
|
||||
const ctx = createCtx({ withToken: false });
|
||||
|
||||
const preview = startDeviceRunSessionPreview({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
captureAsync: async () => Buffer.from('webp'),
|
||||
logger,
|
||||
});
|
||||
await waitForCallAsync(jest.mocked(logger.warn));
|
||||
await preview.stopAsync();
|
||||
|
||||
expectMissingTokenWarning('Could not refresh the session preview; retrying in 60 seconds.');
|
||||
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('names screenshots with capture timestamps', async () => {
|
||||
const ctx = createCtx({ withToken: false });
|
||||
|
||||
await expect(loadScreenshotSessionAsync(ctx, 'drs-id', logger)).resolves.toBeNull();
|
||||
|
||||
expectMissingTokenWarning(
|
||||
'Could not load screenshot session details; using capture timestamps.'
|
||||
);
|
||||
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
|
||||
expect(ctx.graphqlClient.query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('looks up the build archive URL with GraphQL', async () => {
|
||||
const ctx = createCtx({ withToken: false });
|
||||
jest.mocked(ctx.graphqlClient.query).mockReturnValueOnce(
|
||||
graphqlResult({
|
||||
builds: {
|
||||
byId: {
|
||||
id: BUILD_ID,
|
||||
platform: 'IOS',
|
||||
artifacts: { applicationArchiveUrl: APPLICATION_ARCHIVE_URL },
|
||||
},
|
||||
},
|
||||
}) as never
|
||||
);
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response('stop here', { status: 500 }));
|
||||
|
||||
await expect(
|
||||
downloadBuildAsync({
|
||||
logger,
|
||||
buildId: BUILD_ID,
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
deviceRunSessionWorkerApi: null,
|
||||
robotAccessToken: ROBOT_ACCESS_TOKEN,
|
||||
extensions: ['app'],
|
||||
})
|
||||
).rejects.toThrow('Unexpected response from server (500)');
|
||||
|
||||
expect(ctx.graphqlClient.query).toHaveBeenCalled();
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
APPLICATION_ARCHIVE_URL,
|
||||
expect.objectContaining({ headers: { Authorization: `Bearer ${ROBOT_ACCESS_TOKEN}` } })
|
||||
);
|
||||
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 417 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user