mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f95ca50d9 | ||
|
|
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 |
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,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 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
Add experimentation client.
|
||||
@@ -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': minor
|
||||
---
|
||||
|
||||
Add a Free plan build limit guidance experiment for coding agents to `eas build`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': minor
|
||||
---
|
||||
|
||||
Let serve-sim boot, install and launch agent-device iOS sessions with the local-egress guard.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
Add `eas simulator:simctl` to set the appearance of a remote iOS simulator session and restart its SpringBoard.
|
||||
@@ -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
|
||||
@@ -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
|
||||
+51
-1
@@ -2,10 +2,60 @@
|
||||
|
||||
This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
## main
|
||||
## [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))
|
||||
|
||||
@@ -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.13.0",
|
||||
"PRODUCTION": "24.13.0"
|
||||
"STAGING": "25.1.1",
|
||||
"PRODUCTION": "25.1.1"
|
||||
}
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "24.14.0",
|
||||
"version": "25.1.1",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -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,12 +16,14 @@
|
||||
"lint": "oxlint --type-aware --import-plugin --max-warnings=0 --tsconfig ./tsconfig.oxlint.json .",
|
||||
"fmt": "oxfmt .",
|
||||
"fmt:check": "oxfmt --check .",
|
||||
"lint-changelog": "./scripts/bin/run lint-changelog",
|
||||
"release": "lerna run rebuild --concurrency 1 && ./scripts/bin/run release",
|
||||
"test": "lerna run test",
|
||||
"clean": "lerna run clean && rimraf node_modules coverage"
|
||||
"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": "22.20.5",
|
||||
"eslint-plugin-async-protect": "3.1.0",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# @expo/build-tools
|
||||
|
||||
## 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.14.0",
|
||||
"version": "25.1.1",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -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.13.0",
|
||||
"@expo/eas-build-job": "24.13.0",
|
||||
"@expo/downloader": "25.1.1",
|
||||
"@expo/eas-build-job": "25.1.1",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "24.13.0",
|
||||
"@expo/logger": "25.1.1",
|
||||
"@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.13.0",
|
||||
"@expo/template-file": "24.13.0",
|
||||
"@expo/turtle-spawn": "24.13.0",
|
||||
"@expo/steps": "25.1.1",
|
||||
"@expo/template-file": "25.1.1",
|
||||
"@expo/turtle-spawn": "25.1.1",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
|
||||
@@ -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 {
|
||||
@@ -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';
|
||||
+69
-5
@@ -108,7 +108,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 +121,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 +143,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 +162,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;
|
||||
|
||||
@@ -591,6 +591,100 @@ 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 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 new ActualResponse(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
build: {
|
||||
id: buildId,
|
||||
gitCommitHash: null,
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
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('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({
|
||||
|
||||
+26
-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',
|
||||
|
||||
@@ -25,6 +25,7 @@ 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' },
|
||||
@@ -115,6 +116,19 @@ describe(createRunAgentBuildFunction, () => {
|
||||
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'],
|
||||
@@ -162,6 +176,7 @@ describe(createRunAgentBuildFunction, () => {
|
||||
['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);
|
||||
|
||||
|
||||
+342
-24
@@ -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,13 @@ 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
|
||||
): Promise<void> {
|
||||
await runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env: {},
|
||||
@@ -88,8 +91,13 @@ async function runAsync(
|
||||
maxIdleTimeMinutes: undefined,
|
||||
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 +127,23 @@ 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(),
|
||||
}),
|
||||
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 +163,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 +181,27 @@ 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('hands network capture to serve-sim', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
|
||||
@@ -177,6 +216,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 +302,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 +317,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 +347,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
maxDurationSeconds: undefined,
|
||||
capture: NO_CAPTURE,
|
||||
tasks,
|
||||
device: typeof device === 'function' ? device(tasks) : device,
|
||||
device: sessionDevice,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -292,7 +357,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 +393,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 +633,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 +651,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 +667,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({
|
||||
|
||||
+395
-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,78 @@ 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('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,
|
||||
|
||||
+156
-6
@@ -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,15 @@ 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 { sleepAsync } from '../../../utils/retry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { startAppiumEventCollectionAsync } from '../../utils/appiumEvents';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import {
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
|
||||
import {
|
||||
createStartAppiumRemoteSessionBuildFunction,
|
||||
@@ -24,8 +32,26 @@ jest.mock('../../../utils/IosSimulatorUtils', () => ({
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
selectXcodeDeveloperDirectoryAsync: 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/localEgressSession', () => ({
|
||||
...jest.requireActual('../../utils/localEgressSession'),
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as never;
|
||||
|
||||
@@ -51,9 +77,12 @@ 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({
|
||||
@@ -66,6 +95,7 @@ describe(resolveAppiumDeviceAsync, () => {
|
||||
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({
|
||||
@@ -164,6 +194,126 @@ 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(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(),
|
||||
}),
|
||||
finishAsync: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function runSessionAsync(logger: { info: jest.Mock; warn: jest.Mock }): Promise<void> {
|
||||
await createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {
|
||||
DEVICE_RUN_SESSION_ID: '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('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('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);
|
||||
expectAppiumStoppedAndHomeRemoved(logger);
|
||||
expect(stopEventCollection.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
stopAppium.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
});
|
||||
+248
-5
@@ -6,10 +6,12 @@ import path from 'node:path';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import { isProcessDescendantOfAsync } from '../../../utils/processes';
|
||||
import { pollArgentArtifactsForUploadAsync } from '../../utils/argentArtifacts';
|
||||
import { startArgentEventCollectionAsync } from '../../utils/argentEvents';
|
||||
import { createProcessOutput } from '../../utils/processOutput';
|
||||
import {
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
@@ -119,18 +121,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 +150,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 => {
|
||||
@@ -188,6 +248,59 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
}
|
||||
);
|
||||
|
||||
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);
|
||||
@@ -268,7 +381,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({
|
||||
@@ -281,6 +394,82 @@ 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
|
||||
@@ -318,6 +507,52 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
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() };
|
||||
@@ -390,7 +625,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;
|
||||
|
||||
+161
-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,6 +77,7 @@ 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(),
|
||||
}),
|
||||
@@ -73,6 +92,7 @@ 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(),
|
||||
@@ -95,6 +115,54 @@ 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(),
|
||||
}),
|
||||
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(),
|
||||
}),
|
||||
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') {
|
||||
@@ -115,21 +183,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 +253,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 +288,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 },
|
||||
|
||||
@@ -15,6 +15,11 @@ import {
|
||||
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');
|
||||
@@ -174,7 +179,7 @@ it('preserves the original failure when cleanup fails', async () => {
|
||||
api()
|
||||
.put(`${editPath}/tracks/internal`)
|
||||
.reply(400, { error: { message: 'unknown track' } });
|
||||
api().delete(editPath).reply(500);
|
||||
api().delete(editPath).times(6).reply(500);
|
||||
await expect(submission().run()).rejects.toMatchObject({
|
||||
cause: expect.objectContaining({ apiMessage: 'unknown track' }),
|
||||
});
|
||||
@@ -183,6 +188,7 @@ it('does not delete the edit or publish outputs when commit outcome is unknown',
|
||||
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)
|
||||
@@ -220,3 +226,32 @@ it('preserves the cause when the service-account file cannot be read', async ()
|
||||
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...',
|
||||
]);
|
||||
});
|
||||
@@ -28,6 +28,10 @@ import { decompressTarAsync, isFileTarGzAsync } from '../../utils/files';
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
|
||||
import { pluralize } from '../../utils/strings';
|
||||
import {
|
||||
type DeviceRunSessionWorkerApi,
|
||||
getDeviceRunSessionWorkerApi,
|
||||
} from '../utils/deviceRunSessionWorkerApi';
|
||||
|
||||
const streamPipeline = promisify(stream.pipeline);
|
||||
|
||||
@@ -118,6 +122,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,13 +136,22 @@ 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();
|
||||
if (deviceRunSessionWorkerApi) {
|
||||
return await fetchDeviceRunSessionApplicationArchiveUrlAsync({
|
||||
buildId,
|
||||
deviceRunSessionWorkerApi,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
const result = await graphqlClient
|
||||
.query(BUILD_BY_ID_QUERY, { buildId }, graphqlAbortContext(signal))
|
||||
.toPromise();
|
||||
@@ -163,17 +177,71 @@ 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 deviceRunSessionWorkerApi.getApplicationAsync({ signal });
|
||||
} catch (err) {
|
||||
signal?.throwIfAborted();
|
||||
throw new SystemError(
|
||||
`Could not fetch build ${buildId} for this device run session: ${
|
||||
err instanceof Error ? err.message : String(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 +256,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',
|
||||
|
||||
@@ -96,6 +96,9 @@ export function createRunAgentBuildFunction(ctx: CustomBuildContext): BuildFunct
|
||||
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');
|
||||
@@ -120,6 +123,7 @@ export function createRunAgentBuildFunction(ctx: CustomBuildContext): BuildFunct
|
||||
pathEnv,
|
||||
npmCacheUrl,
|
||||
logger: stepCtx.logger,
|
||||
addBuildLogMask: ctx.runtimeApi.addBuildLogMask,
|
||||
signal: signal
|
||||
? AbortSignal.any([signal, timeoutController.signal])
|
||||
: timeoutController.signal,
|
||||
|
||||
@@ -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}`,
|
||||
@@ -232,7 +275,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 +310,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 +329,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 +345,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 +386,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 +423,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 +456,8 @@ async function startAgentDeviceDaemonFromGitAsync({
|
||||
args: ['run', 'src/daemon.ts'],
|
||||
cwd: SRC_DIR,
|
||||
env: { ...env, ...AGENT_DEVICE_DAEMON_ENV },
|
||||
logger,
|
||||
secrets,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,9 @@ 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 });
|
||||
}
|
||||
const deviceIdentifier = inputs.device_identifier.value as string | undefined;
|
||||
const capture = parseNetworkCaptureInputs(
|
||||
{
|
||||
@@ -164,104 +174,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]);
|
||||
}
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -35,7 +35,12 @@ import {
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
|
||||
import { preparePreinstalledWdaAsync } from '../utils/preinstalledWda';
|
||||
import {
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createPrivatePreviewUrlInputProvider,
|
||||
createPublicPreviewInputProvider,
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
@@ -43,6 +48,9 @@ import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
omitNgrokAuthtoken,
|
||||
parseCorsOriginsInput,
|
||||
parseFrameAncestorsInput,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
@@ -71,6 +79,10 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
...createNetworkCaptureInputProviders(),
|
||||
createPublicPreviewInputProvider(),
|
||||
createPrivatePreviewUrlInputProvider(),
|
||||
createFrameAncestorsInputProvider(),
|
||||
createCorsOriginsInputProvider(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -82,10 +94,11 @@ 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;
|
||||
@@ -121,6 +134,13 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger }),
|
||||
]);
|
||||
const { appiumHome, appiumBinPath, appiumEnv } = appiumInstall.enforceValue();
|
||||
const wdaCapabilities = await preparePreinstalledWdaAsync({
|
||||
runtimeIdentifier: device.runtimeIdentifier,
|
||||
appiumHome,
|
||||
appiumBinPath,
|
||||
appiumEnv,
|
||||
logger,
|
||||
});
|
||||
|
||||
const appiumProcess = spawnDetached({
|
||||
command: appiumBinPath,
|
||||
@@ -139,28 +159,23 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
'--allow-insecure',
|
||||
'*:session_discovery',
|
||||
'--default-capabilities',
|
||||
JSON.stringify({ 'appium:eventTimings': true }),
|
||||
JSON.stringify({ 'appium:eventTimings': true, ...wdaCapabilities }),
|
||||
],
|
||||
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 eventCollection: Awaited<ReturnType<typeof startAppiumEventCollectionAsync>> | undefined;
|
||||
let appiumTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let sessionHost: DeviceSessionHost | undefined;
|
||||
let sessionFailed = false;
|
||||
try {
|
||||
await waitForAppiumReadyAsync({ appiumProcess, logger });
|
||||
eventCollection = await startAppiumEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
appiumUrl: `http://${APPIUM_HOST}:${APPIUM_PORT}/`,
|
||||
logger,
|
||||
});
|
||||
appiumTunnel = await startNgrokTunnelAsync({
|
||||
port: APPIUM_PORT,
|
||||
subdomainPrefix: 'appium',
|
||||
@@ -176,17 +191,25 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
sessionHost = await startDeviceSessionHostAsync(ctx, {
|
||||
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),
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
});
|
||||
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
|
||||
const webPreview = await sessionHost.openPreviewAsync({
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
});
|
||||
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
@@ -233,10 +256,13 @@ 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 {
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
})(),
|
||||
],
|
||||
@@ -264,6 +290,7 @@ export type AppiumDevice = {
|
||||
automationName: 'XCUITest' | 'UiAutomator2';
|
||||
driverName: 'xcuitest' | 'uiautomator2';
|
||||
udid: string;
|
||||
runtimeIdentifier?: string;
|
||||
};
|
||||
|
||||
export async function resolveAppiumDeviceAsync({
|
||||
@@ -290,6 +317,7 @@ export async function resolveAppiumDeviceAsync({
|
||||
automationName: 'XCUITest',
|
||||
driverName: 'xcuitest',
|
||||
udid: bootedDevice.udid,
|
||||
runtimeIdentifier: bootedDevice.runtime,
|
||||
};
|
||||
}
|
||||
case BuildRuntimePlatform.LINUX: {
|
||||
|
||||
@@ -34,6 +34,10 @@ import { sleepAsync } from '../../utils/retry';
|
||||
import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts';
|
||||
import { ARGENT_EVENT_LOG_FILENAME, startArgentEventCollectionAsync } from '../utils/argentEvents';
|
||||
import {
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createPrivatePreviewUrlInputProvider,
|
||||
createPublicPreviewInputProvider,
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
@@ -41,6 +45,9 @@ import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
omitNgrokAuthtoken,
|
||||
parseCorsOriginsInput,
|
||||
parseFrameAncestorsInput,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
@@ -84,6 +91,10 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
...createNetworkCaptureInputProviders(),
|
||||
createPublicPreviewInputProvider(),
|
||||
createPrivatePreviewUrlInputProvider(),
|
||||
createFrameAncestorsInputProvider(),
|
||||
createCorsOriginsInputProvider(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -100,14 +111,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).
|
||||
@@ -197,10 +209,13 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
);
|
||||
// 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[] = [];
|
||||
const 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(
|
||||
@@ -208,50 +223,55 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
);
|
||||
}
|
||||
|
||||
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 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 sessionHost: DeviceSessionHost | undefined;
|
||||
let sessionFailed = false;
|
||||
try {
|
||||
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;
|
||||
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}` : ''}`
|
||||
);
|
||||
}
|
||||
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',
|
||||
@@ -268,19 +288,29 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
sessionHost = await startDeviceSessionHostAsync(ctx, {
|
||||
separateLogPhase: true,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 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),
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
});
|
||||
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
|
||||
const webPreview = await sessionHost.openPreviewAsync({
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
});
|
||||
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({
|
||||
@@ -325,11 +355,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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -131,7 +131,7 @@ export function createSubmitToGooglePlayBuildFunction(): BuildFunction {
|
||||
keyPath: path.resolve(ctx.workingDirectory, submission.service_account_key_path),
|
||||
});
|
||||
const artifactPath = path.resolve(ctx.workingDirectory, submission.artifact_path);
|
||||
const client = new GooglePlayClient(credentials);
|
||||
const client = new GooglePlayClient(credentials, ctx.logger);
|
||||
const versionCode = await submitToGooglePlayAsync({
|
||||
artifactType: submission.artifact_type,
|
||||
client,
|
||||
@@ -185,7 +185,19 @@ async function submitToGooglePlayAsync({
|
||||
`Submitting ${packageName} (${artifactType}) to track ${JSON.stringify(submission.track)}.`
|
||||
);
|
||||
logger.info(
|
||||
`Requested status: ${submission.release_status}; rollout: ${submission.rollout ?? 'none'}. Effective status: ${release.status}; rollout: ${release.userFraction ?? 'none'}. changes_not_sent_for_review: ${submission.changes_not_sent_for_review}.`
|
||||
`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)}`);
|
||||
@@ -216,7 +228,8 @@ async function submitToGooglePlayAsync({
|
||||
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(`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,
|
||||
@@ -232,7 +245,7 @@ async function submitToGooglePlayAsync({
|
||||
return versionCode;
|
||||
} catch (error) {
|
||||
commitOutcomeUnknown = error instanceof GooglePlayCommitOutcomeUnknownError;
|
||||
throw GooglePlayUtils.mapGooglePlayError(error);
|
||||
throw GooglePlayUtils.mapGooglePlayError(error, submission.package_name);
|
||||
} finally {
|
||||
if (editId && !committed && !commitOutcomeUnknown) {
|
||||
try {
|
||||
@@ -243,7 +256,7 @@ async function submitToGooglePlayAsync({
|
||||
});
|
||||
} catch {
|
||||
logger.warn(
|
||||
'Could not delete the Google Play edit after failure. The original submission error is preserved.'
|
||||
'Could not discard the pending Google Play changes. Check the release in Play Console before submitting again.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -140,6 +140,7 @@ function createOptions(
|
||||
pathEnv: '/usr/local/bin:/usr/bin',
|
||||
npmCacheUrl: 'https://npm-cache.expo.test',
|
||||
logger: createMockLogger(),
|
||||
addBuildLogMask: jest.fn(),
|
||||
signal: new AbortController().signal,
|
||||
...overrides,
|
||||
};
|
||||
@@ -220,23 +221,22 @@ describe(runAgentAsync, () => {
|
||||
expect(agent.stdin).toHaveBeenCalledWith('Fix the failing test.');
|
||||
});
|
||||
|
||||
it('writes the agent output as log lines with the leased credentials masked', async () => {
|
||||
it('writes the agent output as log lines', async () => {
|
||||
leaseMock.mockResolvedValue(anthropicLease);
|
||||
const logger = createMockLogger();
|
||||
mockSpawn({
|
||||
onAgent: agent => {
|
||||
// One JSON event split across two chunks, in the middle of the token.
|
||||
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":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"token: anthropic-acc'
|
||||
);
|
||||
agent.child.stdout.write('ess-token"}]}}\n');
|
||||
agent.child.stdout.write(
|
||||
'{"type":"assistant","message":{"content":[{"type":"text","text":"Done.\\nThe token was anthropic-access-token.\\n"}]}}\n'
|
||||
'{"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: anthropic-access-token\n');
|
||||
agent.child.stderr.write('warning: deprecated flag\n');
|
||||
agent.exit(0);
|
||||
},
|
||||
});
|
||||
@@ -247,11 +247,54 @@ describe(runAgentAsync, () => {
|
||||
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: token: [redacted]'],
|
||||
['Agent: Done.\nThe token was [redacted].'],
|
||||
['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: [redacted]']]);
|
||||
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 () => {
|
||||
@@ -427,12 +470,7 @@ describe(runAgentAsync, () => {
|
||||
leaseMock.mockResolvedValueOnce(anthropicLease).mockResolvedValueOnce(renewedLease);
|
||||
const logger = createMockLogger();
|
||||
const invocations = mockSpawn({
|
||||
onAgent: agent => {
|
||||
if (invocations.length === 2) {
|
||||
agent.child.stderr.write('tokens: anthropic-access-token renewed-access-token\n');
|
||||
}
|
||||
agent.exit(invocations.length === 1 ? 1 : 0);
|
||||
},
|
||||
onAgent: agent => agent.exit(invocations.length === 1 ? 1 : 0),
|
||||
});
|
||||
|
||||
await runAgentAsync(createOptions({ logger }));
|
||||
@@ -452,8 +490,6 @@ describe(runAgentAsync, () => {
|
||||
[{ source: 'stdout' }],
|
||||
[{ source: 'stderr' }],
|
||||
]);
|
||||
const [, , , resumedStderrLogger] = child.mock.results.map(result => result.value);
|
||||
expect(resumedStderrLogger.info.mock.calls).toEqual([['tokens: [redacted] [redacted]']]);
|
||||
});
|
||||
|
||||
it('resumes Codex with new credentials when the lease changed during the run', async () => {
|
||||
|
||||
@@ -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)',
|
||||
@@ -52,19 +62,20 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
|
||||
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,26 +88,19 @@ 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',
|
||||
@@ -108,18 +112,8 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
|
||||
})
|
||||
).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([]);
|
||||
});
|
||||
});
|
||||
+10
-18
@@ -1,6 +1,5 @@
|
||||
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 { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
|
||||
@@ -25,28 +24,23 @@ 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: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
JSON.stringify({ data: { uploadSession: { url: `${url}/artifact`, headers: {} } } })
|
||||
);
|
||||
});
|
||||
} else {
|
||||
@@ -60,12 +54,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 () => {
|
||||
@@ -181,8 +172,9 @@ it('recovers unlisted recordings from a killed host and flags the unfinished one
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
expect(putBytes).toBe(3 * (await stat(path.join(cut, 'recording.mp4.partial'))).size);
|
||||
expect(creationTokens).toEqual(['session-token', 'session-token', 'session-token']);
|
||||
const inputs = creationBodies
|
||||
.map(body => JSON.parse(body).variables.input)
|
||||
.map(body => JSON.parse(body))
|
||||
.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]);
|
||||
|
||||
+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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
+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 () => {
|
||||
@@ -126,7 +117,7 @@ it('replays the full file after a transient PUT failure without recreating the u
|
||||
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);
|
||||
@@ -281,7 +272,7 @@ 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;
|
||||
@@ -298,7 +289,7 @@ 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(openStream).not.toHaveBeenCalled();
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
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 () => {
|
||||
jest.mocked(turtleFetch).mockRejectedValueOnce(new Error('socket hang up'));
|
||||
|
||||
const error = await createApi()
|
||||
.getSessionAsync()
|
||||
.catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
|
||||
expect(error.status).toBeUndefined();
|
||||
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
@@ -0,0 +1,63 @@
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { readIosApplicationIdentifierAsync } from '../iosAppArtifact';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
|
||||
describe(readIosApplicationIdentifierAsync, () => {
|
||||
let directory: string;
|
||||
let artifactPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.mocked(spawn).mockReset();
|
||||
directory = await mkdtemp(path.join(os.tmpdir(), 'ios-app-artifact-'));
|
||||
artifactPath = path.join(directory, 'Example.app');
|
||||
await mkdir(artifactPath);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reads app metadata without installing or launching', async () => {
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: 'com.example.app\n', stderr: '' } as never);
|
||||
await expect(readIosApplicationIdentifierAsync({ artifactPath, env: {} })).resolves.toBe(
|
||||
'com.example.app'
|
||||
);
|
||||
expect(jest.mocked(spawn).mock.calls).toEqual([
|
||||
[
|
||||
'plutil',
|
||||
['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', path.join(artifactPath, 'Info.plist')],
|
||||
{ stdio: 'pipe', env: {} },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(['missing', 'file', 'wrong extension'])('rejects an invalid artifact: %s', async kind => {
|
||||
await rm(artifactPath, { recursive: true });
|
||||
if (kind === 'file') {
|
||||
await writeFile(artifactPath, 'not an app directory');
|
||||
} else if (kind === 'wrong extension') {
|
||||
artifactPath = path.join(directory, 'Example.ipa');
|
||||
await mkdir(artifactPath);
|
||||
}
|
||||
await expect(
|
||||
readIosApplicationIdentifierAsync({ artifactPath, env: {} })
|
||||
).rejects.toMatchObject({ errorCode: 'EAS_INSTALL_BUILD_INVALID_ARTIFACT' });
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a missing bundle identifier', async () => {
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: ' ', stderr: '' } as never);
|
||||
await expect(
|
||||
readIosApplicationIdentifierAsync({ artifactPath, env: {} })
|
||||
).rejects.toMatchObject({ errorCode: 'EAS_INSTALL_BUILD_MISSING_IDENTIFIER' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../../utils/IosSimulatorUtils';
|
||||
import { resolveIosSessionStartupAsync } from '../iosSimulatorSession';
|
||||
import { readIosApplicationIdentifierAsync } from '../iosAppArtifact';
|
||||
import {
|
||||
resolveLocalEgressServeSimBootEnvironmentAsync,
|
||||
startLocalEgressGuardRelayAsync,
|
||||
} from '../localEgressGuard';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.mock('../../../utils/IosSimulatorUtils');
|
||||
jest.mock('../iosAppArtifact', () => ({ readIosApplicationIdentifierAsync: jest.fn() }));
|
||||
jest.mock('../localEgressGuard', () => ({
|
||||
resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn(),
|
||||
startLocalEgressGuardRelayAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedUtils = jest.mocked(IosSimulatorUtils);
|
||||
|
||||
describe('serve-sim Simulator preparation', () => {
|
||||
const hostOptions = {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
};
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockedUtils.getAvailableDevicesAsync.mockResolvedValue([
|
||||
{
|
||||
udid: 'selected-udid',
|
||||
name: 'iPhone 17',
|
||||
isAvailable: true,
|
||||
deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-17',
|
||||
},
|
||||
] as never);
|
||||
mockedUtils.resolveUdidAsync.mockResolvedValue('selected-udid' as IosSimulatorUuid);
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(null);
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as never);
|
||||
jest.mocked(readIosApplicationIdentifierAsync).mockResolvedValue('dev.example.app');
|
||||
});
|
||||
it.each([
|
||||
['argument count', Array(257).fill('a')],
|
||||
['argument length', ['a'.repeat(9000)]],
|
||||
['NUL argument', ['a\u0000b']],
|
||||
['total UTF-8 bytes', Array(8).fill('😃'.repeat(4096))],
|
||||
])('rejects invalid launch %s before Simulator preparation', async (_, launchArgs) => {
|
||||
await expect(
|
||||
resolveIosSessionStartupAsync({
|
||||
...hostOptions,
|
||||
bootSimulator: true,
|
||||
launchAppIdentifier: 'dev.example.app',
|
||||
launchArgs,
|
||||
})
|
||||
).rejects.toThrow('iOS launch_args');
|
||||
expect(mockedUtils.getAvailableDevicesAsync).not.toHaveBeenCalled();
|
||||
expect(mockedUtils.resolveUdidAsync).not.toHaveBeenCalled();
|
||||
expect(resolveLocalEgressServeSimBootEnvironmentAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an oversized URL', `https://example.test/${'a'.repeat(8192)}`],
|
||||
['NUL in a URL', 'exp://example.test/a\0b'],
|
||||
])('rejects %s before Simulator preparation', async (_, openUrl) => {
|
||||
await expect(
|
||||
resolveIosSessionStartupAsync({
|
||||
...hostOptions,
|
||||
bootSimulator: true,
|
||||
launchAppIdentifier: 'dev.example.app',
|
||||
openUrl,
|
||||
})
|
||||
).rejects.toThrow('iOS open_url');
|
||||
expect(mockedUtils.getAvailableDevicesAsync).not.toHaveBeenCalled();
|
||||
expect(mockedUtils.resolveUdidAsync).not.toHaveBeenCalled();
|
||||
expect(resolveLocalEgressServeSimBootEnvironmentAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps an explicit launch identifier independent of the installed app', async () => {
|
||||
const startup = await resolveIosSessionStartupAsync({
|
||||
...hostOptions,
|
||||
installAppPath: '/tmp/App.app',
|
||||
launchAppIdentifier: 'another.installed.app',
|
||||
});
|
||||
expect(startup.launch.launchAppIdentifier).toBe('another.installed.app');
|
||||
expect(readIosApplicationIdentifierAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('infers the launch identifier from the downloaded app', async () => {
|
||||
const startup = await resolveIosSessionStartupAsync({
|
||||
...hostOptions,
|
||||
installAppPath: '/tmp/App.app',
|
||||
});
|
||||
expect(startup.launch.launchAppIdentifier).toBe('dev.example.app');
|
||||
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({
|
||||
artifactPath: '/tmp/App.app',
|
||||
env: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('does no Simulator work on Android and rejects iOS installation inputs', async () => {
|
||||
const options = { ...hostOptions, runtimePlatform: BuildRuntimePlatform.LINUX };
|
||||
await expect(resolveIosSessionStartupAsync(options)).resolves.toEqual({
|
||||
launch: { launchArgs: [], launchAppIdentifier: undefined, openUrl: undefined },
|
||||
});
|
||||
await expect(
|
||||
resolveIosSessionStartupAsync({ ...options, installAppPath: '/tmp/App.app' })
|
||||
).rejects.toThrow('only supported for iOS');
|
||||
expect(mockedUtils.resolveUdidAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves older workflows that already prepared the Simulator and app', async () => {
|
||||
const startup = await resolveIosSessionStartupAsync({
|
||||
...hostOptions,
|
||||
launchAppIdentifier: 'previously.installed.app',
|
||||
});
|
||||
expect(startup.iosStartup).toBeUndefined();
|
||||
expect(startup.launch.launchAppIdentifier).toBe('previously.installed.app');
|
||||
expect(mockedUtils.resolveUdidAsync).not.toHaveBeenCalled();
|
||||
expect(resolveLocalEgressServeSimBootEnvironmentAsync).not.toHaveBeenCalled();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prepares a session without an app when the workflow requests owned startup', async () => {
|
||||
const startup = await resolveIosSessionStartupAsync({ ...hostOptions, bootSimulator: true });
|
||||
expect(startup.iosStartup?.iosSimulatorUdid).toBe('SELECTED-UDID');
|
||||
expect(startup.launch.launchAppIdentifier).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(['Booted', 'Booting', 'Shutdown'])(
|
||||
'prepares guarded startup on a %s device',
|
||||
async state => {
|
||||
const bootEnv = { SERVE_SIM_ADDITIONAL_DYLIBS: '/guard.dylib' };
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(bootEnv);
|
||||
mockedUtils.getDeviceAsync.mockResolvedValue({ state } as never);
|
||||
const startup = await resolveIosSessionStartupAsync({ ...hostOptions, bootSimulator: true });
|
||||
expect(startup.iosStartup).toEqual({
|
||||
iosSimulatorUdid: 'SELECTED-UDID',
|
||||
bootEnv,
|
||||
installAppPath: undefined,
|
||||
});
|
||||
if (state === 'Shutdown') {
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
['simctl', 'shutdown', 'SELECTED-UDID'],
|
||||
expect.anything()
|
||||
);
|
||||
}
|
||||
expect(startLocalEgressGuardRelayAsync).toHaveBeenCalled();
|
||||
expect(mockedUtils.bootAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('resolves an explicit Simulator without booting or configuring it', async () => {
|
||||
const startup = await resolveIosSessionStartupAsync({
|
||||
...hostOptions,
|
||||
deviceIdentifier: 'iPhone 17',
|
||||
});
|
||||
expect(startup.iosStartup?.iosSimulatorUdid).toBe('SELECTED-UDID');
|
||||
expect(mockedUtils.resolveUdidAsync).toHaveBeenCalledWith({
|
||||
deviceIdentifier: 'iPhone 17',
|
||||
env: {},
|
||||
});
|
||||
expect(mockedUtils.getAvailableDevicesAsync).not.toHaveBeenCalled();
|
||||
expect(mockedUtils.bootAsync).not.toHaveBeenCalled();
|
||||
expect(mockedUtils.startAsync).not.toHaveBeenCalled();
|
||||
expect(mockedUtils.enableAccessibilitySettingsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('selects the default iPhone without booting', async () => {
|
||||
mockedUtils.getAvailableDevicesAsync.mockResolvedValue([
|
||||
{ name: 'iPad', udid: 'ipad' },
|
||||
{ name: 'iPhone 17 Pro', udid: 'pro' },
|
||||
{ name: 'iPhone 17', udid: 'base' },
|
||||
] as never);
|
||||
await resolveIosSessionStartupAsync({ ...hostOptions, bootSimulator: true });
|
||||
expect(mockedUtils.resolveUdidAsync).toHaveBeenCalledWith({
|
||||
deviceIdentifier: 'base',
|
||||
env: {},
|
||||
});
|
||||
expect(mockedUtils.bootAsync).not.toHaveBeenCalled();
|
||||
mockedUtils.getAvailableDevicesAsync.mockResolvedValue([]);
|
||||
await expect(
|
||||
resolveIosSessionStartupAsync({ ...hostOptions, bootSimulator: true })
|
||||
).rejects.toThrow('Could not find an iPhone');
|
||||
});
|
||||
});
|
||||
@@ -437,11 +437,13 @@ describe(configureSimulatorProxyEnvironmentAsync, () => {
|
||||
});
|
||||
|
||||
describe(startChiselServerAsync, () => {
|
||||
const logger = { warn: jest.fn() } as unknown as bunyan;
|
||||
const options = {
|
||||
chiselPath: '/tmp/chisel',
|
||||
controlPort: 52001,
|
||||
authfilePath: '/tmp/authfile.json',
|
||||
env: { PATH: '/bin', AUTH: 'unexpected:password' },
|
||||
logger,
|
||||
};
|
||||
let output: string;
|
||||
let stopAsync: jest.Mock;
|
||||
@@ -483,7 +485,9 @@ describe(startChiselServerAsync, () => {
|
||||
expect(stopAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects and stops a child that prints its fingerprint then fails to bind', async () => {
|
||||
it('keeps the bind error and warns when a child that failed to bind cannot be stopped', async () => {
|
||||
const stopError = new Error('drain timed out');
|
||||
stopAsync.mockRejectedValueOnce(stopError);
|
||||
const started = startChiselServerAsync(options);
|
||||
const rejected = expect(started).rejects.toThrow('address already in use');
|
||||
output += 'listen tcp 127.0.0.1:52001: bind: address already in use\n';
|
||||
@@ -493,16 +497,27 @@ describe(startChiselServerAsync, () => {
|
||||
await jest.advanceTimersByTimeAsync(250);
|
||||
await rejected;
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: stopError },
|
||||
'Could not stop the reverse tunnel server after it failed to start.'
|
||||
);
|
||||
});
|
||||
|
||||
it('stops the child when startup is cancelled', async () => {
|
||||
it('keeps the cancellation and warns when the cancelled child cannot be stopped', async () => {
|
||||
const stopError = new Error('drain timed out');
|
||||
stopAsync.mockRejectedValueOnce(stopError);
|
||||
const controller = new AbortController();
|
||||
const cancelled = new Error('cancelled');
|
||||
const started = startChiselServerAsync({ ...options, signal: controller.signal });
|
||||
const rejected = expect(started).rejects.toThrow('cancelled');
|
||||
controller.abort(new Error('cancelled'));
|
||||
const rejected = expect(started).rejects.toBe(cancelled);
|
||||
controller.abort(cancelled);
|
||||
await jest.advanceTimersByTimeAsync(250);
|
||||
await rejected;
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: stopError },
|
||||
'Could not stop the reverse tunnel server after it failed to start.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
rebindLocalEgressGuardRelays,
|
||||
resolveEgressGuardLibraryAsync,
|
||||
resolveLocalEgressBootEnvironmentAsync,
|
||||
resolveLocalEgressServeSimBootEnvironmentAsync,
|
||||
startLocalEgressGuardRelayAsync,
|
||||
stopLocalEgressGuardRelaysAsync,
|
||||
verifyLocalEgressGuardAsync,
|
||||
} from '../localEgressGuard';
|
||||
@@ -401,6 +403,38 @@ describe(installLocalEgressGuardAsync, () => {
|
||||
expect(sessionLogger.lines.at(-1)?.msg).toContain('refused 1 connection attempt(s)');
|
||||
});
|
||||
|
||||
it('still installs the guard when its event log cannot be created', async () => {
|
||||
const handoffPath = path.join(dir, 'handoff.json');
|
||||
await writeLocalEgressHandoffAsync(handoff, handoffPath);
|
||||
const libraryPath = path.join(dir, 'egress-guard.dylib');
|
||||
const blockedDirectory = path.join(dir, 'not-a-directory');
|
||||
await fs.promises.writeFile(blockedDirectory, 'file');
|
||||
await expect(
|
||||
installLocalEgressGuardAsync({
|
||||
udid: 'u' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
handoffPath,
|
||||
libraryPath,
|
||||
logPath: path.join(blockedDirectory, 'guard.log'),
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
expect(mockedSetEnv).toHaveBeenCalledWith({
|
||||
udid: 'u',
|
||||
env: process.env,
|
||||
variables: buildGuardLaunchdEnvironment({
|
||||
libraryPath,
|
||||
logPath: path.join(blockedDirectory, 'guard.log'),
|
||||
mode: 'block',
|
||||
}),
|
||||
});
|
||||
expect(
|
||||
logger.lines.some(
|
||||
line => line.level === 'warn' && line.msg.includes('They are still refused.')
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('fails the session when launchctl fails', async () => {
|
||||
const handoffPath = path.join(dir, 'handoff.json');
|
||||
await writeLocalEgressHandoffAsync(handoff, handoffPath);
|
||||
@@ -469,6 +503,101 @@ describe(resolveLocalEgressBootEnvironmentAsync, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveLocalEgressServeSimBootEnvironmentAsync, () => {
|
||||
let dir: string;
|
||||
let handoffPath: string;
|
||||
beforeEach(async () => {
|
||||
dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'guard-serve-sim-'));
|
||||
handoffPath = path.join(dir, 'handoff.json');
|
||||
});
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('does not require the guard or check for an ordinary session', async () => {
|
||||
await expect(
|
||||
resolveLocalEgressServeSimBootEnvironmentAsync({
|
||||
handoffPath,
|
||||
libraryPath: null,
|
||||
checkPath: null,
|
||||
})
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('requires both packaged files before handing startup to serve-sim', async () => {
|
||||
await writeLocalEgressHandoffAsync(handoff, handoffPath);
|
||||
await expect(
|
||||
resolveLocalEgressServeSimBootEnvironmentAsync({ handoffPath, libraryPath: null })
|
||||
).rejects.toThrow('missing bin/egress-guard.dylib');
|
||||
await expect(
|
||||
resolveLocalEgressServeSimBootEnvironmentAsync({
|
||||
handoffPath,
|
||||
libraryPath: '/w/bin/egress-guard.dylib',
|
||||
checkPath: null,
|
||||
})
|
||||
).rejects.toThrow('missing bin/egress-guard-check');
|
||||
});
|
||||
|
||||
it('passes the additional dylib and prefixed child variables without host DYLD injection', async () => {
|
||||
await writeLocalEgressHandoffAsync(handoff, handoffPath);
|
||||
const bootEnv = await resolveLocalEgressServeSimBootEnvironmentAsync({
|
||||
handoffPath,
|
||||
libraryPath: '/w/bin/egress-guard.dylib',
|
||||
checkPath: '/w/bin/egress-guard-check',
|
||||
logPath: '/tmp/guard.log',
|
||||
});
|
||||
expect(bootEnv).toEqual({
|
||||
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
|
||||
SIMCTL_CHILD_EAS_EGRESS_GUARD_LOG: '/tmp/guard.log',
|
||||
SIMCTL_CHILD_EAS_EGRESS_GUARD_MODE: 'block',
|
||||
SIMCTL_CHILD_http_proxy: 'http://127.0.0.1:8899',
|
||||
SIMCTL_CHILD_https_proxy: 'http://127.0.0.1:8899',
|
||||
SIMCTL_CHILD_HTTP_PROXY: 'http://127.0.0.1:8899',
|
||||
SIMCTL_CHILD_HTTPS_PROXY: 'http://127.0.0.1:8899',
|
||||
SIMCTL_CHILD_grpc_proxy: 'http://127.0.0.1:8899',
|
||||
SIMCTL_CHILD_no_proxy: 'localhost,127.0.0.1,::1',
|
||||
SIMCTL_CHILD_NO_PROXY: 'localhost,127.0.0.1,::1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(startLocalEgressGuardRelayAsync, () => {
|
||||
it('relays boot events once after repeated starts without changing Simulator state', async () => {
|
||||
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'guard-relay-'));
|
||||
const logPath = path.join(dir, 'guard.log');
|
||||
const logger = createLogger();
|
||||
mockedSetEnv.mockClear();
|
||||
await fs.promises.writeFile(
|
||||
logPath,
|
||||
'eas-egress-guard\tSpringBoard\t4001\tconnect\tblocked\t1.1.1.1:443\tNetwork\n'
|
||||
);
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] });
|
||||
try {
|
||||
await startLocalEgressGuardRelayAsync({ logger, logPath, tailIntervalMs: 5 });
|
||||
await startLocalEgressGuardRelayAsync({ logger, logPath, tailIntervalMs: 5 });
|
||||
await fs.promises.appendFile(
|
||||
logPath,
|
||||
'eas-egress-guard\tMyApp\t4002\tconnect\tblocked\t1.1.1.1:443\tNetwork\n'
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(20);
|
||||
await stopLocalEgressGuardRelaysAsync(logger);
|
||||
expect(mockedSetEnv).not.toHaveBeenCalled();
|
||||
expect(
|
||||
logger.lines.filter(line => /refused connect from SpringBoard/.test(line.msg))
|
||||
).toHaveLength(1);
|
||||
expect(logger.lines.filter(line => /refused connect from MyApp/.test(line.msg))).toHaveLength(
|
||||
1
|
||||
);
|
||||
expect(logger.lines.at(-1)?.msg).toContain('refused 2 connection attempt(s)');
|
||||
} finally {
|
||||
await stopLocalEgressGuardRelaysAsync(logger);
|
||||
jest.clearAllTimers();
|
||||
jest.useRealTimers();
|
||||
await fs.promises.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseGuardCoverage, () => {
|
||||
const ps = [
|
||||
' 1 0 /sbin/launchd',
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { preparePreinstalledWdaAsync } from '../preinstalledWda';
|
||||
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('@expo/turtle-spawn');
|
||||
jest.unmock('@expo/logger');
|
||||
|
||||
const realSetTimeout = setTimeout;
|
||||
const realClearTimeout = clearTimeout;
|
||||
const sleep = (ms: number): Promise<void> => new Promise(resolve => realSetTimeout(resolve, ms));
|
||||
|
||||
async function waitUntil(predicate: () => boolean): Promise<void> {
|
||||
const deadline = Date.now() + 3_000;
|
||||
while (!predicate()) {
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error('Subprocess fixture did not reach the expected state');
|
||||
}
|
||||
await sleep(20);
|
||||
}
|
||||
}
|
||||
|
||||
function isRunning(pid: number): boolean {
|
||||
try {
|
||||
// Orphaned descendants can remain zombies until init reaps them.
|
||||
const state = execFileSync('ps', ['-p', String(pid), '-o', 'stat='])
|
||||
.toString()
|
||||
.trim();
|
||||
return state.length > 0 && !state.startsWith('Z');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
it('kills a stalled downloader and its descendants before fallback removes the artifact', async () => {
|
||||
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'wda-download-timeout-'));
|
||||
const appiumHome = path.join(root, 'appium');
|
||||
const outdir = path.join(appiumHome, 'preinstalled-wda');
|
||||
const pidFiles = ['parent', 'child', 'grandchild'].map(name => path.join(root, `${name}.pid`));
|
||||
const executable = path.join(root, 'appium.cjs');
|
||||
const logger = { child: () => logger, info: jest.fn(), warn: jest.fn() };
|
||||
let preparation: Promise<Record<string, unknown>> | undefined;
|
||||
let fallbackDeadline: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
await fs.promises.mkdir(outdir, { recursive: true });
|
||||
await fs.promises.writeFile(path.join(outdir, 'incomplete'), 'partial download');
|
||||
await fs.promises.writeFile(path.join(appiumHome, 'keep'), 'unrelated');
|
||||
await fs.promises.writeFile(
|
||||
executable,
|
||||
`#!${process.execPath}
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const {spawn} = require('node:child_process');
|
||||
const role = process.argv[2] === 'driver' ? 'parent' : process.argv[2];
|
||||
const outdir = role === 'parent' ? process.argv[process.argv.indexOf('--outdir') + 1] : process.argv[3];
|
||||
fs.writeFileSync(path.join(__dirname, role + '.pid'), String(process.pid));
|
||||
if (role !== 'grandchild') {
|
||||
spawn(process.execPath, [__filename, role === 'parent' ? 'child' : 'grandchild', outdir], {stdio: 'inherit'});
|
||||
}
|
||||
setInterval(() => {
|
||||
if (role === 'grandchild') {
|
||||
fs.mkdirSync(outdir, {recursive: true});
|
||||
fs.writeFileSync(path.join(outdir, 'incomplete'), 'still downloading');
|
||||
}
|
||||
}, 50);
|
||||
`
|
||||
);
|
||||
await fs.promises.chmod(executable, 0o700);
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'Date'] });
|
||||
preparation = preparePreinstalledWdaAsync({
|
||||
runtimeIdentifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5',
|
||||
appiumHome,
|
||||
appiumBinPath: executable,
|
||||
appiumEnv: {},
|
||||
logger: logger as never,
|
||||
});
|
||||
await waitUntil(() => pidFiles.every(file => fs.existsSync(file)));
|
||||
const pids = pidFiles.map(file => Number(fs.readFileSync(file, 'utf8')));
|
||||
expect(pids.every(isRunning)).toBe(true);
|
||||
|
||||
jest.advanceTimersByTime(60_000);
|
||||
await expect(
|
||||
Promise.race([
|
||||
preparation,
|
||||
new Promise((_, reject) => {
|
||||
fallbackDeadline = realSetTimeout(
|
||||
() => reject(new Error('Fallback remained blocked by inherited downloader pipes')),
|
||||
2_000
|
||||
);
|
||||
}),
|
||||
])
|
||||
).resolves.toEqual({});
|
||||
expect(logger.warn.mock.calls[0]?.[0].err.signal).toBe('SIGKILL');
|
||||
await waitUntil(() => pids.every(pid => !isRunning(pid)));
|
||||
await sleep(100);
|
||||
expect(fs.existsSync(outdir)).toBe(false);
|
||||
expect(fs.readFileSync(path.join(appiumHome, 'keep'), 'utf8')).toBe('unrelated');
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
} finally {
|
||||
realClearTimeout(fallbackDeadline);
|
||||
jest.useRealTimers();
|
||||
// Also clean up the fixture if an assertion fails before the timeout fires.
|
||||
for (const file of pidFiles) {
|
||||
if (fs.existsSync(file)) {
|
||||
try {
|
||||
const pid = Number(fs.readFileSync(file, 'utf8'));
|
||||
if (isRunning(pid)) {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
await preparation?.catch(() => {});
|
||||
await fs.promises.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 10_000);
|
||||
@@ -0,0 +1,80 @@
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { preparePreinstalledWdaAsync } from '../preinstalledWda';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
|
||||
const appiumHome = path.join(os.tmpdir(), 'owned-appium-home');
|
||||
const outdir = path.join(appiumHome, 'preinstalled-wda');
|
||||
const runner = path.join(outdir, 'WebDriverAgentRunner-Runner.app');
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
const options = {
|
||||
runtimeIdentifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5',
|
||||
appiumHome,
|
||||
appiumBinPath: path.join(appiumHome, 'node_modules', '.bin', 'appium'),
|
||||
appiumEnv: { APPIUM_HOME: appiumHome },
|
||||
logger: logger as never,
|
||||
};
|
||||
|
||||
it('downloads the driver-matched Simulator runner and retains its XCTest frameworks', async () => {
|
||||
jest.mocked(spawn).mockImplementation((async () => {
|
||||
await fs.promises.mkdir(path.join(runner, 'Frameworks', 'XCTest.framework'), {
|
||||
recursive: true,
|
||||
});
|
||||
return { stdout: '' };
|
||||
}) as never);
|
||||
|
||||
await expect(preparePreinstalledWdaAsync(options)).resolves.toEqual({
|
||||
'appium:usePreinstalledWDA': true,
|
||||
'appium:prebuiltWDAPath': runner,
|
||||
});
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
options.appiumBinPath,
|
||||
[
|
||||
'driver',
|
||||
'run',
|
||||
'xcuitest',
|
||||
'download-wda',
|
||||
'--',
|
||||
'--outdir',
|
||||
outdir,
|
||||
'--platform',
|
||||
'iOS',
|
||||
'--kind',
|
||||
'sim',
|
||||
],
|
||||
expect.objectContaining({ env: options.appiumEnv, detached: true })
|
||||
);
|
||||
expect(
|
||||
(await fs.promises.stat(path.join(runner, 'Frameworks', 'XCTest.framework'))).isDirectory()
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
'com.apple.CoreSimulator.SimRuntime.iOS-16-4',
|
||||
'com.apple.CoreSimulator.SimRuntime.tvOS-26-5',
|
||||
])('keeps the existing startup for unsupported runtime %s', async runtimeIdentifier => {
|
||||
await expect(preparePreinstalledWdaAsync({ ...options, runtimeIdentifier })).resolves.toEqual({});
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back after a failed download and removes only its own incomplete artifact', async () => {
|
||||
await fs.promises.mkdir(outdir, { recursive: true });
|
||||
const sibling = path.join(appiumHome, 'keep');
|
||||
await fs.promises.writeFile(sibling, 'keep');
|
||||
jest.mocked(spawn).mockRejectedValue(new Error('download unavailable'));
|
||||
|
||||
await expect(preparePreinstalledWdaAsync(options)).resolves.toEqual({});
|
||||
await expect(fs.promises.stat(outdir)).rejects.toThrow();
|
||||
await expect(fs.promises.readFile(sibling, 'utf8')).resolves.toBe('keep');
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not enable preinstalled startup when the download produces no runner', async () => {
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: '' } as never);
|
||||
await expect(preparePreinstalledWdaAsync(options)).resolves.toEqual({});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
|
||||
import { createProcessOutput } from '../processOutput';
|
||||
|
||||
const logger = { info: jest.fn() } as unknown as bunyan;
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('redacts split credentials before publishing complete lines', () => {
|
||||
const output = createProcessOutput(logger, ['turn secret']);
|
||||
output.stdout.append('https://preview.test/?tok');
|
||||
output.stdout.append('en=preview-secret&ok=true\n');
|
||||
output.stderr.append('Authorization: Bea');
|
||||
output.stderr.append('rer tool-secret\n{"credential":"turn secret"}\n');
|
||||
expect(logger.info).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{ source: 'stdout' },
|
||||
'https://preview.test/?token=[REDACTED]&ok=true'
|
||||
);
|
||||
expect(logger.info).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{ source: 'stderr' },
|
||||
'Authorization: Bearer [REDACTED]'
|
||||
);
|
||||
expect(output.getOutput()).not.toMatch(/preview-secret|tool-secret|turn secret/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'argent link argent://[email protected]:5678',
|
||||
'argent link argent://[REDACTED]@127.0.0.1:5678',
|
||||
],
|
||||
[
|
||||
'argent link argent://encoded%[email protected]:5678',
|
||||
'argent link argent://[REDACTED]@127.0.0.1:5678',
|
||||
],
|
||||
[
|
||||
'argent link --host 127.0.0.1 --port 5678 --token startup-secret',
|
||||
'argent link --host 127.0.0.1 --port 5678 --token [REDACTED]',
|
||||
],
|
||||
['(or: argent link --token startup-secret)', '(or: argent link --token [REDACTED])'],
|
||||
[
|
||||
'argent link --token "quoted \\"secret\\"" --port 5678',
|
||||
'argent link --token [REDACTED] --port 5678',
|
||||
],
|
||||
["argent link --token='quoted secret' --port 5678", 'argent link --token=[REDACTED] --port 5678'],
|
||||
])('redacts startup credentials at every chunk boundary: %s', (line, expected) => {
|
||||
for (let split = 0; split <= line.length; split++) {
|
||||
jest.mocked(logger.info).mockClear();
|
||||
const output = createProcessOutput(logger);
|
||||
output.stdout.append(line.slice(0, split));
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
output.stdout.append(line.slice(split) + '\n');
|
||||
expect(logger.info).toHaveBeenCalledWith({ source: 'stdout' }, expected);
|
||||
expect(output.getOutput()).toBe(expected + '\n');
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps stdout and stderr fragments separate and decodes split UTF-8', () => {
|
||||
const output = createProcessOutput(logger);
|
||||
const bytes = Buffer.from('hello 🌍\n');
|
||||
output.stdout.append(bytes.subarray(0, 8));
|
||||
output.stderr.append('error\n');
|
||||
output.stdout.append(bytes.subarray(8));
|
||||
expect(logger.info).toHaveBeenNthCalledWith(1, { source: 'stderr' }, 'error');
|
||||
expect(logger.info).toHaveBeenNthCalledWith(2, { source: 'stdout' }, 'hello 🌍');
|
||||
});
|
||||
|
||||
it('omits overlong lines completely and keeps logging the following diagnostics', () => {
|
||||
const output = createProcessOutput(logger);
|
||||
output.stdout.append('https://preview.test/?token=');
|
||||
output.stdout.append('secret'.repeat(20_000));
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
output.stdout.append('\nfatal diagnostic\n');
|
||||
expect(logger.info).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{ source: 'stdout' },
|
||||
'[Overlong output line omitted.]'
|
||||
);
|
||||
expect(logger.info).toHaveBeenNthCalledWith(2, { source: 'stdout' }, 'fatal diagnostic');
|
||||
expect(output.getOutput()).not.toContain('secret');
|
||||
});
|
||||
|
||||
it('splits multi-line and CRLF chunks and keeps a line exactly at the length limit', () => {
|
||||
const output = createProcessOutput(logger);
|
||||
const limit = 16 * 1024;
|
||||
output.stdout.append('first\r\nsecond\nthi');
|
||||
output.stdout.append('rd\n' + 'x'.repeat(limit - 1));
|
||||
output.stdout.append('x\n' + 'y'.repeat(limit) + 'y\nafter');
|
||||
output.finish();
|
||||
expect(jest.mocked(logger.info).mock.calls.map(([, line]) => line)).toEqual([
|
||||
'first',
|
||||
'second',
|
||||
'third',
|
||||
'x'.repeat(limit),
|
||||
'[Overlong output line omitted.]',
|
||||
'after',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps unfinished stdout and stderr lines apart in diagnostics', () => {
|
||||
const output = createProcessOutput(logger);
|
||||
output.stdout.append('done\nDownloading 45%');
|
||||
output.stderr.append('fatal: port in use');
|
||||
expect(output.getOutput()).toBe('done\nDownloading 45%\nfatal: port in use');
|
||||
output.stdout.append('\n');
|
||||
expect(output.getOutput()).toBe('done\nDownloading 45%\nfatal: port in use');
|
||||
});
|
||||
|
||||
it('redacts final partial lines and bounds diagnostics without capping live output', () => {
|
||||
const output = createProcessOutput(logger);
|
||||
for (let index = 0; index < 100; index++) {
|
||||
output.stdout.append('x'.repeat(1023) + '\n');
|
||||
}
|
||||
output.stderr.append('token=final-secret');
|
||||
output.finish();
|
||||
expect(logger.info).toHaveBeenCalledTimes(101);
|
||||
expect(logger.info).toHaveBeenLastCalledWith({ source: 'stderr' }, 'token=[REDACTED]');
|
||||
expect(output.getOutput()).toHaveLength(64 * 1024);
|
||||
expect(output.getOutput()).toContain('token=[REDACTED]');
|
||||
});
|
||||
|
||||
it('applies newly learned secrets to pending output and retained diagnostics', () => {
|
||||
const secrets: string[] = [];
|
||||
const output = createProcessOutput(undefined, secrets);
|
||||
output.stdout.append('opaque-secret\n');
|
||||
output.stderr.append('opaque-secret');
|
||||
secrets.push('opaque-secret');
|
||||
expect(output.getOutput()).not.toContain('opaque-secret');
|
||||
});
|
||||
|
||||
it('redacts JSON token fields with escaped quotes before the token is known', () => {
|
||||
const output = createProcessOutput(logger);
|
||||
output.stdout.append(JSON.stringify({ authToken: 'a"b', controlToken: 'android-secret' }) + '\n');
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
{ source: 'stdout' },
|
||||
'{"authToken":"[REDACTED]","controlToken":"[REDACTED]"}'
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
import { spawnDetached } from '../remoteDeviceRunSession';
|
||||
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('node:fs/promises');
|
||||
jest.mock('../../../sentry');
|
||||
|
||||
it.each([0, 1])(
|
||||
'streams redacted stdout/stderr and drains final partial output on exit %s',
|
||||
async exitCode => {
|
||||
const logger = { info: jest.fn() } as unknown as bunyan;
|
||||
const handle = spawnDetached({
|
||||
command: process.execPath,
|
||||
args: [
|
||||
'-e',
|
||||
`
|
||||
console.log('stdout-marker');
|
||||
console.error('https://preview.test/?token=startup-secret');
|
||||
process.stdout.write('argent link argent://startup-');
|
||||
setTimeout(() => {
|
||||
console.log('[email protected]:5678');
|
||||
console.log('argent link --host 127.0.0.1 --port 5678 --token startup-secret');
|
||||
}, 25);
|
||||
setTimeout(() => {
|
||||
process.stderr.write('final diagnostic');
|
||||
process.exit(${exitCode});
|
||||
}, 100);
|
||||
`,
|
||||
],
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
try {
|
||||
for (let tries = 0; tries < 100 && !handle.getExitError(); tries++) {
|
||||
await delay(20);
|
||||
}
|
||||
await handle.stopAsync();
|
||||
expect(handle.getExitError()?.message).toContain(`code ${exitCode}`);
|
||||
expect(logger.info).toHaveBeenCalledWith({ source: 'stdout' }, 'stdout-marker');
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
{ source: 'stderr' },
|
||||
'https://preview.test/?token=[REDACTED]'
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith({ source: 'stderr' }, 'final diagnostic');
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
{ source: 'stdout' },
|
||||
'argent link argent://[REDACTED]@127.0.0.1:5678'
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
{ source: 'stdout' },
|
||||
'argent link --host 127.0.0.1 --port 5678 --token [REDACTED]'
|
||||
);
|
||||
expect(JSON.stringify(jest.mocked(logger.info).mock.calls)).not.toContain('startup-secret');
|
||||
expect(handle.getOutput()).not.toContain('startup-secret');
|
||||
} finally {
|
||||
await handle.stopAsync();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('drains descendant shutdown logs after the launcher has exited', async () => {
|
||||
const logger = { info: jest.fn() } as unknown as bunyan;
|
||||
const childScript = `
|
||||
process.on('SIGTERM', () => setTimeout(() => {
|
||||
console.error('descendant-shutdown-marker'); process.exit(0);
|
||||
}, 150));
|
||||
console.log('descendant-ready'); setInterval(() => {}, 1000);
|
||||
`;
|
||||
const handle = spawnDetached({
|
||||
command: process.execPath,
|
||||
args: [
|
||||
'-e',
|
||||
`
|
||||
require('node:child_process').spawn(process.execPath, ['-e', ${JSON.stringify(childScript)}], { stdio: 'inherit' });
|
||||
setTimeout(() => process.exit(0), 200);
|
||||
`,
|
||||
],
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
try {
|
||||
for (let tries = 0; tries < 100 && !handle.getExitError(); tries++) {
|
||||
await delay(20);
|
||||
}
|
||||
expect(handle.getExitError()).toBeDefined();
|
||||
await handle.stopAsync();
|
||||
expect(logger.info).toHaveBeenCalledWith({ source: 'stderr' }, 'descendant-shutdown-marker');
|
||||
} finally {
|
||||
await handle.stopAsync();
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
it('fails a stop whose output a detached descendant still holds open, after flushing it', async () => {
|
||||
const logger = { info: jest.fn() } as unknown as bunyan;
|
||||
const lines = (source: string) =>
|
||||
jest
|
||||
.mocked(logger.info)
|
||||
.mock.calls.filter(([fields]) => (fields as { source: string }).source === source)
|
||||
.map(([, line]) => line as unknown as string);
|
||||
// The descendant leaves the process group and keeps the inherited pipes until killed.
|
||||
const escapedScript = `
|
||||
process.stderr.on('error', () => {});
|
||||
setInterval(() => console.error('escaped-tick'), 50);
|
||||
setTimeout(() => process.exit(0), 30_000);
|
||||
`;
|
||||
const handle = spawnDetached({
|
||||
command: process.execPath,
|
||||
args: [
|
||||
'-e',
|
||||
`
|
||||
const escaped = require('node:child_process').spawn(
|
||||
process.execPath,
|
||||
['-e', ${JSON.stringify(escapedScript)}],
|
||||
{ stdio: 'inherit', detached: true }
|
||||
);
|
||||
console.error('escaped-pid ' + escaped.pid);
|
||||
process.stdout.write('launcher partial');
|
||||
setInterval(() => {}, 1000);
|
||||
`,
|
||||
],
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
let escapedPid: number | undefined;
|
||||
try {
|
||||
for (let tries = 0; tries < 100; tries++) {
|
||||
const pidLine = lines('stderr').find(line => line.startsWith('escaped-pid '));
|
||||
escapedPid = pidLine ? Number(pidLine.split(' ')[1]) : undefined;
|
||||
if (escapedPid !== undefined && lines('stderr').includes('escaped-tick')) {
|
||||
break;
|
||||
}
|
||||
await delay(20);
|
||||
}
|
||||
expect(escapedPid).toBeDefined();
|
||||
expect(lines('stdout')).toEqual([]);
|
||||
|
||||
await expect(handle.stopAsync()).rejects.toThrow(
|
||||
'Process output drain timed out after 5000ms.'
|
||||
);
|
||||
expect(handle.getExitError()?.message).toBe('Process exited with signal SIGTERM.');
|
||||
expect(lines('stdout')).toEqual(['launcher partial']);
|
||||
expect(handle.getOutput()).toContain('launcher partial');
|
||||
const ticks = lines('stderr').length;
|
||||
await delay(250);
|
||||
expect(lines('stderr')).toHaveLength(ticks);
|
||||
} finally {
|
||||
if (escapedPid !== undefined) {
|
||||
try {
|
||||
process.kill(escapedPid, 'SIGKILL');
|
||||
} catch {}
|
||||
}
|
||||
await handle.stopAsync().catch(() => {});
|
||||
}
|
||||
}, 20_000);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,184 @@
|
||||
import { once } from 'node:events';
|
||||
import {
|
||||
access,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
readlink,
|
||||
rm,
|
||||
stat,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { WebSocketServer } from 'ws';
|
||||
|
||||
import { runServeSimActionAsync, stageServeSimAppAsync } from '../serveSimActions';
|
||||
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
|
||||
let server: WebSocketServer;
|
||||
let port: number;
|
||||
const token = 'test-session-token';
|
||||
const request = () => ({
|
||||
port,
|
||||
token,
|
||||
action: 'app.install',
|
||||
params: { udid: 'device', path: '/tmp/App.app' },
|
||||
timeoutMs: 2_000,
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
server = new WebSocketServer({ host: '127.0.0.1', port: 0 });
|
||||
await once(server, 'listening');
|
||||
port = (server.address() as { port: number }).port;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const socket of server.clients) {
|
||||
socket.terminate();
|
||||
}
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
it('authenticates the upgrade, waits for ready, and sends the action once', async () => {
|
||||
const actions: unknown[] = [];
|
||||
const upgrades: unknown[] = [];
|
||||
server.on('connection', (socket, upgrade) => {
|
||||
upgrades.push({
|
||||
url: upgrade.url,
|
||||
protocol: upgrade.headers['sec-websocket-protocol'],
|
||||
});
|
||||
socket.on('message', raw => {
|
||||
actions.push(JSON.parse(raw.toString()));
|
||||
socket.send(JSON.stringify({ id: 1, stdout: '', stderr: '', exitCode: 0 }));
|
||||
});
|
||||
socket.send(JSON.stringify({ ready: true }));
|
||||
socket.send(JSON.stringify({ ready: true }));
|
||||
});
|
||||
await runServeSimActionAsync(request());
|
||||
expect(upgrades).toEqual([{ url: '/exec-ws', protocol: `serve-sim.token.${token}` }]);
|
||||
expect(actions).toEqual([{ id: 1, action: 'app.install', params: request().params }]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ id: 1, exitCode: 1, stderr: 'install refused' }, 'install refused'],
|
||||
[{ id: 1, error: 'unknown action app.install' }, 'unknown action'],
|
||||
[{ id: 1, stdout: '' }, 'invalid response'],
|
||||
[{ id: 1, exitCode: 1, stderr: '' }, 'invalid response'],
|
||||
])('rejects unsuccessful action replies without retrying', async (reply, message) => {
|
||||
let attempts = 0;
|
||||
server.on('connection', socket => {
|
||||
socket.send(JSON.stringify({ ready: true }));
|
||||
socket.on('message', () => {
|
||||
attempts++;
|
||||
socket.send(JSON.stringify(reply));
|
||||
});
|
||||
});
|
||||
await expect(runServeSimActionAsync(request())).rejects.toThrow(message);
|
||||
expect(attempts).toBe(1);
|
||||
});
|
||||
|
||||
it('rejects a connection that closes before its action finishes', async () => {
|
||||
server.on('connection', socket => socket.close());
|
||||
await expect(runServeSimActionAsync(request())).rejects.toThrow('closed before completion');
|
||||
});
|
||||
|
||||
it('rejects an upgrade refused by the server without executing an action', async () => {
|
||||
const connected = jest.fn();
|
||||
server.options.verifyClient = () => false;
|
||||
server.on('connection', connected);
|
||||
await expect(runServeSimActionAsync(request())).rejects.toThrow('connection failed');
|
||||
expect(connected).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects malformed replies without resending the action', async () => {
|
||||
const actions = jest.fn();
|
||||
server.on('connection', socket => {
|
||||
socket.send(JSON.stringify({ ready: true }));
|
||||
socket.on('message', () => {
|
||||
actions();
|
||||
socket.send('not JSON');
|
||||
});
|
||||
});
|
||||
await expect(runServeSimActionAsync(request())).rejects.toThrow('returned invalid JSON');
|
||||
expect(actions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('times out and closes a socket that never becomes ready', async () => {
|
||||
const disconnected = new Promise(resolve =>
|
||||
server.on('connection', socket => socket.on('close', resolve))
|
||||
);
|
||||
await expect(runServeSimActionAsync({ ...request(), timeoutMs: 30 })).rejects.toThrow(
|
||||
'timed out'
|
||||
);
|
||||
await disconnected;
|
||||
});
|
||||
|
||||
it('aborts an in-flight action without resending it', async () => {
|
||||
const controller = new AbortController();
|
||||
let attempts = 0;
|
||||
server.on('connection', socket => {
|
||||
socket.send(JSON.stringify({ ready: true }));
|
||||
socket.on('message', () => {
|
||||
attempts++;
|
||||
controller.abort(new Error('session stopped'));
|
||||
});
|
||||
});
|
||||
await expect(runServeSimActionAsync({ ...request(), signal: controller.signal })).rejects.toThrow(
|
||||
'session stopped'
|
||||
);
|
||||
expect(attempts).toBe(1);
|
||||
});
|
||||
|
||||
it('does not connect when already aborted', async () => {
|
||||
const connected = jest.fn();
|
||||
server.on('connection', connected);
|
||||
await expect(
|
||||
runServeSimActionAsync({
|
||||
...request(),
|
||||
signal: AbortSignal.abort(new Error('already stopped')),
|
||||
})
|
||||
).rejects.toThrow('already stopped');
|
||||
expect(connected).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stages a local app without consuming the caller's original", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'serve-sim-stage-test-'));
|
||||
try {
|
||||
const source = path.join(root, 'downloaded', 'App.app');
|
||||
await mkdir(source, { recursive: true });
|
||||
await writeFile(path.join(source, 'marker'), 'app');
|
||||
await symlink('marker', path.join(source, 'current'));
|
||||
const alias = path.join(root, 'Alias.app');
|
||||
await symlink(source, alias);
|
||||
const staged = await stageServeSimAppAsync(alias, root);
|
||||
expect(staged.path).toMatch(/serve-sim-uploads\/eas-app-[^/]+\/Alias.app$/);
|
||||
expect((await lstat(staged.path)).isDirectory()).toBe(true);
|
||||
await access(path.join(staged.path, 'marker'));
|
||||
expect(await readlink(path.join(staged.path, 'current'))).toBe('marker');
|
||||
expect((await stat(path.join(root, 'serve-sim-uploads'))).mode & 0o777).toBe(0o700);
|
||||
await rm(staged.directory, { recursive: true, force: true });
|
||||
expect(await readFile(path.join(source, 'marker'), 'utf8')).toBe('app');
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('removes its staging directory when the source app cannot be read', async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'serve-sim-stage-test-'));
|
||||
try {
|
||||
await expect(stageServeSimAppAsync(path.join(root, 'Missing.app'), root)).rejects.toThrow(
|
||||
'ENOENT'
|
||||
);
|
||||
expect(await readdir(path.join(root, 'serve-sim-uploads'))).toEqual([]);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -6,11 +6,13 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
|
||||
import { uploadServeSimLogsFileAsync } from '../serveSimLogsArtifacts';
|
||||
import { streamServeSimLogsToFileAsync } from '../serveSimLogsRecorder';
|
||||
|
||||
// This test exercises actual sockets, UTF-8 decoding, files, and PUT uploads.
|
||||
// Only WWW's allocation response is substituted; it does not verify cloud storage.
|
||||
// WWW's device run session worker API is served by the local server; it does not verify cloud
|
||||
// storage.
|
||||
jest.mock('../../../sentry');
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
@@ -55,7 +57,29 @@ it('streams selected-device logs into NDJSON and uploads the exact bytes as a se
|
||||
let streamRequest: { url?: string; authorization?: string } | undefined;
|
||||
let uploaded: Buffer | undefined;
|
||||
let uploadedLength: string | undefined;
|
||||
let uploadSessionRequest: { token?: string | string[]; body: unknown } | undefined;
|
||||
server.on('request', (request, response) => {
|
||||
if (request.url === '/v2/device-run-session-worker/artifact-upload-sessions') {
|
||||
const chunks: Buffer[] = [];
|
||||
request.on('data', chunk => chunks.push(Buffer.from(chunk)));
|
||||
request.on('end', () => {
|
||||
uploadSessionRequest = {
|
||||
token: request.headers[DEVICE_RUN_SESSION_TOKEN_HEADER],
|
||||
body: JSON.parse(Buffer.concat(chunks).toString('utf8')),
|
||||
};
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' }).end(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
uploadSession: {
|
||||
url: `${baseUrl}/upload`,
|
||||
headers: { 'Content-Length': String(expected.length) },
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method === 'GET') {
|
||||
streamRequest = { url: request.url, authorization: request.headers.authorization };
|
||||
response.writeHead(200, {
|
||||
@@ -95,27 +119,16 @@ it('streams selected-device logs into NDJSON and uploads the exact bytes as a se
|
||||
});
|
||||
expect(await readFile(filePath)).toEqual(expected);
|
||||
|
||||
const mutation = jest.fn().mockReturnValue({
|
||||
toPromise: async () => ({
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createArtifactUploadSession: {
|
||||
uploadSession: {
|
||||
url: `${baseUrl}/upload`,
|
||||
headers: { 'Content-Length': String(expected.length) },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
await uploadServeSimLogsFileAsync(
|
||||
{ graphqlClient: { mutation } } as unknown as CustomBuildContext,
|
||||
{
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'device-run-session-token' },
|
||||
expoApiV2BaseUrl: `${baseUrl}/v2/`,
|
||||
} as unknown as CustomBuildContext,
|
||||
{ deviceRunSessionId: 'session-test', udid: 'device-B', filePath, logger }
|
||||
);
|
||||
expect(mutation.mock.calls[0][1]).toEqual({
|
||||
deviceRunSessionId: 'session-test',
|
||||
input: {
|
||||
expect(uploadSessionRequest).toEqual({
|
||||
token: 'device-run-session-token',
|
||||
body: {
|
||||
name: 'App logs (device-B)',
|
||||
filename: 'app-logs.ndjson',
|
||||
kind: 'simulator-log',
|
||||
|
||||
@@ -98,13 +98,22 @@ it.each([undefined, 'all'])('refuses an unconfirmed user-app scope (%s)', async
|
||||
|
||||
it('aborts a quiet live stream and drains preceding writes before returning', async () => {
|
||||
const controller = new AbortController();
|
||||
const fsPromises = jest.requireActual<typeof import('node:fs/promises')>('node:fs/promises');
|
||||
const openFile = fsPromises.open;
|
||||
jest.spyOn(fsPromises, 'open').mockImplementationOnce(async (...args) => {
|
||||
const file = await openFile(...args);
|
||||
const write = file.writeFile.bind(file);
|
||||
jest.spyOn(file, 'writeFile').mockImplementationOnce(async (...args) => {
|
||||
const pending = write(...args);
|
||||
controller.abort();
|
||||
await pending;
|
||||
});
|
||||
return file;
|
||||
});
|
||||
const stream = new Readable({ read() {} });
|
||||
stream.push('data: {"pid":42}\n\n');
|
||||
jest.mocked(fetch).mockResolvedValue(new Response(stream));
|
||||
const done = record({ signal: controller.signal });
|
||||
await delay(20);
|
||||
controller.abort();
|
||||
expect((await done).receivedData).toBe(true);
|
||||
expect((await record({ signal: controller.signal })).receivedData).toBe(true);
|
||||
expect(stream.destroyed).toBe(true);
|
||||
expect(await readFile(path.join(directory, 'logs.ndjson'), 'utf8')).toBe('{"pid":42}\n');
|
||||
});
|
||||
|
||||
@@ -88,4 +88,23 @@ describe(createStartupTasks, () => {
|
||||
createStartupTasks(createLoggerMock()).untilAborted(Promise.resolve('ok'))
|
||||
).resolves.toBe('ok');
|
||||
});
|
||||
it.each([false, true])(
|
||||
'inherits parent cancellation, already aborted=%s',
|
||||
async alreadyAborted => {
|
||||
const parent = new AbortController();
|
||||
const reason = new Error('cancelled');
|
||||
if (alreadyAborted) {
|
||||
parent.abort(reason);
|
||||
}
|
||||
const tasks = createStartupTasks(createLoggerMock(), parent.signal);
|
||||
const waiting = tasks.untilAborted(new Promise<void>(() => {}));
|
||||
if (!alreadyAborted) {
|
||||
parent.abort(reason);
|
||||
}
|
||||
await expect(waiting).rejects.toBe(reason);
|
||||
expect(tasks.signal.reason).toBe(reason);
|
||||
tasks.abort(new Error('later failure'));
|
||||
expect(tasks.signal.reason).toBe(reason);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -35,6 +35,7 @@ export async function runAgentAsync({
|
||||
pathEnv,
|
||||
npmCacheUrl,
|
||||
logger,
|
||||
addBuildLogMask,
|
||||
signal,
|
||||
}: {
|
||||
agentKind: AgentKind;
|
||||
@@ -47,11 +48,11 @@ export async function runAgentAsync({
|
||||
pathEnv: string;
|
||||
npmCacheUrl?: string;
|
||||
logger: bunyan;
|
||||
addBuildLogMask: (secret: string) => void;
|
||||
signal: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const leaseOptions = { expoApiV2BaseUrl, expoToken, agentRunId, signal };
|
||||
let credentials = await leaseAgentRunProviderCredentialsAsync(leaseOptions);
|
||||
const secrets = new Set<string>();
|
||||
|
||||
const runDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-agent-run-'));
|
||||
try {
|
||||
@@ -126,14 +127,13 @@ export async function runAgentAsync({
|
||||
}
|
||||
|
||||
for (const secret of invocation.secrets) {
|
||||
secrets.add(secret);
|
||||
addBuildLogMask(secret);
|
||||
}
|
||||
const { exitCode, exitSignal } = await runAgentProcessAsync({
|
||||
command: path.join(cliDirectory, 'node_modules', '.bin', bin),
|
||||
...invocation,
|
||||
cwd: workingDirectory,
|
||||
formatOutputLine,
|
||||
secrets: [...secrets],
|
||||
logger,
|
||||
signal,
|
||||
});
|
||||
@@ -170,7 +170,6 @@ async function runAgentProcessAsync({
|
||||
cwd,
|
||||
stdin,
|
||||
formatOutputLine,
|
||||
secrets,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
@@ -180,7 +179,6 @@ async function runAgentProcessAsync({
|
||||
cwd: string;
|
||||
stdin: string;
|
||||
formatOutputLine: AgentCli['formatOutputLine'];
|
||||
secrets: string[];
|
||||
logger: bunyan;
|
||||
signal: AbortSignal;
|
||||
}): Promise<{ exitCode: number | null; exitSignal: NodeJS.Signals | null }> {
|
||||
@@ -188,8 +186,6 @@ async function runAgentProcessAsync({
|
||||
// Detached, so the agent leads a process group that can be signaled as a whole.
|
||||
const child = spawn(command, args, { cwd, env, detached: true });
|
||||
const outputClosed = new Promise<void>(resolve => child.once('close', () => resolve()));
|
||||
const redact = (text: string): string =>
|
||||
secrets.reduce((masked, secret) => masked.replaceAll(secret, '[redacted]'), text);
|
||||
const stdoutLogger = logger.child({ source: 'stdout' });
|
||||
readline.createInterface({ input: child.stdout }).on('line', line => {
|
||||
let messages: AgentLogMessage[];
|
||||
@@ -201,12 +197,12 @@ async function runAgentProcessAsync({
|
||||
return;
|
||||
}
|
||||
for (const { level, message } of messages) {
|
||||
stdoutLogger[level](redact(message).trimEnd());
|
||||
stdoutLogger[level](message.trimEnd());
|
||||
}
|
||||
});
|
||||
const stderrLogger = logger.child({ source: 'stderr' });
|
||||
readline.createInterface({ input: child.stderr }).on('line', line => {
|
||||
stderrLogger.info(redact(line));
|
||||
stderrLogger.info(line);
|
||||
});
|
||||
// The agent may exit before it reads the prompt; its exit status reports that.
|
||||
child.stdin.on('error', () => {});
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ErrorMetadata } from '@expo/eas-build-job';
|
||||
import { Response } from 'node-fetch';
|
||||
import { z } from 'zod';
|
||||
|
||||
const MAX_RESPONSE_BODY_LENGTH = 8_000;
|
||||
|
||||
export class GooglePlayApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly apiMessage: string,
|
||||
readonly reasons: string[],
|
||||
// Kept for Sentry only, so build logs stay short.
|
||||
readonly diagnostics: { requestPath?: string; responseBody?: string } = {}
|
||||
) {
|
||||
super(`Google Play request failed (HTTP ${status})${apiMessage ? `: ${apiMessage}` : '.'}`);
|
||||
}
|
||||
|
||||
static isRetryableStatus(status: number): boolean {
|
||||
return status === 408 || status === 429 || (status >= 500 && status < 600);
|
||||
}
|
||||
|
||||
// Error metadata for the first Google Play API error in the cause chain. Retries and
|
||||
// uncertain commits wrap the API error, e.g. in an AggregateError.
|
||||
static getErrorMetadata(error: unknown): ErrorMetadata | undefined {
|
||||
let cause = error;
|
||||
for (let depth = 0; cause instanceof Error && depth < 5; depth++) {
|
||||
if (cause instanceof GooglePlayApiError) {
|
||||
return {
|
||||
googlePlayRequestPath: cause.diagnostics.requestPath,
|
||||
// A string, so Sentry does not cut off nested error details.
|
||||
googlePlayErrorResponse: cause.diagnostics.responseBody,
|
||||
};
|
||||
}
|
||||
cause = cause.cause;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
static async fromResponseAsync(
|
||||
response: Response,
|
||||
signal?: AbortSignal
|
||||
): Promise<GooglePlayApiError> {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
signal?.throwIfAborted();
|
||||
// Raw text or HTML can contain the request URL, including an upload secret.
|
||||
}
|
||||
const parsed = z
|
||||
.object({
|
||||
error: z.object({
|
||||
message: z.string().optional(),
|
||||
errors: z.array(z.object({ reason: z.string().optional() })).optional(),
|
||||
}),
|
||||
})
|
||||
.safeParse(data);
|
||||
return new GooglePlayApiError(
|
||||
response.status,
|
||||
parsed.data?.error.message ?? '',
|
||||
parsed.data?.error.errors?.flatMap(error => (error.reason ? [error.reason] : [])) ?? [],
|
||||
{
|
||||
// Path only: the upload session query contains a secret.
|
||||
requestPath: response.url ? new URL(response.url).pathname : undefined,
|
||||
responseBody:
|
||||
data === undefined ? undefined : JSON.stringify(data).slice(0, MAX_RESPONSE_BODY_LENGTH),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { z } from 'zod';
|
||||
|
||||
import { promiseRetryWithCondition } from '../../../utils/promiseRetryWithCondition';
|
||||
|
||||
import { GooglePlayApiError } from './GooglePlayApiError';
|
||||
const PostApi = {
|
||||
'/token': {
|
||||
request: z.object({
|
||||
@@ -61,7 +62,7 @@ export class GooglePlayAuthClient {
|
||||
error =>
|
||||
!signal?.aborted &&
|
||||
error instanceof GooglePlayAuthRequestError &&
|
||||
(error.status === 429 || error.status >= 500),
|
||||
GooglePlayApiError.isRetryableStatus(error.status),
|
||||
{ retries: 3, factor: 2, minTimeout: 1000, maxTimeout: 4000 }
|
||||
)();
|
||||
}
|
||||
|
||||
@@ -1,45 +1,13 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import fetch, { RequestInit, Response } from 'node-fetch';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { promiseRetryWithCondition } from '../../../utils/promiseRetryWithCondition';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { isConnectFailure, isConnectionInterruptedError } from '../../../utils/networkErrors';
|
||||
import { GooglePlayApiError } from './GooglePlayApiError';
|
||||
import { GooglePlayAuthUtils, GoogleServiceAccount } from './GooglePlayAuthUtils';
|
||||
|
||||
export class GooglePlayApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly apiMessage: string,
|
||||
readonly reasons: string[]
|
||||
) {
|
||||
super(`Google Play request failed (HTTP ${status})${apiMessage ? `: ${apiMessage}` : '.'}`);
|
||||
}
|
||||
|
||||
static async fromResponseAsync(
|
||||
response: Response,
|
||||
signal?: AbortSignal
|
||||
): Promise<GooglePlayApiError> {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
signal?.throwIfAborted();
|
||||
// Raw text or HTML can contain the request URL, including an upload secret.
|
||||
}
|
||||
const parsed = z
|
||||
.object({
|
||||
error: z.object({
|
||||
message: z.string().optional(),
|
||||
errors: z.array(z.object({ reason: z.string().optional() })).optional(),
|
||||
}),
|
||||
})
|
||||
.safeParse(data);
|
||||
return new GooglePlayApiError(
|
||||
response.status,
|
||||
parsed.data?.error.message ?? '',
|
||||
parsed.data?.error.errors?.flatMap(error => (error.reason ? [error.reason] : [])) ?? []
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const ReleaseStatusZ = z.enum(['draft', 'inProgress', 'halted', 'completed']);
|
||||
export type ReleaseStatus = z.output<typeof ReleaseStatusZ>;
|
||||
|
||||
@@ -88,7 +56,9 @@ const PostApi = {
|
||||
'/androidpublisher/v3/applications/:packageName/edits/:editId:commit': {
|
||||
path: z.object({ packageName: z.string().min(1), editId: z.string().min(1) }),
|
||||
request: z.object({}),
|
||||
query: z.object({ changesNotSentForReview: z.boolean().optional() }),
|
||||
query: z.object({
|
||||
changesNotSentForReview: z.boolean().optional(),
|
||||
}),
|
||||
response: z.object({ id: z.string().min(1), expiryTimeSeconds: z.string().optional() }),
|
||||
},
|
||||
'/androidpublisher/v3/applications/:packageName/edits/:editId:validate': {
|
||||
@@ -128,7 +98,10 @@ export class GooglePlayClient {
|
||||
private readonly baseUrl = 'https://androidpublisher.googleapis.com';
|
||||
private cachedToken?: Promise<{ value: string; expiresAt: number }>;
|
||||
|
||||
constructor(private readonly serviceAccount: GoogleServiceAccount) {}
|
||||
constructor(
|
||||
private readonly serviceAccount: GoogleServiceAccount,
|
||||
private readonly logger?: bunyan
|
||||
) {}
|
||||
|
||||
private async getTokenAsync(signal?: AbortSignal): Promise<string> {
|
||||
signal?.throwIfAborted();
|
||||
@@ -204,7 +177,21 @@ export class GooglePlayClient {
|
||||
for (const [key, value] of Object.entries(DeleteApi[path].path.parse(params))) {
|
||||
url = url.replace(`:${key}`, encodeURIComponent(value));
|
||||
}
|
||||
await this.requestAsync('DELETE', url, undefined, signal);
|
||||
try {
|
||||
await this.requestWithRetriesAsync(
|
||||
() => this.requestAsync('DELETE', url, undefined, signal),
|
||||
signal
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof AggregateError &&
|
||||
error.cause instanceof GooglePlayApiError &&
|
||||
error.cause.status === 404
|
||||
) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async sendJsonRequestAsync(
|
||||
@@ -236,12 +223,16 @@ export class GooglePlayClient {
|
||||
}
|
||||
}
|
||||
|
||||
const response = await this.requestAsync(
|
||||
method,
|
||||
search.size ? `${path}?${search}` : path,
|
||||
method === 'GET' ? undefined : JSON.stringify(parsedBody),
|
||||
signal,
|
||||
{ 'Content-Type': 'application/json' }
|
||||
const response = await this.requestWithRetriesAsync(
|
||||
() =>
|
||||
this.requestAsync(
|
||||
method,
|
||||
search.size ? `${path}?${search}` : path,
|
||||
method === 'GET' ? undefined : JSON.stringify(parsedBody),
|
||||
signal,
|
||||
{ 'Content-Type': 'application/json' }
|
||||
),
|
||||
signal
|
||||
);
|
||||
|
||||
let data: unknown;
|
||||
@@ -264,6 +255,47 @@ export class GooglePlayClient {
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
private async requestWithRetriesAsync<T>(
|
||||
request: () => Promise<T>,
|
||||
signal?: AbortSignal
|
||||
): Promise<T> {
|
||||
const errors: unknown[] = [];
|
||||
try {
|
||||
return await promiseRetryWithCondition(
|
||||
async () => {
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
return await request();
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
error =>
|
||||
!signal?.aborted &&
|
||||
(error instanceof GooglePlayApiError
|
||||
? GooglePlayApiError.isRetryableStatus(error.status)
|
||||
: isConnectFailure(error) || isConnectionInterruptedError(error)),
|
||||
{ retries: 5, factor: 2, minTimeout: 1000, maxTimeout: 32_000, randomize: true },
|
||||
({ attemptNumber, maxAttemptsCount, error }) => {
|
||||
const reason =
|
||||
error instanceof GooglePlayApiError ? `HTTP ${error.status}` : 'network error';
|
||||
this.logger?.warn(
|
||||
`Retrying Google Play request (${reason}, attempt ${attemptNumber}/${maxAttemptsCount})...`
|
||||
);
|
||||
}
|
||||
)();
|
||||
} catch (error) {
|
||||
// Keep earlier failures: a later rejection does not prove that every attempt failed.
|
||||
if (errors.length > 1) {
|
||||
throw new AggregateError(errors, 'Google Play request failed after retries.', {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async requestAsync(
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
||||
path: string,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import fetch, { FetchError, Response } from 'node-fetch';
|
||||
import fetch, { Response } from 'node-fetch';
|
||||
import promiseRetry from 'promise-retry';
|
||||
import { FileHandle } from 'node:fs/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { GooglePlayApiError, GooglePlayClient } from './GooglePlayClient';
|
||||
import { isConnectFailure, isConnectionInterruptedError } from '../../../utils/networkErrors';
|
||||
import { GooglePlayApiError } from './GooglePlayApiError';
|
||||
import { GooglePlayClient } from './GooglePlayClient';
|
||||
import { AndroidArtifactType } from './appArtifact';
|
||||
|
||||
const UploadResponseZ = z.object({
|
||||
@@ -53,8 +55,10 @@ export namespace GooglePlayResumableUpload {
|
||||
signal?.throwIfAborted();
|
||||
if (
|
||||
timeout.aborted ||
|
||||
isNetworkRetryable(error) ||
|
||||
(error instanceof GooglePlayApiError && (error.status === 429 || error.status >= 500))
|
||||
isConnectFailure(error) ||
|
||||
isConnectionInterruptedError(error) ||
|
||||
(error instanceof GooglePlayApiError &&
|
||||
GooglePlayApiError.isRetryableStatus(error.status))
|
||||
) {
|
||||
retry(
|
||||
timeout.aborted
|
||||
@@ -187,7 +191,7 @@ export namespace GooglePlayResumableUpload {
|
||||
const failure = new SystemError(
|
||||
'Google Play upload failed before a response was received.'
|
||||
);
|
||||
if (timeout.aborted || isNetworkRetryable(error)) {
|
||||
if (timeout.aborted || isConnectFailure(error) || isConnectionInterruptedError(error)) {
|
||||
checkServerOffset = true;
|
||||
retry(failure);
|
||||
}
|
||||
@@ -200,7 +204,7 @@ export namespace GooglePlayResumableUpload {
|
||||
checkServerOffset = true;
|
||||
return retry(new SystemError('Google Play upload response timed out.'));
|
||||
}
|
||||
if (response.status === 429 || response.status >= 500) {
|
||||
if (GooglePlayApiError.isRetryableStatus(response.status)) {
|
||||
checkServerOffset = true;
|
||||
retry(error);
|
||||
}
|
||||
@@ -276,15 +280,3 @@ function getContentTypeFromArtifactType(artifactType: AndroidArtifactType): stri
|
||||
? 'application/vnd.android.package-archive'
|
||||
: 'application/octet-stream';
|
||||
}
|
||||
|
||||
function isNetworkRetryable(error: unknown): boolean {
|
||||
const visited = new Set<Error>();
|
||||
while (error instanceof Error && !visited.has(error)) {
|
||||
if (error instanceof FetchError && error.type === 'system') {
|
||||
return true;
|
||||
}
|
||||
visited.add(error);
|
||||
error = error.cause;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -3,8 +3,11 @@ import { bunyan } from '@expo/logger';
|
||||
import fs from 'node:fs/promises';
|
||||
import promiseRetry from 'promise-retry';
|
||||
|
||||
import { promiseRetryWithCondition } from '../../../utils/promiseRetryWithCondition';
|
||||
|
||||
import { GooglePlayApiError } from './GooglePlayApiError';
|
||||
import { GooglePlayAuthRequestError } from './GooglePlayAuthClient';
|
||||
import { GooglePlayApiError, GooglePlayClient, ReleaseStatus } from './GooglePlayClient';
|
||||
import { GooglePlayClient, ReleaseStatus } from './GooglePlayClient';
|
||||
import { GooglePlayResumableUpload } from './GooglePlayResumableUpload';
|
||||
import { AndroidArtifactType } from './appArtifact';
|
||||
|
||||
@@ -141,7 +144,7 @@ export namespace GooglePlayUtils {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
): Promise<void> {
|
||||
const commitOnceAsync = async ({
|
||||
const sendCommitAsync = async ({
|
||||
changesNotSentForReview,
|
||||
}: {
|
||||
changesNotSentForReview?: boolean;
|
||||
@@ -163,13 +166,48 @@ export namespace GooglePlayUtils {
|
||||
}
|
||||
if (
|
||||
!(error instanceof GooglePlayApiError) ||
|
||||
error.status >= 500 ||
|
||||
error.status === 408 ||
|
||||
error.status === 429
|
||||
GooglePlayApiError.isRetryableStatus(error.status)
|
||||
) {
|
||||
throw new GooglePlayCommitOutcomeUnknownError(
|
||||
'Google Play commit outcome is unknown. Check the release in Play Console before you submit again. The commit was not retried.',
|
||||
{ cause: error }
|
||||
`Could not confirm whether Google Play saved the release for ${packageName}. Check its release status at https://play.google.com/console/ before submitting again.`,
|
||||
{ cause: error, metadata: GooglePlayApiError.getErrorMetadata(error) }
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
logger.info(
|
||||
changesNotSentForReview
|
||||
? 'Google Play saved the changes without sending them for review. Send them for review at https://play.google.com/console/.'
|
||||
: 'Google Play saved the release using its normal review process. Check its review and publishing status at https://play.google.com/console/.'
|
||||
);
|
||||
};
|
||||
// Google can accept the bundle upload before the edit is ready to commit.
|
||||
// Retry only this explicit rejection, not other HTTP 400 preconditions.
|
||||
const isUploadStillProcessing = (error: unknown): boolean =>
|
||||
error instanceof GooglePlayApiError &&
|
||||
error.status === 400 &&
|
||||
error.apiMessage === 'Some of the Android App Bundle uploads are not completed yet.';
|
||||
|
||||
const commitAsync = async (options: { changesNotSentForReview?: boolean }): Promise<void> => {
|
||||
try {
|
||||
await promiseRetryWithCondition(
|
||||
sendCommitAsync,
|
||||
error => {
|
||||
signal?.throwIfAborted();
|
||||
return isUploadStillProcessing(error);
|
||||
},
|
||||
{ retries: 5, factor: 2, minTimeout: 2000, maxTimeout: 32_000 },
|
||||
({ attemptNumber, maxAttemptsCount }) => {
|
||||
logger.warn(
|
||||
`Google Play is still processing the uploaded bundle. Retrying the same edit commit (attempt ${attemptNumber}/${maxAttemptsCount}).`
|
||||
);
|
||||
}
|
||||
)(options);
|
||||
} catch (error) {
|
||||
if (isUploadStillProcessing(error)) {
|
||||
throw new SystemError(
|
||||
`Google Play did not finish processing the uploaded bundle for ${packageName} after six commit attempts. Wait a few minutes, then check the release at https://play.google.com/console/ before submitting again.`,
|
||||
{ cause: error, metadata: GooglePlayApiError.getErrorMetadata(error) }
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
@@ -178,7 +216,7 @@ export namespace GooglePlayUtils {
|
||||
try {
|
||||
// Omit the optional parameter for normal review submission. Some app states
|
||||
// reject its presence, even when false. True saves changes for review in Console.
|
||||
await commitOnceAsync({ changesNotSentForReview: changesNotSentForReview || undefined });
|
||||
await commitAsync({ changesNotSentForReview: changesNotSentForReview || undefined });
|
||||
} catch (error) {
|
||||
if (!(error instanceof GooglePlayApiError) || error.status !== 400) {
|
||||
throw error;
|
||||
@@ -191,7 +229,7 @@ export namespace GooglePlayUtils {
|
||||
logger.warn(
|
||||
'Google does not allow changesNotSentForReview for this app right now. Retrying commit once without it. The changes will be sent for review.'
|
||||
);
|
||||
await commitOnceAsync({});
|
||||
await commitAsync({});
|
||||
} else if (
|
||||
!changesNotSentForReview &&
|
||||
error.apiMessage.includes('Please set the query parameter changesNotSentForReview to true')
|
||||
@@ -199,29 +237,31 @@ export namespace GooglePlayUtils {
|
||||
logger.warn(
|
||||
'Google requires changesNotSentForReview=true. Retrying commit once. Send the saved changes for review from Play Console.'
|
||||
);
|
||||
await commitOnceAsync({ changesNotSentForReview: true });
|
||||
await commitAsync({ changesNotSentForReview: true });
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function mapGooglePlayError(error: unknown): unknown {
|
||||
export function mapGooglePlayError(error: unknown, packageName: string): unknown {
|
||||
if (error instanceof AggregateError) {
|
||||
return mapGooglePlayError(error.cause, packageName);
|
||||
}
|
||||
if (
|
||||
(error instanceof GooglePlayApiError || error instanceof GooglePlayAuthRequestError) &&
|
||||
(error.status === 408 || error.status === 429 || error.status >= 500)
|
||||
GooglePlayApiError.isRetryableStatus(error.status)
|
||||
) {
|
||||
return new SystemError(
|
||||
`Google Play is temporarily unavailable or rate limited (HTTP ${error.status}). Try again later.`,
|
||||
{ cause: error }
|
||||
{ cause: error, metadata: GooglePlayApiError.getErrorMetadata(error) }
|
||||
);
|
||||
}
|
||||
if (
|
||||
error instanceof GooglePlayAuthRequestError &&
|
||||
error.status >= 400 &&
|
||||
error.status < 500 &&
|
||||
error.status !== 408 &&
|
||||
error.status !== 429
|
||||
!GooglePlayApiError.isRetryableStatus(error.status)
|
||||
) {
|
||||
return new UserError(
|
||||
'EAS_GOOGLE_PLAY_INVALID_CREDENTIALS',
|
||||
@@ -235,7 +275,7 @@ export namespace GooglePlayUtils {
|
||||
const message = error.apiMessage;
|
||||
const reasons = new Set(error.reasons);
|
||||
let code = 'EAS_GOOGLE_PLAY_REQUEST_FAILED';
|
||||
let detail = `Google Play rejected the request (HTTP ${error.status}; reasons: ${error.reasons.join(', ') || 'unspecified'}). Check the app and release settings in Play Console. Concurrent edits or Console changes can invalidate an edit.`;
|
||||
let detail = `Google Play rejected the request (HTTP ${error.status})${message ? `: ${message}` : '.'} Check the app settings before submitting again.`;
|
||||
if (message.includes('Package not found')) {
|
||||
code = 'EAS_GOOGLE_PLAY_FIRST_UPLOAD';
|
||||
detail =
|
||||
@@ -247,7 +287,7 @@ export namespace GooglePlayUtils {
|
||||
) {
|
||||
code = 'EAS_GOOGLE_PLAY_VERSION_CODE_USED';
|
||||
detail =
|
||||
'This version code was already uploaded. Increase the Android version code and build a new binary.';
|
||||
'This version code was already uploaded. Check whether the intended release already exists. To upload a new binary, increase the Android version code and build again.';
|
||||
} else if (
|
||||
/privacy policy/i.test(message) ||
|
||||
reasons.has('apkNotificationMessageKeyPermissionsRequirePrivacyPolicy') ||
|
||||
@@ -255,10 +295,13 @@ export namespace GooglePlayUtils {
|
||||
) {
|
||||
code = 'EAS_GOOGLE_PLAY_PRIVACY_POLICY_REQUIRED';
|
||||
detail = 'This app requires a privacy policy. Add it in Play Console before you submit.';
|
||||
} else if (message.includes('Only releases with status draft may be created on draft app')) {
|
||||
} else if (
|
||||
message.includes('Only releases with status draft may be created on draft app') ||
|
||||
message.includes('Staged rollout is not permitted on draft app')
|
||||
) {
|
||||
code = 'EAS_GOOGLE_PLAY_DRAFT_APP';
|
||||
detail =
|
||||
'This app is a draft in Play Console. Complete its required metadata or submit with release_status: draft.';
|
||||
'This app is still a draft in Play Console. Submit a draft release without a rollout, or complete the app setup in Play Console before submitting a release to testers. Use releaseStatus: "draft" in eas.json, or release_status: "draft" in a workflow.';
|
||||
} else if (error.status === 409 || /edit.*(?:expired|invalid|not found)/i.test(message)) {
|
||||
code = 'EAS_GOOGLE_PLAY_EDIT_INVALID';
|
||||
detail =
|
||||
@@ -269,11 +312,12 @@ export namespace GooglePlayUtils {
|
||||
'The Google service account cannot submit this app. Check its key and app permissions in Play Console.';
|
||||
} else if (error.status === 400) {
|
||||
code = 'EAS_GOOGLE_PLAY_INVALID_RELEASE';
|
||||
detail =
|
||||
'Google Play rejected the release or review settings. Check the track, release status, rollout, and review settings in Play Console.';
|
||||
detail = `Google Play rejected the release or review settings${message ? `: ${message}` : '.'} Check the track, release status, rollout, and review settings in Play Console.`;
|
||||
}
|
||||
return new UserError(code, message ? `${detail} Google Play said: ${message}` : detail, {
|
||||
cause: error,
|
||||
});
|
||||
return new UserError(
|
||||
code,
|
||||
`${detail}\n\nApp: ${packageName}\nGoogle Play Console: https://play.google.com/console/`,
|
||||
{ cause: error, metadata: GooglePlayApiError.getErrorMetadata(error) }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import nock from 'nock';
|
||||
import { generateKeyPairSync } from 'node:crypto';
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
import { GooglePlayClient } from '../GooglePlayClient';
|
||||
import { createMockLogger } from '../../../../__tests__/utils/logger';
|
||||
|
||||
const packageName = 'dev.expo.submitfixture';
|
||||
const editPath = `/androidpublisher/v3/applications/${packageName}/edits/edit`;
|
||||
@@ -13,6 +13,11 @@ const api = (): nock.Scope =>
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
const serviceAccount = {
|
||||
client_email: '[email protected]',
|
||||
@@ -75,11 +80,12 @@ it('rejects invalid rollout requests before sending them', async () => {
|
||||
).rejects.toBeInstanceOf(ZodError);
|
||||
});
|
||||
|
||||
it('does not retry an ambiguous edit commit', async () => {
|
||||
it('keeps the outcome unknown when a lost commit response is followed by an invalid edit', async () => {
|
||||
api()
|
||||
.post(`${editPath}:commit`, {})
|
||||
.query({ changesNotSentForReview: false })
|
||||
.replyWithError('Lost response');
|
||||
.replyWithError(Object.assign(new Error('Lost response'), { code: 'ECONNRESET' }));
|
||||
api().post(`${editPath}:commit`, {}).query({ changesNotSentForReview: false }).reply(404);
|
||||
await expect(
|
||||
client.postAsync(
|
||||
'/androidpublisher/v3/applications/:packageName/edits/:editId:commit',
|
||||
@@ -87,7 +93,62 @@ it('does not retry an ambiguous edit commit', async () => {
|
||||
{ packageName, editId: 'edit' },
|
||||
{ query: { changesNotSentForReview: false } }
|
||||
)
|
||||
).rejects.toBeInstanceOf(SystemError);
|
||||
).rejects.toMatchObject({
|
||||
errors: [expect.any(Error), expect.objectContaining({ status: 404 })],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([408, 429, 500, 503])('stops after five retries for HTTP %s', async status => {
|
||||
api().get(editPath).times(6).reply(status);
|
||||
await expect(
|
||||
client.getAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
|
||||
packageName,
|
||||
editId: 'edit',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
cause: expect.objectContaining({ status }),
|
||||
errors: expect.arrayContaining([expect.objectContaining({ status })]),
|
||||
});
|
||||
});
|
||||
|
||||
it('retries the same track update body after a temporary error', async () => {
|
||||
const body = {
|
||||
track: 'internal',
|
||||
releases: [{ versionCodes: ['123'], status: 'draft' as const }],
|
||||
};
|
||||
api().put(`${editPath}/tracks/internal`, body).reply(503);
|
||||
api().put(`${editPath}/tracks/internal`, body).reply(200, { track: 'internal' });
|
||||
await expect(
|
||||
client.putAsync(
|
||||
'/androidpublisher/v3/applications/:packageName/edits/:editId/tracks/:track',
|
||||
body,
|
||||
{ packageName, editId: 'edit', track: 'internal' }
|
||||
)
|
||||
).resolves.toEqual({ track: 'internal' });
|
||||
});
|
||||
|
||||
it('does not add retries to upload-session requests', async () => {
|
||||
api().post('/upload/session').reply(503);
|
||||
await expect(client.requestAsync('POST', '/upload/session')).rejects.toMatchObject({
|
||||
status: 503,
|
||||
});
|
||||
});
|
||||
|
||||
it('stops retrying when cancelled after a transient response', async () => {
|
||||
const controller = new AbortController();
|
||||
api()
|
||||
.get(editPath)
|
||||
.reply(() => {
|
||||
controller.abort();
|
||||
return [503];
|
||||
});
|
||||
await expect(
|
||||
client.getAsync(
|
||||
'/androidpublisher/v3/applications/:packageName/edits/:editId',
|
||||
{ packageName, editId: 'edit' },
|
||||
controller.signal
|
||||
)
|
||||
).rejects.toBeDefined();
|
||||
});
|
||||
|
||||
it('deletes an edit with an empty response', async () => {
|
||||
@@ -100,6 +161,44 @@ it('deletes an edit with an empty response', async () => {
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('reports a retry even when the next request succeeds', async () => {
|
||||
const logger = createMockLogger();
|
||||
client = new GooglePlayClient(serviceAccount, logger);
|
||||
api().get(editPath).reply(503);
|
||||
api().get(editPath).reply(200, { id: 'edit' });
|
||||
await client.getAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
|
||||
packageName,
|
||||
editId: 'edit',
|
||||
});
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Retrying Google Play request (HTTP 503, attempt 2/6)...'
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts a missing edit after a lost delete response', async () => {
|
||||
api()
|
||||
.delete(editPath)
|
||||
.replyWithError(Object.assign(new Error('Lost response'), { code: 'ECONNRESET' }));
|
||||
api().delete(editPath).reply(404);
|
||||
await expect(
|
||||
client.deleteAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
|
||||
packageName,
|
||||
editId: 'edit',
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('retains an initial delete rejection', async () => {
|
||||
api().delete(editPath).reply(404);
|
||||
await expect(
|
||||
client.deleteAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
|
||||
packageName,
|
||||
editId: 'edit',
|
||||
})
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
|
||||
it('rejects malformed success responses without exposing their contents', async () => {
|
||||
api()
|
||||
.get(editPath)
|
||||
@@ -124,6 +223,42 @@ it('retains structured API errors', async () => {
|
||||
).rejects.toMatchObject({ status: 403, apiMessage: 'Denied', reasons: ['forbidden'] });
|
||||
});
|
||||
|
||||
it.each(['CERT_HAS_EXPIRED', 'EPROTO', 'EACCES', undefined])(
|
||||
'does not retry a system error with code %s',
|
||||
async code => {
|
||||
const logger = createMockLogger();
|
||||
client = new GooglePlayClient(serviceAccount, logger);
|
||||
api()
|
||||
.get(editPath)
|
||||
.replyWithError(Object.assign(new Error('Request failed'), { code }));
|
||||
await expect(
|
||||
client.getAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
|
||||
packageName,
|
||||
editId: 'edit',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
cause: expect.objectContaining({ type: 'system' }),
|
||||
});
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['EAI_AGAIN', 'ECONNREFUSED', 'ECONNRESET', 'UND_ERR_SOCKET'])(
|
||||
'retries a recognised network error %s',
|
||||
async code => {
|
||||
api()
|
||||
.get(editPath)
|
||||
.replyWithError(Object.assign(new Error('Request failed'), { code }));
|
||||
api().get(editPath).reply(200, { id: 'edit' });
|
||||
await expect(
|
||||
client.getAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
|
||||
packageName,
|
||||
editId: 'edit',
|
||||
})
|
||||
).resolves.toEqual({ id: 'edit' });
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['body', 'query'])('accepts extra fields in an empty %s object', async field => {
|
||||
api()
|
||||
.post(`/androidpublisher/v3/applications/${packageName}/edits`, {})
|
||||
|
||||
+160
-11
@@ -1,13 +1,20 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import nock from 'nock';
|
||||
import { Response } from 'node-fetch';
|
||||
|
||||
import { api, editPath, mockToken, packageName, serviceAccount } from './googlePlayTestUtils';
|
||||
import { createMockLogger } from '../../../../__tests__/utils/logger';
|
||||
import { GooglePlayAuthRequestError } from '../GooglePlayAuthClient';
|
||||
import { GooglePlayApiError, GooglePlayClient } from '../GooglePlayClient';
|
||||
import { GooglePlayUtils } from '../GooglePlayUtils';
|
||||
import { GooglePlayApiError } from '../GooglePlayApiError';
|
||||
import { GooglePlayClient } from '../GooglePlayClient';
|
||||
import { GooglePlayCommitOutcomeUnknownError, GooglePlayUtils } from '../GooglePlayUtils';
|
||||
|
||||
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 });
|
||||
});
|
||||
beforeEach(() => nock.disableNetConnect());
|
||||
afterEach(() => {
|
||||
const pending = nock.pendingMocks();
|
||||
@@ -18,15 +25,17 @@ afterEach(() => {
|
||||
function commit({
|
||||
signal,
|
||||
changesNotSentForReview = false,
|
||||
logger = createMockLogger(),
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
changesNotSentForReview?: boolean;
|
||||
logger?: ReturnType<typeof createMockLogger>;
|
||||
} = {}) {
|
||||
return GooglePlayUtils.commitEditAsync(new GooglePlayClient(serviceAccount), {
|
||||
packageName,
|
||||
editId: 'edit',
|
||||
changesNotSentForReview,
|
||||
logger: createMockLogger(),
|
||||
logger,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
@@ -57,16 +66,23 @@ it.each([
|
||||
] as const)(
|
||||
'retries the rejected review setting (requested=%s)',
|
||||
async (changesNotSentForReview, initialQuery, message, retryQuery) => {
|
||||
const logger = createMockLogger();
|
||||
mockToken();
|
||||
api().post(`${editPath}:commit`).query(initialQuery).reply(400, { error: { message } });
|
||||
api().post(`${editPath}:commit`).query(retryQuery).reply(200, { id: 'edit' });
|
||||
await expect(commit({ changesNotSentForReview })).resolves.toBeUndefined();
|
||||
await expect(commit({ changesNotSentForReview, logger })).resolves.toBeUndefined();
|
||||
expect(logger.info).toHaveBeenLastCalledWith(
|
||||
changesNotSentForReview
|
||||
? 'Google Play saved the release using its normal review process. Check its review and publishing status at https://play.google.com/console/.'
|
||||
: 'Google Play saved the changes without sending them for review. Send them for review at https://play.google.com/console/.'
|
||||
);
|
||||
}
|
||||
);
|
||||
it.each([408, 429, 503])('does not retry an uncertain HTTP %s commit', async status => {
|
||||
it.each([408, 429, 503])('retries a transient HTTP %s commit on the same edit', async status => {
|
||||
mockToken();
|
||||
api().post(`${editPath}:commit`).query(true).reply(status);
|
||||
await expect(commit()).rejects.toMatchObject({ cause: expect.objectContaining({ status }) });
|
||||
api().post(`${editPath}:commit`).query({}).reply(200, { id: 'edit' });
|
||||
await expect(commit()).resolves.toBeUndefined();
|
||||
});
|
||||
it('preserves cancellation before commit is sent', async () => {
|
||||
const controller = new AbortController();
|
||||
@@ -74,6 +90,74 @@ it('preserves cancellation before commit is sent', async () => {
|
||||
controller.abort(reason);
|
||||
await expect(commit({ signal: controller.signal })).rejects.toBe(reason);
|
||||
});
|
||||
const pendingUploadMessage = 'Some of the Android App Bundle uploads are not completed yet.';
|
||||
|
||||
it.each([false, true])(
|
||||
'waits for bundle processing without changing review settings (%s)',
|
||||
async changesNotSentForReview => {
|
||||
const logger = createMockLogger();
|
||||
const query = changesNotSentForReview ? { changesNotSentForReview: 'true' } : {};
|
||||
mockToken();
|
||||
api()
|
||||
.post(`${editPath}:commit`, {})
|
||||
.query(query)
|
||||
.reply(400, { error: { message: pendingUploadMessage } });
|
||||
api().post(`${editPath}:commit`, {}).query(query).reply(200, { id: 'edit' });
|
||||
await expect(commit({ changesNotSentForReview, logger })).resolves.toBeUndefined();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Google Play is still processing')
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('stops waiting for bundle processing after six attempts', async () => {
|
||||
mockToken();
|
||||
api()
|
||||
.post(`${editPath}:commit`)
|
||||
.query({})
|
||||
.times(6)
|
||||
.reply(400, { error: { message: pendingUploadMessage } });
|
||||
const error = await commit().catch(error => error);
|
||||
expect(GooglePlayUtils.mapGooglePlayError(error, packageName)).toMatchObject({
|
||||
message: expect.stringContaining('Google Play did not finish processing'),
|
||||
metadata: {
|
||||
googlePlayRequestPath: `${editPath}:commit`,
|
||||
googlePlayErrorResponse: JSON.stringify({ error: { message: pendingUploadMessage } }),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not retry an unrelated commit precondition', async () => {
|
||||
mockToken();
|
||||
api()
|
||||
.post(`${editPath}:commit`)
|
||||
.query({})
|
||||
.reply(400, { error: { message: 'Precondition check failed.' } });
|
||||
await expect(commit()).rejects.toBeInstanceOf(GooglePlayApiError);
|
||||
});
|
||||
|
||||
it('checks cancellation before sending a processing retry', async () => {
|
||||
const controller = new AbortController();
|
||||
const reason = new Error('cancelled while waiting');
|
||||
const logger = createMockLogger();
|
||||
jest.spyOn(logger, 'warn').mockImplementation(() => controller.abort(reason));
|
||||
mockToken();
|
||||
api()
|
||||
.post(`${editPath}:commit`)
|
||||
.query({})
|
||||
.reply(400, { error: { message: pendingUploadMessage } });
|
||||
await expect(commit({ signal: controller.signal, logger })).rejects.toBe(reason);
|
||||
});
|
||||
|
||||
it('does not report success if a processing retry has no valid commit receipt', async () => {
|
||||
mockToken();
|
||||
api()
|
||||
.post(`${editPath}:commit`)
|
||||
.query({})
|
||||
.reply(400, { error: { message: pendingUploadMessage } });
|
||||
api().post(`${editPath}:commit`).query({}).reply(200, { id: 'different-edit' });
|
||||
await expect(commit()).rejects.toBeInstanceOf(GooglePlayCommitOutcomeUnknownError);
|
||||
});
|
||||
it('preserves OAuth rejection before commit is sent', async () => {
|
||||
nock('https://oauth2.googleapis.com').post('/token').reply(400, { error: 'invalid_grant' });
|
||||
await expect(commit()).rejects.toBeInstanceOf(GooglePlayAuthRequestError);
|
||||
@@ -86,21 +170,33 @@ it.each([
|
||||
[403, 'wrong signing key', 'EAS_GOOGLE_PLAY_REQUEST_FAILED'],
|
||||
[400, 'privacy policy required', 'EAS_GOOGLE_PLAY_PRIVACY_POLICY_REQUIRED'],
|
||||
[400, 'Only releases with status draft may be created on draft app', 'EAS_GOOGLE_PLAY_DRAFT_APP'],
|
||||
[400, 'Staged rollout is not permitted on draft app', 'EAS_GOOGLE_PLAY_DRAFT_APP'],
|
||||
[409, 'edit invalid', 'EAS_GOOGLE_PLAY_EDIT_INVALID'],
|
||||
[400, 'unknown track', 'EAS_GOOGLE_PLAY_INVALID_RELEASE'],
|
||||
] as const)('keeps the Google error behind %s %s', (status, message, code) => {
|
||||
const original = new GooglePlayApiError(status, message, []);
|
||||
const mapped = GooglePlayUtils.mapGooglePlayError(original);
|
||||
const mapped = GooglePlayUtils.mapGooglePlayError(original, packageName);
|
||||
expect(mapped).toBeInstanceOf(UserError);
|
||||
expect(mapped).toMatchObject({
|
||||
errorCode: code,
|
||||
message: expect.stringContaining(`Google Play said: ${message}`),
|
||||
cause: original,
|
||||
});
|
||||
if (code === 'EAS_GOOGLE_PLAY_REQUEST_FAILED' || code === 'EAS_GOOGLE_PLAY_INVALID_RELEASE') {
|
||||
expect((mapped as Error).message).toContain(message);
|
||||
} else {
|
||||
expect((mapped as Error).message).not.toContain(message);
|
||||
}
|
||||
expect((mapped as Error).message).toContain(`App: ${packageName}`);
|
||||
expect((mapped as Error).message).toContain('https://play.google.com/console/');
|
||||
expect((mapped as Error).message).not.toContain('Google Play error:');
|
||||
if (code === 'EAS_GOOGLE_PLAY_DRAFT_APP') {
|
||||
expect((mapped as Error).message).toContain('without a rollout');
|
||||
expect((mapped as Error).message).toContain('releaseStatus: "draft" in eas.json');
|
||||
}
|
||||
});
|
||||
it('maps a rejected OAuth key to a credential error', () => {
|
||||
const original = new GooglePlayAuthRequestError(400, 'invalid_grant');
|
||||
expect(GooglePlayUtils.mapGooglePlayError(original)).toMatchObject({
|
||||
expect(GooglePlayUtils.mapGooglePlayError(original, packageName)).toMatchObject({
|
||||
errorCode: 'EAS_GOOGLE_PLAY_INVALID_CREDENTIALS',
|
||||
cause: original,
|
||||
});
|
||||
@@ -110,7 +206,7 @@ it.each([408, 429, 500, 503])('classifies temporary HTTP %s errors as system err
|
||||
new GooglePlayApiError(status, 'temporary failure', []),
|
||||
new GooglePlayAuthRequestError(status, 'temporarily_unavailable'),
|
||||
]) {
|
||||
const mapped = GooglePlayUtils.mapGooglePlayError(original);
|
||||
const mapped = GooglePlayUtils.mapGooglePlayError(original, packageName);
|
||||
expect(mapped).toBeInstanceOf(SystemError);
|
||||
expect(mapped).toMatchObject({ cause: original });
|
||||
expect((mapped as Error).message).not.toContain('settings');
|
||||
@@ -118,5 +214,58 @@ it.each([408, 429, 500, 503])('classifies temporary HTTP %s errors as system err
|
||||
});
|
||||
it('keeps non-Google errors unchanged', () => {
|
||||
const error = new SystemError('file failure');
|
||||
expect(GooglePlayUtils.mapGooglePlayError(error)).toBe(error);
|
||||
expect(GooglePlayUtils.mapGooglePlayError(error, packageName)).toBe(error);
|
||||
});
|
||||
it('keeps the Google Play error response in metadata, not in the message', async () => {
|
||||
const response = {
|
||||
error: {
|
||||
code: 400,
|
||||
message: 'Precondition check failed.',
|
||||
status: 'FAILED_PRECONDITION',
|
||||
details: [
|
||||
{
|
||||
'@type': 'type.googleapis.com/google.rpc.PreconditionFailure',
|
||||
violations: [{ type: 'APP_SETUP', description: 'Complete the app setup.' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
mockToken();
|
||||
api().post(`${editPath}:commit`).query({}).reply(400, response);
|
||||
const error = await commit().catch(error => error);
|
||||
const mapped = GooglePlayUtils.mapGooglePlayError(error, packageName);
|
||||
expect(mapped).toMatchObject({
|
||||
errorCode: 'EAS_GOOGLE_PLAY_INVALID_RELEASE',
|
||||
metadata: {
|
||||
googlePlayRequestPath: `${editPath}:commit`,
|
||||
googlePlayErrorResponse: JSON.stringify(response),
|
||||
},
|
||||
});
|
||||
expect((mapped as Error).message).not.toContain('APP_SETUP');
|
||||
});
|
||||
it('keeps the last Google Play error response when the commit outcome is unknown', async () => {
|
||||
mockToken();
|
||||
api()
|
||||
.post(`${editPath}:commit`)
|
||||
.query({})
|
||||
.times(6)
|
||||
.reply(503, { error: { message: 'Backend error' } });
|
||||
await expect(commit()).rejects.toMatchObject({
|
||||
metadata: {
|
||||
googlePlayRequestPath: `${editPath}:commit`,
|
||||
googlePlayErrorResponse: JSON.stringify({ error: { message: 'Backend error' } }),
|
||||
},
|
||||
});
|
||||
});
|
||||
it('does not keep the upload session query or a non-JSON response', async () => {
|
||||
const error = await GooglePlayApiError.fromResponseAsync(
|
||||
new Response('<html>upload_id=SECRET</html>', {
|
||||
status: 400,
|
||||
url: 'https://androidpublisher.googleapis.com/upload/session?upload_id=SECRET',
|
||||
})
|
||||
);
|
||||
expect(GooglePlayApiError.getErrorMetadata(error)).toEqual({
|
||||
googlePlayRequestPath: '/upload/session',
|
||||
googlePlayErrorResponse: undefined,
|
||||
});
|
||||
});
|
||||
@@ -89,14 +89,16 @@ it('uploads aligned chunks and reports server-confirmed progress', async () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(['network', 429, 503])(
|
||||
it.each(['network', 408, 429, 503])(
|
||||
'recovers the accepted offset after a %s interruption',
|
||||
async failure => {
|
||||
mockToken();
|
||||
mockStart();
|
||||
const interrupted = uploadApi().put(session).matchHeader('content-range', 'bytes 0-11/12');
|
||||
if (failure === 'network') {
|
||||
interrupted.replyWithError('connection reset');
|
||||
interrupted.replyWithError(
|
||||
Object.assign(new Error('connection reset'), { code: 'ECONNRESET' })
|
||||
);
|
||||
} else {
|
||||
interrupted.reply(failure as number, { error: { message: 'retry' } });
|
||||
}
|
||||
@@ -121,7 +123,7 @@ it('recovers a completed upload when the final upload response is lost', async (
|
||||
uploadApi()
|
||||
.put(session)
|
||||
.matchHeader('content-length', '12')
|
||||
.replyWithError('lost final response');
|
||||
.replyWithError(Object.assign(new Error('lost final response'), { code: 'ECONNRESET' }));
|
||||
status().reply(200, { versionCode: 42 });
|
||||
await expect(upload()).resolves.toBe(42);
|
||||
});
|
||||
@@ -286,7 +288,7 @@ it('retries a network failure during session creation', async () => {
|
||||
api()
|
||||
.post(`/upload${editPath}/apks`)
|
||||
.query({ uploadType: 'resumable' })
|
||||
.replyWithError('connection reset');
|
||||
.replyWithError(Object.assign(new Error('connection reset'), { code: 'ECONNRESET' }));
|
||||
mockStart();
|
||||
uploadApi().put(session).reply(200, { versionCode: 42 });
|
||||
await expect(upload()).resolves.toBe(42);
|
||||
@@ -347,7 +349,9 @@ it.each([
|
||||
mockStart();
|
||||
const finalChunk = uploadApi().put(session).matchHeader('content-length', '12');
|
||||
if (finalResponse === 'lost') {
|
||||
finalChunk.replyWithError('lost final response');
|
||||
finalChunk.replyWithError(
|
||||
Object.assign(new Error('lost final response'), { code: 'ECONNRESET' })
|
||||
);
|
||||
} else {
|
||||
finalChunk.reply(308, '', { Range: 'bytes=0-11' });
|
||||
}
|
||||
|
||||
@@ -1,31 +1,15 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { graphql } from 'gql.tada';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
import {
|
||||
ArtifactPutError,
|
||||
ArtifactPutNetworkError,
|
||||
type ArtifactSource,
|
||||
type ArtifactUploadSession,
|
||||
putArtifactWithRetriesAsync,
|
||||
} from './artifactPut';
|
||||
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
|
||||
|
||||
const CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION = graphql(`
|
||||
mutation CreateDeviceRunSessionArtifactUploadSession(
|
||||
$deviceRunSessionId: ID!
|
||||
$input: CreateDeviceRunSessionArtifactUploadSessionInput!
|
||||
) {
|
||||
deviceRunSession {
|
||||
createArtifactUploadSession(deviceRunSessionId: $deviceRunSessionId, input: $input) {
|
||||
uploadSession {
|
||||
url
|
||||
headers
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
|
||||
|
||||
type ArtifactMetadata = {
|
||||
deviceRunSessionId: string;
|
||||
@@ -69,29 +53,29 @@ async function createDeviceRunSessionArtifactUploadSessionAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{ deviceRunSessionId, artifactId, name, filename, kind, metadata, size }: ArtifactMetadata,
|
||||
signal: AbortSignal
|
||||
) {
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(
|
||||
CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION,
|
||||
): Promise<ArtifactUploadSession> {
|
||||
const workerApi = requireDeviceRunSessionWorkerApi(ctx);
|
||||
let uploadSession: ArtifactUploadSession;
|
||||
try {
|
||||
uploadSession = await workerApi.createArtifactUploadSessionAsync(
|
||||
{
|
||||
deviceRunSessionId,
|
||||
input: {
|
||||
name,
|
||||
filename,
|
||||
...(kind !== undefined ? { kind } : {}),
|
||||
...(metadata !== undefined ? { metadata } : {}),
|
||||
size,
|
||||
},
|
||||
name,
|
||||
filename,
|
||||
...(kind !== undefined ? { kind } : {}),
|
||||
...(metadata !== undefined ? { metadata } : {}),
|
||||
size,
|
||||
},
|
||||
graphqlAbortContext(signal)
|
||||
)
|
||||
.toPromise();
|
||||
signal.throwIfAborted();
|
||||
if (result.error) {
|
||||
{ signal }
|
||||
);
|
||||
} catch (err) {
|
||||
signal.throwIfAborted();
|
||||
throw new SystemError(
|
||||
`Failed to create upload session for device run session artifact ${artifactId}: ${result.error.message}`,
|
||||
{ cause: result.error }
|
||||
`Failed to create upload session for device run session artifact ${artifactId}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
return result.data!.deviceRunSession.createArtifactUploadSession.uploadSession;
|
||||
signal.throwIfAborted();
|
||||
return uploadSession;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { graphql } from 'gql.tada';
|
||||
import fs from 'node:fs';
|
||||
import { StringDecoder } from 'node:string_decoder';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
@@ -10,25 +9,13 @@ import HttpLogStream from '../../logging/HttpLogStream';
|
||||
import RemoteLoggerStream from '../../logging/RemoteLoggerStream';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { type SignedUrl } from '../../storage/uploadWithSignedUrl';
|
||||
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
|
||||
|
||||
const POLL_INTERVAL_MS = 1_000;
|
||||
const UPLOAD_INTERVAL_MS = 5_000;
|
||||
const EAS_LOGS_THREAD = 'session-events';
|
||||
const EAS_LOGS_BUFFER_RETENTION_MS = 30_000;
|
||||
|
||||
const CREATE_DEVICE_RUN_SESSION_EVENT_LOG_UPLOAD_SESSION_MUTATION = graphql(`
|
||||
mutation CreateDeviceRunSessionEventLogUploadSession($deviceRunSessionId: ID!) {
|
||||
deviceRunSession {
|
||||
createEventLogUploadSession(deviceRunSessionId: $deviceRunSessionId) {
|
||||
uploadSession {
|
||||
url
|
||||
headers
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
/**
|
||||
* The normalized, producer-agnostic event shape uploaded to the API server.
|
||||
* Every remote-session producer (agent-device, argent, ...) tails its own
|
||||
@@ -349,22 +336,17 @@ async function createEventLogUploadSessionAsync(
|
||||
ctx: CustomBuildContext,
|
||||
deviceRunSessionId: string
|
||||
): Promise<SignedUrl> {
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(CREATE_DEVICE_RUN_SESSION_EVENT_LOG_UPLOAD_SESSION_MUTATION, {
|
||||
deviceRunSessionId,
|
||||
})
|
||||
.toPromise();
|
||||
if (result.error) {
|
||||
const workerApi = requireDeviceRunSessionWorkerApi(ctx);
|
||||
try {
|
||||
return await workerApi.createEventLogUploadSessionAsync();
|
||||
} catch (err) {
|
||||
throw new SystemError(
|
||||
`Failed to create device run session event log upload session: ${result.error.message}`,
|
||||
{ cause: result.error }
|
||||
`Failed to create device run session event log upload session: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
const uploadSession = result.data!.deviceRunSession.createEventLogUploadSession.uploadSession;
|
||||
return {
|
||||
url: uploadSession.url,
|
||||
headers: uploadSession.headers as Record<string, string>,
|
||||
};
|
||||
}
|
||||
|
||||
async function collectEventFileAsync({
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { graphql } from 'gql.tada';
|
||||
import spawn, { type SpawnOptions } from '@expo/turtle-spawn';
|
||||
import fetch from 'node-fetch';
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
@@ -11,24 +10,13 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { killProcessGroup } from '../../utils/processes';
|
||||
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
|
||||
|
||||
const PREVIEW_INTERVAL_MS = 60_000;
|
||||
const PREVIEW_MAX_DIMENSION = 320;
|
||||
const PREVIEW_QUALITY = 70;
|
||||
const MAX_PREVIEW_SIZE_BYTES = 5 * 1024 * 1024;
|
||||
const CREATE_PREVIEW_UPLOAD_SESSION_MUTATION = graphql(`
|
||||
mutation CreateDeviceRunSessionPreviewUploadSession($deviceRunSessionId: ID!) {
|
||||
deviceRunSession {
|
||||
createPreviewUploadSession(deviceRunSessionId: $deviceRunSessionId) {
|
||||
uploadSession {
|
||||
url
|
||||
headers
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
/**
|
||||
* node-fetch includes the request URL in its error messages, and this URL is a signed upload URL
|
||||
* that is valid for the whole session. Replace such errors with one that names only the failure.
|
||||
@@ -82,31 +70,7 @@ export function startDeviceRunSessionPreview({
|
||||
throw new Error('Session preview is empty or exceeds the 5 MiB upload limit.');
|
||||
}
|
||||
if (!uploadSession) {
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(
|
||||
CREATE_PREVIEW_UPLOAD_SESSION_MUTATION,
|
||||
{ deviceRunSessionId },
|
||||
{
|
||||
// Keep the client's auth headers and combine cancellation after URQL sets its signal.
|
||||
fetch: (url, options) =>
|
||||
globalThis.fetch(url, {
|
||||
...options,
|
||||
signal: AbortSignal.any([signal, ...(options?.signal ? [options.signal] : [])]),
|
||||
}),
|
||||
}
|
||||
)
|
||||
.toPromise();
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
const session = result.data?.deviceRunSession.createPreviewUploadSession.uploadSession;
|
||||
if (!session) {
|
||||
throw new Error('No session preview upload URL was returned.');
|
||||
}
|
||||
uploadSession = {
|
||||
url: session.url,
|
||||
headers: session.headers as Record<string, string>,
|
||||
};
|
||||
uploadSession = await createPreviewUploadSessionAsync(ctx, deviceRunSessionId, signal);
|
||||
}
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
@@ -139,6 +103,14 @@ export function startDeviceRunSessionPreview({
|
||||
};
|
||||
}
|
||||
|
||||
async function createPreviewUploadSessionAsync(
|
||||
ctx: CustomBuildContext,
|
||||
deviceRunSessionId: string,
|
||||
signal: AbortSignal
|
||||
): Promise<{ url: string; headers: Record<string, string> }> {
|
||||
return await requireDeviceRunSessionWorkerApi(ctx).createPreviewUploadSessionAsync({ signal });
|
||||
}
|
||||
|
||||
/**
|
||||
* Homebrew's FFmpeg is built without libwebp, so macOS encodes previews with `cwebp` from
|
||||
* Homebrew's webp package instead. Throws when it cannot be installed.
|
||||
@@ -146,18 +118,47 @@ export function startDeviceRunSessionPreview({
|
||||
export async function ensureMacosPreviewEncoderInstalledAsync({
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
if ((await asyncResult(spawn('cwebp', ['-version'], { env, stdio: 'pipe' }))).ok) {
|
||||
const probe = await asyncResult(
|
||||
runEncoderCommandAsync('cwebp', ['-version'], { env, stdio: 'pipe' }, signal)
|
||||
);
|
||||
if (probe.ok) {
|
||||
return;
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
logger.info('Installing webp with Homebrew for the session preview.');
|
||||
await spawn('brew', ['install', 'webp'], {
|
||||
env: { ...env, HOMEBREW_NO_AUTO_UPDATE: '1' },
|
||||
logger,
|
||||
});
|
||||
await runEncoderCommandAsync(
|
||||
'brew',
|
||||
['install', 'webp'],
|
||||
{ env: { ...env, HOMEBREW_NO_AUTO_UPDATE: '1' }, logger },
|
||||
signal
|
||||
);
|
||||
}
|
||||
|
||||
async function runEncoderCommandAsync(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: SpawnOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
export async function captureDeviceRunSessionPreviewAsync({
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import { graphql } from 'gql.tada';
|
||||
import { z } from 'zod';
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
|
||||
import type { CustomBuildContext } from '../../customBuildContext';
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
|
||||
|
||||
const SHORT_COMMIT_HASH_LENGTH = 7;
|
||||
const MAX_APP_SLUG_LENGTH = 80;
|
||||
@@ -15,36 +14,15 @@ const LEADING_OR_TRAILING_DOTS_AND_HYPHENS = /^[.-]+|[.-]+$/g;
|
||||
// A full git commit hash: 40 hex characters for SHA-1 or 64 for SHA-256.
|
||||
const FULL_GIT_COMMIT_HASH = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i;
|
||||
|
||||
const SCREENSHOT_SESSION_QUERY = graphql(`
|
||||
query ScreenshotSession($deviceRunSessionId: ID!) {
|
||||
deviceRunSessions {
|
||||
byId(deviceRunSessionId: $deviceRunSessionId) {
|
||||
platform
|
||||
app { slug }
|
||||
build {
|
||||
id
|
||||
gitCommitHash
|
||||
app { slug }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const AppSchema = z.object({ slug: z.string() });
|
||||
const ScreenshotSessionSchema = z.object({
|
||||
platform: z.enum(['IOS', 'ANDROID']),
|
||||
app: AppSchema,
|
||||
build: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
gitCommitHash: z.string().nullable(),
|
||||
app: AppSchema,
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
export type ScreenshotSession = z.infer<typeof ScreenshotSessionSchema>;
|
||||
/**
|
||||
* Session details used to name screenshots. `app` is always null: the device run session worker
|
||||
* API does not return the app slug.
|
||||
*/
|
||||
export type ScreenshotSession = {
|
||||
platform: 'IOS' | 'ANDROID';
|
||||
app: { slug: string } | null;
|
||||
build: { id: string; gitCommitHash: string | null; app: { slug: string } | null } | null;
|
||||
};
|
||||
|
||||
export async function loadScreenshotSessionAsync(
|
||||
ctx: CustomBuildContext,
|
||||
@@ -52,17 +30,23 @@ export async function loadScreenshotSessionAsync(
|
||||
logger: bunyan
|
||||
): Promise<ScreenshotSession | null> {
|
||||
try {
|
||||
const result = await ctx.graphqlClient
|
||||
.query(
|
||||
SCREENSHOT_SESSION_QUERY,
|
||||
{ deviceRunSessionId },
|
||||
graphqlAbortContext(AbortSignal.timeout(SESSION_LOOKUP_TIMEOUT_MS))
|
||||
)
|
||||
.toPromise();
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
return ScreenshotSessionSchema.parse(result.data?.deviceRunSessions.byId);
|
||||
const signal = AbortSignal.timeout(SESSION_LOOKUP_TIMEOUT_MS);
|
||||
const application = await requireDeviceRunSessionWorkerApi(ctx).getApplicationAsync({
|
||||
signal,
|
||||
});
|
||||
return {
|
||||
// www runs iOS sessions only on macOS workers and Android sessions only on Linux workers,
|
||||
// so the worker API does not return the platform.
|
||||
platform: ctx.runtimePlatform === BuildRuntimePlatform.DARWIN ? 'IOS' : 'ANDROID',
|
||||
app: null,
|
||||
build: application.build
|
||||
? {
|
||||
id: application.build.id,
|
||||
gitCommitHash: application.build.gitCommitHash,
|
||||
app: null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, deviceRunSessionId },
|
||||
@@ -86,18 +70,20 @@ export function screenshotArtifactDetails(
|
||||
}
|
||||
|
||||
const app = session.build?.app ?? session.app;
|
||||
const appSlug =
|
||||
app.slug
|
||||
.replace(FILENAME_UNSAFE_RUN, '-')
|
||||
.replace(LEADING_OR_TRAILING_DOTS_AND_HYPHENS, '')
|
||||
.slice(0, MAX_APP_SLUG_LENGTH) || 'app';
|
||||
const platform = session.platform.toLowerCase();
|
||||
const filenameParts = [appSlug, platform];
|
||||
const metadata: Record<string, string> = {
|
||||
__eas_type: 'screenshot',
|
||||
appSlug: app.slug,
|
||||
platform,
|
||||
};
|
||||
const filenameParts = [platform];
|
||||
const metadata: Record<string, string> = { __eas_type: 'screenshot' };
|
||||
// Without an app slug, the filename starts with the platform.
|
||||
if (app) {
|
||||
const appSlug =
|
||||
app.slug
|
||||
.replace(FILENAME_UNSAFE_RUN, '-')
|
||||
.replace(LEADING_OR_TRAILING_DOTS_AND_HYPHENS, '')
|
||||
.slice(0, MAX_APP_SLUG_LENGTH) || 'app';
|
||||
filenameParts.unshift(appSlug);
|
||||
metadata.appSlug = app.slug;
|
||||
}
|
||||
metadata.platform = platform;
|
||||
|
||||
if (session.build) {
|
||||
metadata.buildId = session.build.id;
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
import { type EnvironmentSecret, EnvironmentSecretType, SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
|
||||
/**
|
||||
* The environment secret that holds the device run session token. www gives it only to the job of
|
||||
* one device run session, and the token names that session.
|
||||
*/
|
||||
export const DEVICE_RUN_SESSION_TOKEN_ENV = 'EAS_DEVICE_RUN_SESSION_TOKEN';
|
||||
|
||||
/**
|
||||
* www reads the device run session token only from this header, and only on the
|
||||
* `/v2/device-run-session-worker/*` routes. It is not an `Authorization` credential.
|
||||
*/
|
||||
export const DEVICE_RUN_SESSION_TOKEN_HEADER = 'expo-device-run-session-token';
|
||||
|
||||
const DEVICE_RUN_SESSION_WORKER_PATH = 'device-run-session-worker/';
|
||||
|
||||
const DeviceRunSessionStatusSchema = z.object({
|
||||
id: z.string(),
|
||||
// The status entity values that www returns.
|
||||
status: z.enum(['new', 'queued', 'starting', 'in-progress', 'stopped', 'errored']),
|
||||
});
|
||||
|
||||
const UploadSessionSchema = z.object({
|
||||
uploadSession: z.object({
|
||||
url: z.string(),
|
||||
headers: z.record(z.string(), z.string()),
|
||||
}),
|
||||
});
|
||||
|
||||
const TurnIceServersSchema = z.object({
|
||||
iceServers: z.array(
|
||||
z.object({
|
||||
urls: z.array(z.string()),
|
||||
username: z.string().optional(),
|
||||
credential: z.string().optional(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
const DeviceRunSessionApplicationSchema = z.object({
|
||||
build: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
gitCommitHash: z.string().nullable(),
|
||||
applicationArchiveUrl: z.string().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
const ApiV2ErrorResponseSchema = z.object({
|
||||
errors: z.array(z.object({ code: z.string().optional(), message: z.string() })).min(1),
|
||||
});
|
||||
|
||||
export type DeviceRunSessionStatus = z.infer<typeof DeviceRunSessionStatusSchema>;
|
||||
export type DeviceRunSessionUploadSession = z.infer<typeof UploadSessionSchema>['uploadSession'];
|
||||
export type DeviceRunSessionTurnIceServers = z.infer<typeof TurnIceServersSchema>['iceServers'];
|
||||
export type DeviceRunSessionApplication = z.infer<typeof DeviceRunSessionApplicationSchema>;
|
||||
|
||||
export type CreateDeviceRunSessionArtifactUploadSessionInput = {
|
||||
name: string;
|
||||
filename: string;
|
||||
kind?: string | null;
|
||||
size: number;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
type RequestOptions = {
|
||||
/** Stops the request when aborted. */
|
||||
signal?: AbortSignal;
|
||||
/** node-fetch timeout of each attempt, in milliseconds. */
|
||||
timeoutMs?: number;
|
||||
/** Additional attempts after a failed one. Defaults to none, so each call sends one request. */
|
||||
retries?: number;
|
||||
logger?: bunyan;
|
||||
};
|
||||
|
||||
/**
|
||||
* A request to the device run session worker API failed. The message names the route and the
|
||||
* reason. It never includes the device run session token.
|
||||
*/
|
||||
export class DeviceRunSessionWorkerApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
/** The HTTP status of the response, or undefined when no response arrived. */
|
||||
readonly status: number | undefined
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'DeviceRunSessionWorkerApiError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Client for the www routes under `/v2/device-run-session-worker/`. These routes act only on the
|
||||
* session that the token names, so no request carries a session ID.
|
||||
*/
|
||||
export class DeviceRunSessionWorkerApi {
|
||||
constructor(
|
||||
private readonly expoApiV2BaseUrl: string,
|
||||
private readonly deviceRunSessionToken: string
|
||||
) {}
|
||||
|
||||
public async getSessionAsync(options: RequestOptions = {}): Promise<DeviceRunSessionStatus> {
|
||||
return await this.requestAsync('GET', 'session', DeviceRunSessionStatusSchema, options);
|
||||
}
|
||||
|
||||
public async startAsync(
|
||||
{ remoteConfig }: { remoteConfig: Record<string, unknown> },
|
||||
options: RequestOptions = {}
|
||||
): Promise<DeviceRunSessionStatus> {
|
||||
return await this.requestAsync('POST', 'start', DeviceRunSessionStatusSchema, {
|
||||
...options,
|
||||
json: { remoteConfig },
|
||||
});
|
||||
}
|
||||
|
||||
public async stopAsync(options: RequestOptions = {}): Promise<DeviceRunSessionStatus> {
|
||||
return await this.requestAsync('POST', 'stop', DeviceRunSessionStatusSchema, options);
|
||||
}
|
||||
|
||||
public async createArtifactUploadSessionAsync(
|
||||
input: CreateDeviceRunSessionArtifactUploadSessionInput,
|
||||
options: RequestOptions = {}
|
||||
): Promise<DeviceRunSessionUploadSession> {
|
||||
const { uploadSession } = await this.requestAsync(
|
||||
'POST',
|
||||
'artifact-upload-sessions',
|
||||
UploadSessionSchema,
|
||||
{ ...options, json: input }
|
||||
);
|
||||
return uploadSession;
|
||||
}
|
||||
|
||||
public async createEventLogUploadSessionAsync(
|
||||
options: RequestOptions = {}
|
||||
): Promise<DeviceRunSessionUploadSession> {
|
||||
const { uploadSession } = await this.requestAsync(
|
||||
'POST',
|
||||
'event-log-upload-session',
|
||||
UploadSessionSchema,
|
||||
options
|
||||
);
|
||||
return uploadSession;
|
||||
}
|
||||
|
||||
public async createPreviewUploadSessionAsync(
|
||||
options: RequestOptions = {}
|
||||
): Promise<DeviceRunSessionUploadSession> {
|
||||
const { uploadSession } = await this.requestAsync(
|
||||
'POST',
|
||||
'preview-upload-session',
|
||||
UploadSessionSchema,
|
||||
options
|
||||
);
|
||||
return uploadSession;
|
||||
}
|
||||
|
||||
public async fetchTurnIceServersAsync(
|
||||
options: RequestOptions = {}
|
||||
): Promise<DeviceRunSessionTurnIceServers> {
|
||||
const { iceServers } = await this.requestAsync(
|
||||
'POST',
|
||||
'turn-ice-servers',
|
||||
TurnIceServersSchema,
|
||||
options
|
||||
);
|
||||
return iceServers;
|
||||
}
|
||||
|
||||
public async getApplicationAsync(
|
||||
options: RequestOptions = {}
|
||||
): Promise<DeviceRunSessionApplication> {
|
||||
return await this.requestAsync(
|
||||
'GET',
|
||||
'application',
|
||||
DeviceRunSessionApplicationSchema,
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
private async requestAsync<T>(
|
||||
method: 'GET' | 'POST',
|
||||
route: string,
|
||||
dataSchema: z.ZodType<T>,
|
||||
{
|
||||
signal,
|
||||
timeoutMs,
|
||||
retries = 0,
|
||||
logger,
|
||||
json,
|
||||
}: RequestOptions & { json?: Record<string, unknown> }
|
||||
): Promise<T> {
|
||||
const routeDescription = `${method} /v2/${DEVICE_RUN_SESSION_WORKER_PATH}${route}`;
|
||||
const url = new URL(
|
||||
`${DEVICE_RUN_SESSION_WORKER_PATH}${route}`,
|
||||
this.expoApiV2BaseUrl
|
||||
).toString();
|
||||
|
||||
const responseResult = await asyncResult(
|
||||
turtleFetch(url, method, {
|
||||
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: this.deviceRunSessionToken },
|
||||
...(json ? { json } : {}),
|
||||
...(signal ? { signal } : {}),
|
||||
...(timeoutMs !== undefined ? { timeout: timeoutMs } : {}),
|
||||
retries,
|
||||
shouldThrowOnNotOk: false,
|
||||
logger,
|
||||
})
|
||||
);
|
||||
if (!responseResult.ok) {
|
||||
const reason = responseResult.reason;
|
||||
throw new DeviceRunSessionWorkerApiError(
|
||||
`${routeDescription} failed before www responded: ${
|
||||
reason instanceof Error ? reason.message : String(reason)
|
||||
}. Check the network connection of the worker.`,
|
||||
undefined
|
||||
);
|
||||
}
|
||||
const response = responseResult.value;
|
||||
|
||||
const bodyResult = await asyncResult(response.json());
|
||||
if (!response.ok) {
|
||||
const apiError = bodyResult.ok ? ApiV2ErrorResponseSchema.safeParse(bodyResult.value) : null;
|
||||
const apiErrorMessage = apiError?.success
|
||||
? apiError.data.errors.map(error => error.message).join(' ')
|
||||
: response.statusText;
|
||||
throw new DeviceRunSessionWorkerApiError(
|
||||
`${routeDescription} failed with status ${response.status}: ${apiErrorMessage}`,
|
||||
response.status
|
||||
);
|
||||
}
|
||||
if (!bodyResult.ok) {
|
||||
throw new DeviceRunSessionWorkerApiError(
|
||||
`${routeDescription} returned a response that is not JSON.`,
|
||||
response.status
|
||||
);
|
||||
}
|
||||
const parsed = z.object({ data: dataSchema }).safeParse(bodyResult.value);
|
||||
if (!parsed.success) {
|
||||
throw new DeviceRunSessionWorkerApiError(
|
||||
`${routeDescription} returned an unexpected response: ${parsed.error.message}`,
|
||||
response.status
|
||||
);
|
||||
}
|
||||
return parsed.data.data;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the device run session token when www gave one to this job, or null. Without a token,
|
||||
* callers keep using the robot access token and the account-wide API.
|
||||
*/
|
||||
export function getDeviceRunSessionToken(
|
||||
env: Record<string, string | undefined> | undefined
|
||||
): string | null {
|
||||
const token = env?.[DEVICE_RUN_SESSION_TOKEN_ENV];
|
||||
return token ? token : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the device run session token from the job's environment secrets, or null. It resolves
|
||||
* duplicate names like the build context env does (the last secret wins), so the worker and the
|
||||
* steps use the same token. A file secret is not a token.
|
||||
*/
|
||||
export function getDeviceRunSessionTokenFromEnvironmentSecrets(
|
||||
environmentSecrets: EnvironmentSecret[] | undefined
|
||||
): string | null {
|
||||
const secret = [...(environmentSecrets ?? [])]
|
||||
.reverse()
|
||||
.find(({ name }) => name === DEVICE_RUN_SESSION_TOKEN_ENV);
|
||||
if (!secret || secret.type !== EnvironmentSecretType.STRING || !secret.value) {
|
||||
return null;
|
||||
}
|
||||
return secret.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a client for the device run session worker API when this job has a device run session
|
||||
* token, or null when it does not.
|
||||
*/
|
||||
export function getDeviceRunSessionWorkerApi(
|
||||
ctx: Pick<CustomBuildContext, 'env' | 'expoApiV2BaseUrl'>
|
||||
): DeviceRunSessionWorkerApi | null {
|
||||
const token = getDeviceRunSessionToken(ctx.env);
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
return new DeviceRunSessionWorkerApi(getExpoApiV2BaseUrl(ctx), token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the client for the device run session worker API, which every device run session job
|
||||
* uses to call www about its session.
|
||||
*/
|
||||
export function requireDeviceRunSessionWorkerApi(
|
||||
ctx: Pick<CustomBuildContext, 'env' | 'expoApiV2BaseUrl'>
|
||||
): DeviceRunSessionWorkerApi {
|
||||
const workerApi = getDeviceRunSessionWorkerApi(ctx);
|
||||
if (!workerApi) {
|
||||
throw new SystemError(
|
||||
`This device run session job has no ${DEVICE_RUN_SESSION_TOKEN_ENV} secret. ` +
|
||||
'www gives one to every device run session job, and the worker needs it to call the ' +
|
||||
'device run session API. Start a new session, and contact support if it happens again.'
|
||||
);
|
||||
}
|
||||
return workerApi;
|
||||
}
|
||||
|
||||
function getExpoApiV2BaseUrl(ctx: Pick<CustomBuildContext, 'env' | 'expoApiV2BaseUrl'>): string {
|
||||
if (ctx.expoApiV2BaseUrl) {
|
||||
return ctx.expoApiV2BaseUrl;
|
||||
}
|
||||
if (ctx.env.__API_SERVER_URL) {
|
||||
return new URL('/v2/', ctx.env.__API_SERVER_URL).toString();
|
||||
}
|
||||
throw new SystemError(
|
||||
`${DEVICE_RUN_SESSION_TOKEN_ENV} is set, but the API server URL is not. ` +
|
||||
'The worker cannot call the device run session API without it. ' +
|
||||
'Check that the worker sets __API_SERVER_URL for device run session jobs.'
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { BuildPhaseResult, SystemError, UserError } from '@expo/eas-build-job';
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
@@ -29,12 +30,12 @@ import {
|
||||
import { startDeviceRunSessionScreenshotsAsync } from './deviceRunSessionScreenshots';
|
||||
import {
|
||||
type DetachedProcessHandle,
|
||||
type ServeSimApplicationOptions,
|
||||
type ServeSimLaunchOptions,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
fetchWebPreviewTurnArgsAsync,
|
||||
findAvailablePortAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
isProcessRunning,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
@@ -45,6 +46,11 @@ import {
|
||||
SERVE_SIM_STOP_GRACE_PERIOD_MS,
|
||||
} from './IosSimulatorRecordingUtils';
|
||||
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
|
||||
import { startLogPhase } from '../../utils/logPhase';
|
||||
import { runServeSimActionAsync, stageServeSimAppAsync } from './serveSimActions';
|
||||
import { verifyLocalEgressGuardAsync } from './localEgressGuard';
|
||||
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
import { createStartupTasks } from './startupTasks';
|
||||
|
||||
const WEB_PREVIEW_HOST = '127.0.0.1';
|
||||
const SERVE_SIM_PACKAGE_NAME = '@expo/serve-sim';
|
||||
@@ -56,6 +62,8 @@ const EXPO_DEVICE_HUB_PACKAGE_NAME = 'expo-device-hub';
|
||||
const EXPO_DEVICE_HUB_MAX_DIMENSION = '960';
|
||||
const EXPO_DEVICE_HUB_VIDEO_BITRATE = '6000000';
|
||||
const EXPO_DEVICE_HUB_VIDEO_FPS = '60';
|
||||
// expo-device-hub's record of each running Hub, like serve-sim's: `server-<port>.json`.
|
||||
export const EXPO_DEVICE_HUB_STATE_DIR = path.join(os.tmpdir(), 'expo-device-hub');
|
||||
// On SIGTERM the Hub finalizes the recording itself, with this deadline before it force-exits,
|
||||
// so a stop request that failed here still gets one more chance to write the MP4.
|
||||
const EXPO_DEVICE_HUB_SIGTERM_FINALIZE_DEADLINE_MS = 60_000;
|
||||
@@ -80,7 +88,8 @@ export function simulatorPreviewPageUrl(env: BuildStepEnv, subdomainId: string):
|
||||
// Local website hosts are shared by local and staging simulator sessions.
|
||||
const WEBSITE_DEV_ORIGINS = ['https://expo.test', 'https://*.expo.test'];
|
||||
|
||||
export function websiteOriginServeSimArgs(env: BuildStepEnv): string[] {
|
||||
// serve-sim and expo-device-hub take the same website origin flags and values.
|
||||
export function websiteOriginArgs(env: BuildStepEnv): string[] {
|
||||
const origins = new Set([websiteOrigin(env)]);
|
||||
if (!env.EXPO_LOCAL && env.EXPO_STAGING) {
|
||||
origins.add('https://*.expo.dev');
|
||||
@@ -93,6 +102,16 @@ export function websiteOriginServeSimArgs(env: BuildStepEnv): string[] {
|
||||
return [...origins].flatMap(origin => ['--cors-origin', origin, '--frame-ancestor', origin]);
|
||||
}
|
||||
|
||||
// Both hosts take the same flag; it only applies under --require-token, which both run with.
|
||||
export function frameAncestorArgs(frameAncestors: readonly string[]): string[] {
|
||||
return frameAncestors.flatMap(origin => ['--frame-ancestor', origin]);
|
||||
}
|
||||
|
||||
// Both hosts take the same flag and match the same origin shapes.
|
||||
export function corsOriginArgs(corsOrigins: readonly string[]): string[] {
|
||||
return corsOrigins.flatMap(origin => ['--cors-origin', origin]);
|
||||
}
|
||||
|
||||
function createServeSimPackageSpec(packageVersion: string | undefined): string {
|
||||
return `${SERVE_SIM_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
|
||||
}
|
||||
@@ -105,8 +124,13 @@ export function createServeSimArgs({
|
||||
port,
|
||||
turnArgs = [],
|
||||
websiteArgs = [],
|
||||
frameAncestorArgs: frameArgs = [],
|
||||
corsOriginArgs: corsArgs = [],
|
||||
shareUrl,
|
||||
disableShare = false,
|
||||
packageVersion,
|
||||
iosSimulatorUdid,
|
||||
installAppPath,
|
||||
launchAppIdentifier,
|
||||
launchArgs = [],
|
||||
openUrl,
|
||||
@@ -116,13 +140,19 @@ export function createServeSimArgs({
|
||||
port: number;
|
||||
turnArgs?: string[];
|
||||
websiteArgs?: string[];
|
||||
frameAncestorArgs?: string[];
|
||||
corsOriginArgs?: string[];
|
||||
shareUrl?: string;
|
||||
disableShare?: boolean;
|
||||
packageVersion?: string;
|
||||
iosSimulatorUdid?: string;
|
||||
installAppPath?: string;
|
||||
networkCapture?: boolean;
|
||||
networkCaptureFields?: string[];
|
||||
} & ServeSimLaunchOptions): string[] {
|
||||
return [
|
||||
createServeSimPackageSpec(packageVersion),
|
||||
...(iosSimulatorUdid ? [iosSimulatorUdid] : []),
|
||||
'--port',
|
||||
String(port),
|
||||
'--host',
|
||||
@@ -142,8 +172,12 @@ export function createServeSimArgs({
|
||||
SERVE_SIM_VIDEO_FPS,
|
||||
...turnArgs,
|
||||
...websiteArgs,
|
||||
...frameArgs,
|
||||
...corsArgs,
|
||||
...(shareUrl ? ['--share-url', shareUrl] : []),
|
||||
...(disableShare ? ['--disable-share'] : []),
|
||||
...(launchAppIdentifier ? ['--launch-app-identifier', launchAppIdentifier] : []),
|
||||
...(installAppPath ? ['--install-app-path', installAppPath] : []),
|
||||
...launchArgs.flatMap(argument => ['--launch-arg', argument]),
|
||||
...(openUrl ? ['--open-url', openUrl] : []),
|
||||
// `--network-capture` also covers an already booted simulator. Fields are repeated, not
|
||||
@@ -160,11 +194,17 @@ export function createServeSimArgs({
|
||||
export function createExpoDeviceHubArgs({
|
||||
port,
|
||||
turnArgs = [],
|
||||
websiteArgs = [],
|
||||
frameAncestorArgs: frameArgs = [],
|
||||
corsOriginArgs: corsArgs = [],
|
||||
packageVersion,
|
||||
recordingDirectory,
|
||||
}: {
|
||||
port: number;
|
||||
turnArgs?: string[];
|
||||
websiteArgs?: string[];
|
||||
frameAncestorArgs?: string[];
|
||||
corsOriginArgs?: string[];
|
||||
packageVersion?: string;
|
||||
recordingDirectory?: string;
|
||||
}): string[] {
|
||||
@@ -174,6 +214,7 @@ export function createExpoDeviceHubArgs({
|
||||
String(port),
|
||||
'--host',
|
||||
WEB_PREVIEW_HOST,
|
||||
'--require-token',
|
||||
'--platform',
|
||||
'android',
|
||||
'--transport',
|
||||
@@ -192,6 +233,9 @@ export function createExpoDeviceHubArgs({
|
||||
'--hide-boot-device',
|
||||
...(recordingDirectory ? ['--android-recording-directory', recordingDirectory] : []),
|
||||
...turnArgs,
|
||||
...websiteArgs,
|
||||
...frameArgs,
|
||||
...corsArgs,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -200,25 +244,39 @@ const WebPreviewReadyResponseSchema = z.object({
|
||||
device: z.string(),
|
||||
});
|
||||
|
||||
// Startup output includes bearer URLs (or a token field with --quiet).
|
||||
function redactPreviewTokens(output: string): string {
|
||||
return output
|
||||
.replace(/([?&#]token=)[^\s&"'<>]+/g, '$1[redacted]')
|
||||
.replace(/("token"\s*:\s*")[^"]*/g, '$1[redacted]');
|
||||
}
|
||||
|
||||
export async function waitForWebPreviewReadyAsync({
|
||||
previewServer,
|
||||
serverName,
|
||||
port,
|
||||
timeoutMs,
|
||||
startupTimeoutMs,
|
||||
signal,
|
||||
}: {
|
||||
previewServer: Pick<DetachedProcessHandle, 'pid' | 'getOutput'>;
|
||||
serverName: string;
|
||||
port: number;
|
||||
timeoutMs: number;
|
||||
startupTimeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<string> {
|
||||
const readyUrl = `http://${WEB_PREVIEW_HOST}:${port}/readyz`;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let deadline = Date.now() + (startupTimeoutMs ?? timeoutMs);
|
||||
// Preserve the boot budget until the host starts answering.
|
||||
let waitingForHost = startupTimeoutMs !== undefined;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
signal?.throwIfAborted();
|
||||
if (previewServer.pid !== undefined && !isProcessRunning(previewServer.pid)) {
|
||||
throw new SystemError(
|
||||
`${serverName} exited before becoming ready. Last output:\n${
|
||||
previewServer.getOutput() || '<empty>'
|
||||
redactPreviewTokens(previewServer.getOutput()) || '<empty>'
|
||||
}`
|
||||
);
|
||||
}
|
||||
@@ -226,21 +284,52 @@ export async function waitForWebPreviewReadyAsync({
|
||||
const response = await turtleFetch(readyUrl, 'GET', {
|
||||
retries: 0,
|
||||
timeout: 2_000,
|
||||
shouldThrowOnNotOk: false,
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new SystemError(`${serverName} readiness returned HTTP ${response.status}`);
|
||||
}
|
||||
const ready = WebPreviewReadyResponseSchema.parse(await response.json());
|
||||
signal?.throwIfAborted();
|
||||
return ready.device;
|
||||
} catch (error) {
|
||||
const connectionRefused =
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
error.code === 'ECONNREFUSED';
|
||||
if (waitingForHost && !connectionRefused) {
|
||||
waitingForHost = false;
|
||||
deadline = Math.min(deadline, Date.now() + timeoutMs);
|
||||
}
|
||||
lastError = error;
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
await sleepAsync(WEB_PREVIEW_READY_POLL_INTERVAL_MS);
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
throw new SystemError(
|
||||
`Timed out waiting for ${serverName} readiness at ${readyUrl}${
|
||||
lastError instanceof Error ? `: ${lastError.message}` : ''
|
||||
}. Last output:\n${previewServer.getOutput() || '<empty>'}`
|
||||
}. Last output:\n${redactPreviewTokens(previewServer.getOutput()) || '<empty>'}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function readExpoDeviceHubPreviewTokenAsync(
|
||||
port: number,
|
||||
stateDir: string = EXPO_DEVICE_HUB_STATE_DIR
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const state = JSON.parse(
|
||||
await fs.promises.readFile(path.join(stateDir, `server-${port}.json`), 'utf-8')
|
||||
) as { token?: unknown };
|
||||
return typeof state.token === 'string' ? state.token : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readServeSimPreviewTokenAsync(
|
||||
udid: string,
|
||||
stateDir: string = SERVE_SIM_STATE_DIR
|
||||
@@ -251,6 +340,8 @@ export async function readServeSimPreviewTokenAsync(
|
||||
|
||||
export type DeviceWebPreview = {
|
||||
previewPageUrl: string;
|
||||
/** Human-facing link, absent when a private job has no authenticated URL. */
|
||||
sharePageUrl?: string;
|
||||
apiUrl: string;
|
||||
previewToken?: string;
|
||||
/** Closes this tunnel only. The session host and recording remain running. */
|
||||
@@ -258,7 +349,7 @@ export type DeviceWebPreview = {
|
||||
};
|
||||
|
||||
export type DeviceSessionHost = {
|
||||
openPreviewAsync(options: { baseDomain: string }): Promise<DeviceWebPreview>;
|
||||
openPreviewAsync(options: { baseDomain: string; authtoken: string }): Promise<DeviceWebPreview>;
|
||||
/**
|
||||
* Terminal and idempotent. Never rejects: a failed finalization, host stop or upload is logged
|
||||
* and reported, so callers need no error handling around it.
|
||||
@@ -273,30 +364,94 @@ type AndroidSessionRecording = {
|
||||
env: BuildStepEnv;
|
||||
};
|
||||
|
||||
type DeviceSessionHostOptions = {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
separateLogPhase?: boolean;
|
||||
startupTimeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
packageVersion?: string;
|
||||
disableShare?: boolean;
|
||||
privatePreviewUrl?: string;
|
||||
/** Origins allowed to embed the preview host, in addition to the website origins. */
|
||||
frameAncestors?: string[];
|
||||
/** Origins whose pages may call the preview host's API, in addition to the website origins. */
|
||||
corsOrigins?: string[];
|
||||
iosSimulatorUdid?: string;
|
||||
installAppPath?: string;
|
||||
bootEnv?: Record<string, string>;
|
||||
application?: Promise<ServeSimApplicationOptions | void>;
|
||||
onStartupError?: (error: unknown) => void;
|
||||
networkCapture?: boolean;
|
||||
networkCaptureFields?: string[];
|
||||
} & ServeSimLaunchOptions;
|
||||
|
||||
export async function startDeviceSessionHostAsync(
|
||||
ctx: CustomBuildContext,
|
||||
options: DeviceSessionHostOptions
|
||||
): Promise<DeviceSessionHost> {
|
||||
if (!options.separateLogPhase) {
|
||||
return await startDeviceSessionHostInternalAsync(ctx, options, () => {}, options.logger);
|
||||
}
|
||||
const phase = startLogPhase(options.logger, 'Simulator preview');
|
||||
let ready = false;
|
||||
try {
|
||||
const host = await startDeviceSessionHostInternalAsync(
|
||||
ctx,
|
||||
{ ...options, logger: phase.logger },
|
||||
successful =>
|
||||
phase.end(ready && successful ? BuildPhaseResult.SUCCESS : BuildPhaseResult.FAIL),
|
||||
options.logger
|
||||
);
|
||||
ready = true;
|
||||
return host;
|
||||
} catch (error) {
|
||||
phase.end(BuildPhaseResult.FAIL);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function startDeviceSessionHostInternalAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
startupTimeoutMs,
|
||||
signal,
|
||||
packageVersion,
|
||||
iosSimulatorUdid,
|
||||
installAppPath,
|
||||
application,
|
||||
onStartupError,
|
||||
disableShare = false,
|
||||
privatePreviewUrl,
|
||||
frameAncestors = [],
|
||||
corsOrigins = [],
|
||||
launchAppIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
networkCapture = false,
|
||||
networkCaptureFields = [],
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
packageVersion?: string;
|
||||
networkCapture?: boolean;
|
||||
networkCaptureFields?: string[];
|
||||
} & ServeSimLaunchOptions
|
||||
bootEnv = {},
|
||||
}: DeviceSessionHostOptions,
|
||||
onFinished: (successful: boolean) => void,
|
||||
artifactLogger: bunyan
|
||||
): Promise<DeviceSessionHost> {
|
||||
signal?.throwIfAborted();
|
||||
const isAndroid = runtimePlatform === BuildRuntimePlatform.LINUX;
|
||||
if (
|
||||
Object.keys(bootEnv).length > 0 &&
|
||||
(runtimePlatform !== BuildRuntimePlatform.DARWIN || !iosSimulatorUdid)
|
||||
) {
|
||||
throw new UserError(
|
||||
'EAS_IOS_SIMULATOR_BOOT_INVALID_INPUT',
|
||||
'Simulator boot options require an explicit iOS Simulator.'
|
||||
);
|
||||
}
|
||||
// Unreachable from the step functions, which reject a non-Darwin launch while parsing.
|
||||
// Kept because this function is exported and expo-device-hub cannot launch.
|
||||
if (isAndroid && networkCapture) {
|
||||
@@ -311,6 +466,9 @@ export async function startDeviceSessionHostAsync(
|
||||
`Cannot launch ${launchAppIdentifier}: an application launch runs through serve-sim on an iOS simulator, and this session runs expo-device-hub on ${runtimePlatform}.`
|
||||
);
|
||||
}
|
||||
if (application && (isAndroid || !iosSimulatorUdid)) {
|
||||
throw new SystemError('Deferred startup requires an explicit iOS Simulator.');
|
||||
}
|
||||
if (isAndroid) {
|
||||
await ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
|
||||
}
|
||||
@@ -322,14 +480,19 @@ export async function startDeviceSessionHostAsync(
|
||||
env,
|
||||
}
|
||||
: null;
|
||||
startupTimeoutMs ??= iosSimulatorUdid ? 30 * 60_000 : undefined;
|
||||
const startup = createStartupTasks(logger, signal);
|
||||
const serveSimTmpdir = env.TMPDIR ?? os.tmpdir();
|
||||
const serveSimStateDir = env.SERVE_SIM_STATE_DIR ?? SERVE_SIM_STATE_DIR;
|
||||
const subdomainId = randomBytes(16).toString('hex');
|
||||
const previewPageUrl = simulatorPreviewPageUrl(env, subdomainId);
|
||||
const sharePageUrl = disableShare ? privatePreviewUrl : previewPageUrl;
|
||||
const port = await findAvailablePortAsync();
|
||||
const serverName = isAndroid ? 'expo-device-hub' : 'serve-sim';
|
||||
const packageSpec = isAndroid
|
||||
? createExpoDeviceHubPackageSpec(packageVersion)
|
||||
: createServeSimPackageSpec(packageVersion);
|
||||
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { env, logger });
|
||||
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { logger });
|
||||
const previewExec = resolvePackageExec(
|
||||
resolveConfiguredPackageManager(env, PackageManager.NPM),
|
||||
isAndroid
|
||||
@@ -338,16 +501,25 @@ export async function startDeviceSessionHostAsync(
|
||||
turnArgs,
|
||||
packageVersion,
|
||||
recordingDirectory: recording?.directory,
|
||||
websiteArgs: websiteOriginArgs(env),
|
||||
frameAncestorArgs: frameAncestorArgs(frameAncestors),
|
||||
corsOriginArgs: corsOriginArgs(corsOrigins),
|
||||
})
|
||||
: createServeSimArgs({
|
||||
port,
|
||||
turnArgs,
|
||||
packageVersion,
|
||||
websiteArgs: websiteOriginServeSimArgs(env),
|
||||
shareUrl: previewPageUrl,
|
||||
launchAppIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
iosSimulatorUdid,
|
||||
websiteArgs: websiteOriginArgs(env),
|
||||
frameAncestorArgs: frameAncestorArgs(frameAncestors),
|
||||
corsOriginArgs: corsOriginArgs(corsOrigins),
|
||||
// serve-sim adds its bearer token to --share-url; private links are for worker logs only.
|
||||
shareUrl: disableShare ? undefined : previewPageUrl,
|
||||
disableShare,
|
||||
installAppPath: iosSimulatorUdid ? undefined : installAppPath,
|
||||
launchAppIdentifier: iosSimulatorUdid ? undefined : launchAppIdentifier,
|
||||
launchArgs: iosSimulatorUdid ? [] : launchArgs,
|
||||
openUrl: iosSimulatorUdid ? undefined : openUrl,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
})
|
||||
@@ -355,24 +527,37 @@ export async function startDeviceSessionHostAsync(
|
||||
logger.info(
|
||||
`Launching ${packageSpec} on ${WEB_PREVIEW_HOST}:${port} via ${previewExec.command}.`
|
||||
);
|
||||
// Redact these credentials before forwarding process output to build logs.
|
||||
const secrets = [
|
||||
...turnArgs.filter((_, index) => turnArgs[index - 1] === '--turn-credential'),
|
||||
...(recording ? [recording.controlToken] : []),
|
||||
];
|
||||
const screenshots = await startDeviceRunSessionScreenshotsAsync(ctx, {
|
||||
deviceRunSessionId: getDeviceRunSessionIdOrThrow(env),
|
||||
logger,
|
||||
logger: artifactLogger,
|
||||
});
|
||||
let previewServer: DetachedProcessHandle;
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
previewServer = spawnDetached({
|
||||
command: previewExec.command,
|
||||
args: previewExec.args,
|
||||
env: {
|
||||
...env,
|
||||
...bootEnv,
|
||||
...(isAndroid ? {} : { TMPDIR: serveSimTmpdir, SERVE_SIM_STATE_DIR: serveSimStateDir }),
|
||||
EXPO_DEVICE_HUB_SCREENSHOT_DIRECTORY: screenshots.directory,
|
||||
...(recording ? { EXPO_DEVICE_HUB_RECORDING_CONTROL_TOKEN: recording.controlToken } : {}),
|
||||
// The Hub would resolve its state directory from the step's env, which may set its own
|
||||
// TMPDIR or state directory, so it writes its token where the host reads it below.
|
||||
...(isAndroid ? { EXPO_DEVICE_HUB_STATE_DIR } : {}),
|
||||
},
|
||||
// Only an Android host records through expo-device-hub; serve-sim records on iOS.
|
||||
stopGracePeriodMs: recording
|
||||
? RECORDING_STOP_GRACE_PERIOD_MS
|
||||
: SERVE_SIM_STOP_GRACE_PERIOD_MS,
|
||||
logger,
|
||||
secrets,
|
||||
});
|
||||
} catch (error) {
|
||||
// Nothing was spawned, so nothing can still write into the directory.
|
||||
@@ -380,14 +565,21 @@ export async function startDeviceSessionHostAsync(
|
||||
throw error;
|
||||
}
|
||||
|
||||
let stagedAppDirectory: string | undefined;
|
||||
let beforeLaunch: ReturnType<typeof asyncResult<void>> | undefined;
|
||||
const readinessController = new AbortController();
|
||||
const readinessSignal = AbortSignal.any([startup.signal, readinessController.signal]);
|
||||
let previewToken: string | undefined;
|
||||
let previewTask: Promise<DeviceWebPreview> | null = null;
|
||||
let finishTask: Promise<void> | null = null;
|
||||
let hostReady = false;
|
||||
let sessionPreview: ReturnType<typeof startDeviceRunSessionPreview> | null = null;
|
||||
const previewSetupController = new AbortController();
|
||||
let previewSetupTask: Promise<void> | undefined;
|
||||
let previewFailed = false;
|
||||
|
||||
const host: DeviceSessionHost = {
|
||||
openPreviewAsync({ baseDomain }) {
|
||||
openPreviewAsync({ baseDomain, authtoken }) {
|
||||
if (finishTask) {
|
||||
return Promise.reject(
|
||||
new SystemError('Cannot open a preview after session host finalization.')
|
||||
@@ -402,7 +594,7 @@ export async function startDeviceSessionHostAsync(
|
||||
subdomainPrefix: 'web-preview',
|
||||
subdomainId,
|
||||
baseDomain,
|
||||
authtoken: getNgrokAuthtokenOrThrow(env),
|
||||
authtoken,
|
||||
logger,
|
||||
});
|
||||
if (finishTask) {
|
||||
@@ -413,8 +605,10 @@ export async function startDeviceSessionHostAsync(
|
||||
throw new SystemError('Session host finalized while the preview was opening.');
|
||||
}
|
||||
let closeTask: Promise<void> | null = null;
|
||||
previewFailed = false;
|
||||
return {
|
||||
previewPageUrl,
|
||||
sharePageUrl,
|
||||
apiUrl: tunnel.url,
|
||||
previewToken,
|
||||
closeAsync() {
|
||||
@@ -433,6 +627,7 @@ export async function startDeviceSessionHostAsync(
|
||||
previewTask = opening;
|
||||
// A failed tunnel may be retried without restarting capture.
|
||||
void opening.catch(() => {
|
||||
previewFailed = true;
|
||||
if (previewTask === opening) {
|
||||
previewTask = null;
|
||||
}
|
||||
@@ -440,9 +635,14 @@ export async function startDeviceSessionHostAsync(
|
||||
return opening;
|
||||
},
|
||||
finishAsync() {
|
||||
readinessController.abort(new Error('Simulator preview ended.'));
|
||||
return (finishTask ??= finishDeviceSessionHostAsync(ctx, {
|
||||
previewTask,
|
||||
stopSessionPreviewAsync: async () => await sessionPreview?.stopAsync(),
|
||||
stopSessionPreviewAsync: async () => {
|
||||
previewSetupController.abort();
|
||||
await previewSetupTask;
|
||||
await sessionPreview?.stopAsync();
|
||||
},
|
||||
previewServer,
|
||||
screenshots,
|
||||
serverName,
|
||||
@@ -450,35 +650,135 @@ export async function startDeviceSessionHostAsync(
|
||||
// A host that never answered /readyz has nothing to finalize or upload.
|
||||
recording: hostReady ? recording : null,
|
||||
logger,
|
||||
}));
|
||||
artifactLogger,
|
||||
onStopped: successful => onFinished(successful && !previewFailed),
|
||||
})
|
||||
.finally(async () => {
|
||||
await beforeLaunch;
|
||||
if (stagedAppDirectory) {
|
||||
await fs.promises.rm(stagedAppDirectory, { recursive: true, force: true });
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
artifactLogger.warn({ err }, 'Could not finish the simulator preview.');
|
||||
onFinished(false);
|
||||
}));
|
||||
},
|
||||
};
|
||||
try {
|
||||
logger.info(`Waiting for ${serverName} to become ready.`);
|
||||
const device = await waitForWebPreviewReadyAsync({
|
||||
previewServer,
|
||||
serverName,
|
||||
port,
|
||||
timeoutMs,
|
||||
});
|
||||
hostReady = true;
|
||||
if (!isAndroid) {
|
||||
previewToken = await readServeSimPreviewTokenAsync(device);
|
||||
if (!previewToken) {
|
||||
throw new SystemError(
|
||||
`serve-sim became ready but wrote no session token for device ${device}. The preview is ` +
|
||||
'on a public tunnel and would be reachable without one, so the session cannot continue. ' +
|
||||
'This usually means the state file was not written as expected; retry the session, and ' +
|
||||
'report it if it repeats.'
|
||||
const readiness = asyncResult(
|
||||
waitForWebPreviewReadyAsync({
|
||||
previewServer,
|
||||
serverName,
|
||||
port,
|
||||
timeoutMs,
|
||||
startupTimeoutMs,
|
||||
signal: readinessSignal,
|
||||
}).catch(error => {
|
||||
startup.abort(error);
|
||||
throw error;
|
||||
})
|
||||
);
|
||||
if (iosSimulatorUdid) {
|
||||
previewToken = await waitForServeSimControlAsync({
|
||||
device: iosSimulatorUdid,
|
||||
port,
|
||||
previewServer,
|
||||
stateDir: serveSimStateDir,
|
||||
timeoutMs: startupTimeoutMs ?? timeoutMs,
|
||||
signal: readinessSignal,
|
||||
});
|
||||
secrets.push(previewToken);
|
||||
if (Object.keys(bootEnv).length > 0) {
|
||||
await startup.untilAborted(
|
||||
verifyLocalEgressGuardAsync({ udid: iosSimulatorUdid as IosSimulatorUuid, env, logger })
|
||||
);
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
beforeLaunch = asyncResult(
|
||||
startup.untilAborted(
|
||||
startup.run('Simulator setup', async taskLogger => {
|
||||
try {
|
||||
await IosSimulatorUtils.disableApsdAsync({
|
||||
udid: iosSimulatorUdid as IosSimulatorUuid,
|
||||
env,
|
||||
});
|
||||
} catch (err) {
|
||||
startup.signal.throwIfAborted();
|
||||
taskLogger.warn({ err }, 'Failed to disable apsd in the Simulator.');
|
||||
}
|
||||
startup.signal.throwIfAborted();
|
||||
})
|
||||
)
|
||||
);
|
||||
const app = (application ? await startup.untilAborted(application) : undefined) ?? {
|
||||
installAppPath,
|
||||
launchAppIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
};
|
||||
signal?.throwIfAborted();
|
||||
const action = (name: string, params: Record<string, unknown>) =>
|
||||
runServeSimActionAsync({
|
||||
port,
|
||||
token: previewToken!,
|
||||
action: name,
|
||||
params,
|
||||
timeoutMs: startupTimeoutMs ?? timeoutMs,
|
||||
signal: startup.signal,
|
||||
});
|
||||
if (app.installAppPath) {
|
||||
const staged = await stageServeSimAppAsync(app.installAppPath, serveSimTmpdir);
|
||||
stagedAppDirectory = staged.directory;
|
||||
signal?.throwIfAborted();
|
||||
await action('app.install', { udid: iosSimulatorUdid, path: staged.path });
|
||||
}
|
||||
(await beforeLaunch)?.enforceValue();
|
||||
if (app.launchAppIdentifier) {
|
||||
signal?.throwIfAborted();
|
||||
await action('app.launch', {
|
||||
udid: iosSimulatorUdid,
|
||||
bundleId: app.launchAppIdentifier,
|
||||
launchArgs: app.launchArgs ?? [],
|
||||
openUrl: app.openUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
logger.info(`Waiting for ${serverName} to become ready.`);
|
||||
const device = (await readiness).enforceValue();
|
||||
if (iosSimulatorUdid && device.toLowerCase() !== iosSimulatorUdid.toLowerCase()) {
|
||||
throw new SystemError(
|
||||
`serve-sim became ready on ${device}, but this session requested ${iosSimulatorUdid}.`
|
||||
);
|
||||
}
|
||||
hostReady = true;
|
||||
// Both servers record their session token in a state file once they are ready.
|
||||
if (isAndroid) {
|
||||
previewToken = await readExpoDeviceHubPreviewTokenAsync(port, EXPO_DEVICE_HUB_STATE_DIR);
|
||||
} else {
|
||||
previewToken ??= await readServeSimPreviewTokenAsync(device, serveSimStateDir);
|
||||
}
|
||||
if (!previewToken) {
|
||||
throw new SystemError(
|
||||
`${serverName} became ready but wrote no session token for device ${device}. The ` +
|
||||
'preview is on a public tunnel and would be reachable without one, so the session ' +
|
||||
'cannot continue. This usually means the state file was not written as expected; ' +
|
||||
'retry the session, and report it if it repeats.'
|
||||
);
|
||||
}
|
||||
secrets.push(previewToken);
|
||||
if (!isAndroid) {
|
||||
IosSimulatorRecordingUtils.useServeSimPackage(packageSpec);
|
||||
}
|
||||
// Android installed FFmpeg before launching the host. The optional thumbnail must not delay
|
||||
// readiness on macOS, so it starts after its encoder is installed there.
|
||||
void (async () => {
|
||||
previewSetupTask = (async () => {
|
||||
if (!isAndroid) {
|
||||
await ensureMacosPreviewEncoderInstalledAsync({ env, logger });
|
||||
await ensureMacosPreviewEncoderInstalledAsync({
|
||||
env,
|
||||
logger,
|
||||
signal: previewSetupController.signal,
|
||||
});
|
||||
}
|
||||
if (!finishTask) {
|
||||
sessionPreview = startDeviceRunSessionPreview({
|
||||
@@ -495,15 +795,60 @@ export async function startDeviceSessionHostAsync(
|
||||
});
|
||||
}
|
||||
})().catch(err => {
|
||||
logger.warn({ err }, 'Could not start refreshing the session preview.');
|
||||
if (!previewSetupController.signal.aborted) {
|
||||
logger.warn({ err }, 'Could not start refreshing the session preview.');
|
||||
}
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
return host;
|
||||
} catch (error) {
|
||||
onStartupError?.(error);
|
||||
await host.finishAsync();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForServeSimControlAsync({
|
||||
device,
|
||||
port,
|
||||
stateDir,
|
||||
previewServer,
|
||||
timeoutMs,
|
||||
signal,
|
||||
}: {
|
||||
device: string;
|
||||
port: number;
|
||||
stateDir: string;
|
||||
previewServer: DetachedProcessHandle;
|
||||
timeoutMs: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<string> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
signal?.throwIfAborted();
|
||||
if (
|
||||
previewServer.getExitError() !== undefined ||
|
||||
(previewServer.pid !== undefined && !isProcessRunning(previewServer.pid))
|
||||
) {
|
||||
throw new SystemError(
|
||||
`serve-sim exited before its control API became ready. ${previewServer.getOutput()}`
|
||||
);
|
||||
}
|
||||
const servers = await readServeSimServersAsync(stateDir);
|
||||
const server = servers.find(
|
||||
server =>
|
||||
server.udid.toLowerCase() === device.toLowerCase() &&
|
||||
server.url === `http://${WEB_PREVIEW_HOST}:${port}`
|
||||
);
|
||||
if (server?.token) {
|
||||
return server.token;
|
||||
}
|
||||
await sleepAsync(WEB_PREVIEW_READY_POLL_INTERVAL_MS);
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
throw new SystemError('Timed out waiting for serve-sim control readiness.');
|
||||
}
|
||||
|
||||
async function finishDeviceSessionHostAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
@@ -515,6 +860,8 @@ async function finishDeviceSessionHostAsync(
|
||||
port,
|
||||
recording,
|
||||
logger,
|
||||
artifactLogger,
|
||||
onStopped,
|
||||
}: {
|
||||
previewTask: Promise<DeviceWebPreview> | null;
|
||||
stopSessionPreviewAsync: () => Promise<void>;
|
||||
@@ -524,10 +871,13 @@ async function finishDeviceSessionHostAsync(
|
||||
port: number;
|
||||
recording: AndroidSessionRecording | null;
|
||||
logger: bunyan;
|
||||
artifactLogger: bunyan;
|
||||
onStopped: (successful: boolean) => void;
|
||||
}
|
||||
): Promise<void> {
|
||||
// Stop capturing before the host stops, so the last thumbnail shows the session, not shutdown.
|
||||
await stopSessionPreviewAsync();
|
||||
const hostExited = previewServer.getExitError() !== undefined;
|
||||
// Native ngrok operations have no scoped cancellation. Retire a late listener too.
|
||||
const retirePreview = withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Preview tunnel retirement', timeoutMs: 5_000 },
|
||||
@@ -536,9 +886,13 @@ async function finishDeviceSessionHostAsync(
|
||||
const preview = await previewTask?.catch(() => null);
|
||||
await preview?.closeAsync();
|
||||
}
|
||||
).catch(err => {
|
||||
logger.warn({ err }, `Could not close the ${serverName} preview tunnel within its deadline.`);
|
||||
});
|
||||
).then(
|
||||
() => true,
|
||||
err => {
|
||||
logger.warn({ err }, `Could not close the ${serverName} preview tunnel within its deadline.`);
|
||||
return false;
|
||||
}
|
||||
);
|
||||
let finalization: AndroidRecordingFinalization | null = null;
|
||||
if (recording) {
|
||||
// stopAsync signals the whole process group, including capture's encoder.
|
||||
@@ -559,18 +913,26 @@ async function finishDeviceSessionHostAsync(
|
||||
} catch (err) {
|
||||
logger.warn({ err }, `Could not stop the ${serverName} session host.`);
|
||||
}
|
||||
await retirePreview;
|
||||
const previewRetired = await retirePreview;
|
||||
onStopped(hostStopped && !hostExited && previewRetired);
|
||||
await screenshots.finishAsync(hostStopped);
|
||||
// A Hub that never recorded has logged its reason and left nothing to upload.
|
||||
const captured = finalization !== 'not-recording';
|
||||
let uploaded = false;
|
||||
if (recording && captured && hostStopped) {
|
||||
uploaded = await uploadFinishedAndroidRecordingAsync(ctx, { recording, logger });
|
||||
uploaded = await uploadFinishedAndroidRecordingAsync(ctx, {
|
||||
recording,
|
||||
logger: artifactLogger,
|
||||
});
|
||||
}
|
||||
if (recording && captured && (finalization === 'failed' || !uploaded)) {
|
||||
// The Hub reports capture failures on stderr; the stop route answers with only a summary.
|
||||
logger.warn(
|
||||
{ hostOutput: previewServer.getOutput().slice(-HOST_OUTPUT_TAIL_CHARS) || '<empty>' },
|
||||
artifactLogger.warn(
|
||||
{
|
||||
hostOutput:
|
||||
redactPreviewTokens(previewServer.getOutput()).slice(-HOST_OUTPUT_TAIL_CHARS) ||
|
||||
'<empty>',
|
||||
},
|
||||
'Session host output around the recording failure.'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { type BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export async function readIosApplicationIdentifierAsync({
|
||||
artifactPath,
|
||||
env,
|
||||
}: {
|
||||
artifactPath: string;
|
||||
env: BuildStepEnv;
|
||||
}): Promise<string> {
|
||||
const artifactStat = await fs.promises.stat(artifactPath).catch(err => {
|
||||
throw new UserError(
|
||||
'EAS_INSTALL_BUILD_INVALID_ARTIFACT',
|
||||
`Build artifact does not exist at ${artifactPath}.`,
|
||||
{ cause: err }
|
||||
);
|
||||
});
|
||||
if (path.extname(artifactPath) !== '.app' || !artifactStat.isDirectory()) {
|
||||
throw new UserError(
|
||||
'EAS_INSTALL_BUILD_INVALID_ARTIFACT',
|
||||
'iOS Simulator sessions require a .app build artifact.'
|
||||
);
|
||||
}
|
||||
const infoPlistPath = path.join(artifactPath, 'Info.plist');
|
||||
const { stdout } = await spawn(
|
||||
'plutil',
|
||||
['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', infoPlistPath],
|
||||
{ stdio: 'pipe', env }
|
||||
);
|
||||
const applicationIdentifier = stdout.trim();
|
||||
if (!applicationIdentifier) {
|
||||
throw new UserError(
|
||||
'EAS_INSTALL_BUILD_MISSING_IDENTIFIER',
|
||||
`Could not read CFBundleIdentifier from ${infoPlistPath}.`
|
||||
);
|
||||
}
|
||||
return applicationIdentifier;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { minBy } from 'lodash';
|
||||
|
||||
import {
|
||||
type IosSimulatorName,
|
||||
IosSimulatorUtils,
|
||||
type IosSimulatorUuid,
|
||||
} from '../../utils/IosSimulatorUtils';
|
||||
import {
|
||||
resolveLocalEgressServeSimBootEnvironmentAsync,
|
||||
startLocalEgressGuardRelayAsync,
|
||||
} from './localEgressGuard';
|
||||
import { readIosApplicationIdentifierAsync } from './iosAppArtifact';
|
||||
import {
|
||||
type ServeSimLaunchOptions,
|
||||
parseServeSimLaunchInputs,
|
||||
validateServeSimLaunchOptions,
|
||||
} from './remoteDeviceRunSession';
|
||||
|
||||
export type IosSessionStartup = {
|
||||
iosSimulatorUdid: string;
|
||||
installAppPath?: string;
|
||||
bootEnv?: Record<string, string>;
|
||||
};
|
||||
|
||||
export async function resolveIosSessionStartupAsync({
|
||||
runtimePlatform,
|
||||
bootSimulator,
|
||||
deviceIdentifier,
|
||||
installAppPath,
|
||||
launchAppIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
bootSimulator?: boolean;
|
||||
deviceIdentifier?: string;
|
||||
installAppPath?: string;
|
||||
launchAppIdentifier?: unknown;
|
||||
launchArgs?: unknown;
|
||||
openUrl?: unknown;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<{ launch: ServeSimLaunchOptions; iosStartup?: IosSessionStartup }> {
|
||||
signal?.throwIfAborted();
|
||||
if ((bootSimulator || installAppPath) && runtimePlatform !== BuildRuntimePlatform.DARWIN) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
'install_app_path is only supported for iOS Simulator sessions.'
|
||||
);
|
||||
}
|
||||
const applicationIdentifier =
|
||||
launchAppIdentifier ??
|
||||
(installAppPath
|
||||
? await readIosApplicationIdentifierAsync({ artifactPath: installAppPath, env })
|
||||
: undefined);
|
||||
const launch = parseServeSimLaunchInputs(
|
||||
{ launchAppIdentifier: applicationIdentifier, launchArgs, openUrl },
|
||||
{ runtimePlatform }
|
||||
);
|
||||
if (
|
||||
runtimePlatform !== BuildRuntimePlatform.DARWIN ||
|
||||
(!bootSimulator && !deviceIdentifier && !installAppPath)
|
||||
) {
|
||||
return { launch };
|
||||
}
|
||||
validateServeSimLaunchOptions(launch);
|
||||
signal?.throwIfAborted();
|
||||
let selectedIdentifier = (deviceIdentifier ?? undefined) as
|
||||
| IosSimulatorUuid
|
||||
| IosSimulatorName
|
||||
| undefined;
|
||||
if (selectedIdentifier === undefined) {
|
||||
const devices = await IosSimulatorUtils.getAvailableDevicesAsync({ env, filter: 'available' });
|
||||
selectedIdentifier = minBy(
|
||||
devices.filter(device => device.name.startsWith('iPhone')),
|
||||
device => device.name.length
|
||||
)?.udid;
|
||||
}
|
||||
if (!selectedIdentifier) {
|
||||
throw new Error('Could not find an iPhone among available simulator devices.');
|
||||
}
|
||||
const iosSimulatorUdid = (
|
||||
await IosSimulatorUtils.resolveUdidAsync({ deviceIdentifier: selectedIdentifier, env })
|
||||
).toUpperCase() as IosSimulatorUuid;
|
||||
const bootEnv = await resolveLocalEgressServeSimBootEnvironmentAsync();
|
||||
signal?.throwIfAborted();
|
||||
if (bootEnv) {
|
||||
const device = await IosSimulatorUtils.getDeviceAsync({ udid: iosSimulatorUdid, env });
|
||||
signal?.throwIfAborted();
|
||||
if (device && device.state !== 'Shutdown') {
|
||||
logger.info(`Shutting down ${iosSimulatorUdid} to apply the local egress guard at boot.`);
|
||||
await spawn('xcrun', ['simctl', 'shutdown', iosSimulatorUdid], { env, logger, signal });
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
await startLocalEgressGuardRelayAsync({ logger });
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
return {
|
||||
launch,
|
||||
iosStartup: { iosSimulatorUdid, installAppPath, bootEnv: bootEnv ?? undefined },
|
||||
};
|
||||
}
|
||||
Loaded 100 of 272 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user