mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
187
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b836ef30c | ||
|
|
bcb5d96ea8 | ||
|
|
d02de4da64 | ||
|
|
04c214be93 | ||
|
|
1d9607ca00 | ||
|
|
78ba76001b | ||
|
|
3c8b62fb39 | ||
|
|
c3a26fef34 | ||
|
|
a2c0d0cf09 | ||
|
|
f148570a36 | ||
|
|
e8ca106f2e | ||
|
|
62ddc91aa4 | ||
|
|
9b54c203ee | ||
|
|
a0fe3ff2c3 | ||
|
|
fb383d818b | ||
|
|
c07c5edba9 | ||
|
|
56646b7436 | ||
|
|
f775b10549 | ||
|
|
c756c5d0b5 | ||
|
|
9f674b1d19 | ||
|
|
6f33a4d2a4 | ||
|
|
44b63e5a7d | ||
|
|
42e4fa8a7c | ||
|
|
e4bb181802 | ||
|
|
53ac936b11 | ||
|
|
c0d3f4f935 | ||
|
|
5ec3d84fcb | ||
|
|
d0cb5a2d67 | ||
|
|
aae7d13ac0 | ||
|
|
d9b1f866d4 | ||
|
|
a03dca45eb | ||
|
|
f949f3430e | ||
|
|
e61a563b70 | ||
|
|
150cd21174 | ||
|
|
4b9e792e91 | ||
|
|
0199cd9331 | ||
|
|
ffe6bda88d | ||
|
|
904859b824 | ||
|
|
3fd966bb9e | ||
|
|
aa9f5370d4 | ||
|
|
60a0b483eb | ||
|
|
070f15c633 | ||
|
|
c8aa5eb05d | ||
|
|
68596f8c0c | ||
|
|
1e38ada929 | ||
|
|
d52629e9f3 | ||
|
|
bed6354ef5 | ||
|
|
9aac1a0dff | ||
|
|
c1c2b748b4 | ||
|
|
7b39b92df8 | ||
|
|
929905ba7e | ||
|
|
ef7fceba86 | ||
|
|
c2dc766c38 | ||
|
|
785cfe2d53 | ||
|
|
18a008a39a | ||
|
|
f5be6b8d88 | ||
|
|
c4064ea9ba | ||
|
|
f1dda5df94 | ||
|
|
4ec2a5fe9e | ||
|
|
1068fab6ac | ||
|
|
3e00e86e5a | ||
|
|
69a4a06320 | ||
|
|
219df86f88 | ||
|
|
50e1f2aea5 | ||
|
|
4400b93ed3 | ||
|
|
52c1a4ce54 | ||
|
|
f97e6ecfa8 | ||
|
|
6d8c29cc34 | ||
|
|
2630625440 | ||
|
|
7ebd9f1a97 | ||
|
|
8545feb433 | ||
|
|
14e14e3017 | ||
|
|
338d367282 | ||
|
|
102f06cc50 | ||
|
|
f96c266645 | ||
|
|
5739556d67 | ||
|
|
821dcd1a96 | ||
|
|
3b59a744da | ||
|
|
73edec4d68 | ||
|
|
13fe586f3f | ||
|
|
c3413315d1 | ||
|
|
6c256fd9d2 | ||
|
|
6683492955 | ||
|
|
964b4ed93f | ||
|
|
34e8b45bad | ||
|
|
d1de958201 | ||
|
|
2b672fc07f | ||
|
|
8453efcfb6 | ||
|
|
0456e8eea6 | ||
|
|
0ec9a81552 | ||
|
|
3b4fedeee5 | ||
|
|
570617f467 | ||
|
|
8262aa34c6 | ||
|
|
1f72736126 | ||
|
|
3328b60f85 | ||
|
|
4abb8601d5 | ||
|
|
9a881d8366 | ||
|
|
657034a1a6 | ||
|
|
bb98f5beef | ||
|
|
4aca8b1cc4 | ||
|
|
800a0e0893 | ||
|
|
2911b04ea1 | ||
|
|
50b381abe0 | ||
|
|
c8696f6012 | ||
|
|
45d795e076 | ||
|
|
8e98a915b9 | ||
|
|
73c65d3edb | ||
|
|
01e3be71aa | ||
|
|
991bbeae8c | ||
|
|
6c342abb97 | ||
|
|
1016aa35f3 | ||
|
|
ceb8f846c2 | ||
|
|
53cb3a7263 | ||
|
|
09188ab271 | ||
|
|
c4ffc1605e | ||
|
|
d90f022966 | ||
|
|
8b8362e4ae | ||
|
|
b5ac163b37 | ||
|
|
92c8944cda | ||
|
|
96581b8094 | ||
|
|
e229f5378f | ||
|
|
19657cc7ff | ||
|
|
be7acb7ab5 | ||
|
|
3c302a055d | ||
|
|
449bd4981e | ||
|
|
4fd36987ba | ||
|
|
01c1ffcaf4 | ||
|
|
b10cc829bd | ||
|
|
3d8ed4c81f | ||
|
|
a344c0e1e8 | ||
|
|
f80f548e2f | ||
|
|
c72ae9f9f6 | ||
|
|
bf0420cd29 | ||
|
|
738ccb1ff6 | ||
|
|
c487bcf1df | ||
|
|
bcaaaddab8 | ||
|
|
4e432fa537 | ||
|
|
817d06e66e | ||
|
|
76a9d17ea7 | ||
|
|
8ce87bfa56 | ||
|
|
2b7e8454e3 | ||
|
|
962a2a733e | ||
|
|
9e13c1e820 | ||
|
|
cadb7b2d1e | ||
|
|
718c82d8e1 | ||
|
|
5d31ecc2bc | ||
|
|
890286a08d | ||
|
|
33ff59a75e | ||
|
|
c2bbba6e78 | ||
|
|
86b85e7d8d | ||
|
|
03a7d3a6ee | ||
|
|
a21db49184 | ||
|
|
6dd6da083d | ||
|
|
656921d776 | ||
|
|
fabde0c2bb | ||
|
|
7680f33879 | ||
|
|
199a996b38 | ||
|
|
4728c64d3f | ||
|
|
0fdf96e795 | ||
|
|
04f3bee0c4 | ||
|
|
140cfe445a | ||
|
|
da55f26cac | ||
|
|
e24130c6e0 | ||
|
|
2a0fe12836 | ||
|
|
4c153da14d | ||
|
|
81166df987 | ||
|
|
ef189b00be | ||
|
|
27e56afd12 | ||
|
|
1c342621c0 | ||
|
|
9b7098cb58 | ||
|
|
9b247a6197 | ||
|
|
862d0a8f09 | ||
|
|
417996ade0 | ||
|
|
1c6161432a | ||
|
|
3f667ae4a8 | ||
|
|
4fb6de564c | ||
|
|
975693e96e | ||
|
|
b84233bf07 | ||
|
|
6dec2f9a13 | ||
|
|
c15e184bb8 | ||
|
|
89740dbbfd | ||
|
|
390a165c57 | ||
|
|
f7fb43b974 | ||
|
|
a32080fadf | ||
|
|
55017fbf70 | ||
|
|
7fd8a3a75b | ||
|
|
0f38c2ad82 |
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,5 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Regenerate graphql code
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Default Android emulators to 4 GiB of RAM to reduce ADB disconnects during large transfers. Retain the 2 GiB default for API 34 and below outside interactive device sessions, and preserve explicit emulator configuration overrides.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@expo/eas-build-job': minor
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/eas-json': minor
|
||||
---
|
||||
|
||||
Add `permissions.account.role` to build profiles to request a role for the build's `EXPO_TOKEN`.
|
||||
@@ -0,0 +1,11 @@
|
||||
export declare function getAttributionAsync(changeset: {
|
||||
id: string;
|
||||
commit?: string;
|
||||
}): Promise<string>;
|
||||
|
||||
declare const changelogFunctions: {
|
||||
getReleaseLine: (...args: never[]) => Promise<string>;
|
||||
getDependencyReleaseLine: (...args: never[]) => Promise<string>;
|
||||
};
|
||||
|
||||
export default changelogFunctions;
|
||||
@@ -0,0 +1,50 @@
|
||||
import { getCommitInfo } from '@changesets/get-github-info';
|
||||
|
||||
import { formatEntry } from './formatEntry.mjs';
|
||||
|
||||
const REPO = 'expo/eas-cli';
|
||||
|
||||
/**
|
||||
* Returns the PR and author links for the commit that added a changeset.
|
||||
*
|
||||
* @param {{ id: string, commit?: string }} changeset
|
||||
* @returns {Promise<string>} For example `([#123](https://...) by [@user](https://...))`.
|
||||
*/
|
||||
export async function getAttributionAsync(changeset) {
|
||||
if (!changeset.commit) {
|
||||
throw new Error(
|
||||
`Changeset .changeset/${changeset.id}.md is not committed. Commit it, then run the release again.`
|
||||
);
|
||||
}
|
||||
const info = await getCommitInfo({ repo: REPO, commit: changeset.commit });
|
||||
if (info?.pull && info.author) {
|
||||
return `(${info.pull.markdownLink} by ${info.author.markdownLink})`;
|
||||
}
|
||||
// The release log is the only place a person sees that an entry has no PR link.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`No pull request found on GitHub for commit ${changeset.commit} (changeset ${changeset.id}). The entry links the commit instead.`
|
||||
);
|
||||
const commitLink =
|
||||
info?.commit.markdownLink ??
|
||||
`[\`${changeset.commit.slice(0, 7)}\`](https://github.com/${REPO}/commit/${changeset.commit})`;
|
||||
return info?.author ? `(${commitLink} by ${info.author.markdownLink})` : `(${commitLink})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ id: string, summary: string, commit?: string }} changeset
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function getReleaseLineAsync(changeset) {
|
||||
return `- ${formatEntry(changeset.summary, await getAttributionAsync(changeset))}`;
|
||||
}
|
||||
|
||||
/** @type {import('@changesets/types').ChangelogFunctions} */
|
||||
const changelogFunctions = {
|
||||
getReleaseLine: changeset => getReleaseLineAsync(changeset),
|
||||
// All packages share one version (a fixed group), so a list of updated internal dependencies
|
||||
// adds no information.
|
||||
getDependencyReleaseLine: () => Promise.resolve(''),
|
||||
};
|
||||
|
||||
export default changelogFunctions;
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/[email protected]/schema.json",
|
||||
"changelog": "./changelog.mjs",
|
||||
"commit": false,
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"format": "oxfmt",
|
||||
"fixed": [
|
||||
[
|
||||
"eas-cli",
|
||||
"@expo/eas-json",
|
||||
"eas-build-cache-provider",
|
||||
"@expo/eas-build-job",
|
||||
"@expo/build-tools",
|
||||
"@expo/steps",
|
||||
"eas-cli-local-build-plugin",
|
||||
"@expo/worker",
|
||||
"@expo/logger",
|
||||
"@expo/downloader",
|
||||
"@expo/turtle-spawn",
|
||||
"@expo/template-file",
|
||||
"create-eas-build-function"
|
||||
]
|
||||
],
|
||||
"linked": [],
|
||||
"ignore": [],
|
||||
"privatePackages": {
|
||||
"version": true,
|
||||
"tag": false
|
||||
},
|
||||
"updateInternalDependencies": "patch"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Retry transient failures when reporting that a device run session has started.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Retry the build lookup and the archive download of `eas/download_build` on transient failures, and name the failure cause in the build log.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Read the app config fewer times in `eas build` and other project commands. An iOS build now runs `expo config` 2 times instead of 6.
|
||||
@@ -0,0 +1 @@
|
||||
export declare function formatEntry(summary: string, attribution: string): string;
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Formats a changeset summary as the text of a CHANGELOG list item.
|
||||
*
|
||||
* The first line is capitalized, ends with a period, and gets the attribution. Later lines are
|
||||
* indented by two spaces, so they stay inside the list item.
|
||||
*
|
||||
* @param {string} summary The changeset summary.
|
||||
* @param {string} attribution For example `([#123](https://...) by [@user](https://...))`.
|
||||
* @returns {string} The entry text, without the leading `- `.
|
||||
*/
|
||||
export function formatEntry(summary, attribution) {
|
||||
const [firstLine, ...otherLines] = summary
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map(line => line.trimEnd());
|
||||
if (!firstLine) {
|
||||
throw new Error('The changeset summary is empty. Describe the change below the front matter.');
|
||||
}
|
||||
const capitalizedLine = `${firstLine[0].toUpperCase()}${firstLine.slice(1)}`;
|
||||
const sentence = capitalizedLine.endsWith('.') ? capitalizedLine : `${capitalizedLine}.`;
|
||||
const indentedLines = otherLines.map(line => (line ? ` ${line}` : ''));
|
||||
return [`${sentence} ${attribution}`, ...indentedLines].join('\n');
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': none
|
||||
---
|
||||
|
||||
Make command descriptions in `eas --help` consistently lowercase without trailing periods.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
'@expo/steps': patch
|
||||
---
|
||||
|
||||
Stop printing workflow hook parsing messages in build logs: native builds showed them under an "Unknown build phase" group, and custom builds warned about unknown hook keys and hooks on steps that never run.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'eas-cli': none
|
||||
'@expo/build-tools': none
|
||||
---
|
||||
|
||||
Bump Node.js to 22.23.4 to fix the CVEs in the [October 2026 Node.js security releases](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases).
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/worker': patch
|
||||
---
|
||||
|
||||
Retry Node.js installation without the internal Node.js cache mirror when the first attempt fails, while preserving other configured mirrors.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': minor
|
||||
---
|
||||
|
||||
Stop sandboxes after an optional maximum duration measured from readiness.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Share one GraphQL client between the project ID check and the command, so commands send one fewer app query.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Show why a simulator session failed in `eas simulator:get`, and add an `errors` array to its `--json` output. For an unknown error, `simulator:start` and `simulator:get` now show the failed step's error from the session logs.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
Add `--os-version` to `eas simulator` to select the iOS Simulator runtime version.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Show why a simulator session failed in `eas simulator:start`, such as an unknown `--device` name, instead of only the session status.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': minor
|
||||
---
|
||||
|
||||
Unify signed URL uploading to a single implementation and use it everywhere
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/worker': patch
|
||||
---
|
||||
|
||||
Mask secrets in every field of uploaded job log records, such as logged errors, not only in log messages.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@expo/eas-build-job': major
|
||||
'@expo/build-tools': major
|
||||
---
|
||||
|
||||
Remove legacy artifact type exports and route workflow uploads through workflow storage without file-type categories. Preserve workflow uploads and Maestro reports.
|
||||
@@ -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
|
||||
@@ -0,0 +1,43 @@
|
||||
name: Test recording recovery
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: &recording-paths
|
||||
- packages/build-tools/src/steps/utils/deviceRunSessionScreenRecordings.ts
|
||||
- packages/build-tools/src/steps/utils/deviceRunSessionArtifacts.ts
|
||||
- packages/build-tools/src/steps/utils/__tests__/*.test.ffmpeg.ts
|
||||
- packages/build-tools/jest/ffmpeg-config.ts
|
||||
- .github/workflows/test-recording-recovery.yml
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
paths: *recording-paths
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: test-recording-recovery-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
name: Test recording recovery with ffmpeg
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup tools
|
||||
uses: ./.github/actions/setup-mise
|
||||
- name: Install ffmpeg
|
||||
# The recording recovery test cuts and decodes real MP4 files with ffmpeg and ffprobe.
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Build build-tools dependencies
|
||||
# tsc emits the dist files that the test imports from workspace packages such as @expo/logger.
|
||||
run: yarn run -T lerna run typecheck --scope @expo/build-tools --include-dependencies
|
||||
- name: Run recording recovery test
|
||||
working-directory: packages/build-tools
|
||||
run: yarn jest-ffmpeg
|
||||
@@ -9,9 +9,13 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'scripts/**'
|
||||
- '.changeset/*.mjs'
|
||||
- '.changeset/*.d.mts'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'scripts/**'
|
||||
- '.changeset/*.mjs'
|
||||
- '.changeset/*.d.mts'
|
||||
|
||||
concurrency:
|
||||
group: test-scripts-${{ github.ref }}
|
||||
@@ -26,3 +30,4 @@ jobs:
|
||||
uses: ./.github/actions/setup-mise
|
||||
- run: yarn install --immutable
|
||||
- run: yarn typecheck
|
||||
- run: yarn test
|
||||
@@ -15,9 +15,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: ['20.18.3', '22', '24']
|
||||
node: ['22', '24']
|
||||
include:
|
||||
- node: '20.18.3'
|
||||
- node: '22'
|
||||
coverage: true
|
||||
name: Test with Node ${{ matrix.node }}
|
||||
steps:
|
||||
@@ -71,8 +71,6 @@ jobs:
|
||||
if: ${{ matrix.coverage }}
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
- if: ${{ matrix.coverage }}
|
||||
run: yarn lint-changelog
|
||||
|
||||
notify-slack:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
type: string
|
||||
required: false
|
||||
description: 'Version number for the release'
|
||||
dry_run:
|
||||
type: boolean
|
||||
required: true
|
||||
@@ -19,7 +15,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.EXPO_BOT_PAT }}
|
||||
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
@@ -29,7 +24,7 @@ jobs:
|
||||
token: ${{ secrets.EXPO_BOT_PAT }}
|
||||
- uses: ./.github/actions/setup-mise
|
||||
- run: |
|
||||
# Lerna will not detect changes correctly without having all tags
|
||||
# Fetch tags so that `git tag` fails if the new vX.Y.Z tag already exists
|
||||
git fetch --depth=1 origin +refs/tags/*:refs/tags/*
|
||||
# Checkout the main branch
|
||||
git checkout main
|
||||
@@ -37,10 +32,5 @@ jobs:
|
||||
git config --global user.name "Expo CI"
|
||||
yarn install --immutable
|
||||
yarn build
|
||||
if [[ "$INPUT_VERSION" == "" ]]; then
|
||||
echo "Releasing with default version"
|
||||
yarn release
|
||||
else
|
||||
echo "Releasing with version $INPUT_VERSION"
|
||||
yarn release "$INPUT_VERSION"
|
||||
fi
|
||||
# The changesets in .changeset/ set the version bump.
|
||||
yarn release
|
||||
+207
-4
@@ -2,16 +2,213 @@
|
||||
|
||||
This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
## main
|
||||
|
||||
### 🛠 Breaking changes
|
||||
## [25.3.0](https://github.com/expo/eas-cli/releases/tag/v25.3.0) - 2026-10-20
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add a Free plan build limit checkout experiment to `eas build` that offers to open the Starter plan checkout. ([#4633](https://github.com/expo/eas-cli/pull/4633) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- [build-tools] Let serve-sim boot, install and launch Argent iOS sessions. ([#4602](https://github.com/expo/eas-cli/pull/4602) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Let serve-sim boot, install and launch Appium iOS sessions. ([#4603](https://github.com/expo/eas-cli/pull/4603) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Upload the screen recording session manifest as a JSON artifact alongside each video. ([#4689](https://github.com/expo/eas-cli/pull/4689) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add a `--system-image` flag to `eas simulator:start` to select the Android SDK system image for the emulator. ([#4500](https://github.com/expo/eas-cli/pull/4500) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Quote Android launch arguments and URLs for the device shell in simulator sessions, so values with spaces or shell characters reach `am start` as one argument; pass raw values and remove any shell quoting added as a workaround. ([#4695](https://github.com/expo/eas-cli/pull/4695) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [build-tools] Wait up to five minutes for Google Play to finish processing an uploaded bundle before giving up on the edit commit. ([#4700](https://github.com/expo/eas-cli/pull/4700) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [build-tools] Reset the device run session idle timer on iOS Simulator preview input. ([#4691](https://github.com/expo/eas-cli/pull/4691) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Forward captured Simulator traffic through the local egress proxy. ([#4598](https://github.com/expo/eas-cli/pull/4598) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [25.2.0](https://github.com/expo/eas-cli/releases/tag/v25.2.0) - 2026-10-17
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add experimentation client. ([#4558](https://github.com/expo/eas-cli/pull/4558) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- [eas-cli] Add a Free plan build limit guidance experiment for coding agents to `eas build`. ([#4631](https://github.com/expo/eas-cli/pull/4631) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- [build-tools] Let serve-sim boot, install and launch agent-device iOS sessions with the local-egress guard. ([#4544](https://github.com/expo/eas-cli/pull/4544) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `eas simulator:control` to run serve-sim actions, with file upload and download, on a remote iOS simulator session. ([#4641](https://github.com/expo/eas-cli/pull/4641) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Prepare authenticated simulator action transport and file transfers. ([#4642](https://github.com/expo/eas-cli/pull/4642) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [25.1.1](https://github.com/expo/eas-cli/releases/tag/v25.1.1) - 2026-10-16
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Call www only through the device run session worker API in device run session jobs, and remove their GraphQL fallbacks. ([#4638](https://github.com/expo/eas-cli/pull/4638) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [25.1.0](https://github.com/expo/eas-cli/releases/tag/v25.1.0) - 2026-10-16
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Show EAS Simulator minutes in `eas account:usage`. ([#4583](https://github.com/expo/eas-cli/pull/4583) by [@LipLoody](https://github.com/LipLoody))
|
||||
- [build-tools] Pass the `cors_origins` session input to serve-sim and expo-device-hub as `--cors-origin` origins, so a third party page can call the preview host API, for example with `@expo/hub-client`. ([#4657](https://github.com/expo/eas-cli/pull/4657) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
- [build-tools] Pass the `frame_ancestors` session input to serve-sim and expo-device-hub as `--frame-ancestor` origins, so a third party can embed the preview host. ([#4655](https://github.com/expo/eas-cli/pull/4655) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
- [eas-cli] Add `--cors-origin` to `eas simulator:start`, so a third party page can call the simulator preview API, for example with `@expo/hub-client`. ([#4658](https://github.com/expo/eas-cli/pull/4658) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
- [eas-cli] Add `--frame-ancestor` to `eas simulator:start`, so a third party site can embed the simulator preview, and print the embeddable preview URL. ([#4656](https://github.com/expo/eas-cli/pull/4656) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Allocate 4 GiB of guest RAM to Android emulators in EAS Simulator sessions. ([#4640](https://github.com/expo/eas-cli/pull/4640) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Retry transient Google Play API failures with exponential backoff while keeping uncertain commit results explicit. ([#4629](https://github.com/expo/eas-cli/pull/4629) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Retry Google Play edit commits when an uploaded Android App Bundle is still processing, with bounded retries and progress warnings. ([#4651](https://github.com/expo/eas-cli/pull/4651) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Report Google Play error responses to Sentry so unexplained submission failures can be classified. ([#4652](https://github.com/expo/eas-cli/pull/4652) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [25.0.0](https://github.com/expo/eas-cli/releases/tag/v25.0.0) - 2026-10-15
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- [eas-cli] Default new simulator sessions to authenticated preview links and omit preview credentials from private output; use `simulator:start --public-link` for public sharing, while `simulator:get` follows the saved preference. ([#4619](https://github.com/expo/eas-cli/pull/4619) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Start `expo-device-hub` for Android simulator sessions with `--require-token` and the website origins, as serve-sim starts for iOS, and report its session token. ([#4578](https://github.com/expo/eas-cli/pull/4578) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
- [build-tools] Respect saved sharing preferences for iOS simulator preview sessions. ([#4620](https://github.com/expo/eas-cli/pull/4620) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Give agent-device and its simulator preview separate session logs. ([#4585](https://github.com/expo/eas-cli/pull/4585) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Give Appium and its simulator preview separate session logs. ([#4586](https://github.com/expo/eas-cli/pull/4586) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Give Argent and its simulator preview separate session logs. ([#4587](https://github.com/expo/eas-cli/pull/4587) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Let serve-sim start the iOS Simulator for web-preview sessions. ([#4601](https://github.com/expo/eas-cli/pull/4601) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Stream simulator preview output into its existing session step. ([#4525](https://github.com/expo/eas-cli/pull/4525) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Use effectivePermissions for logic about capabilities and account:view display. ([#4637](https://github.com/expo/eas-cli/pull/4637) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Speed up cold iOS Appium sessions by launching preinstalled WebDriverAgent. ([#4618](https://github.com/expo/eas-cli/pull/4618) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools][worker] Call the device run session worker API with the session token when www provides one. ([#4615](https://github.com/expo/eas-cli/pull/4615) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools][eas-json] Clarify Google Play submission messages and reject zero Android submission rollouts. ([#4625](https://github.com/expo/eas-cli/pull/4625) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] Update error message for scoped VC in EAS CLI now that scoped can be used outside build. ([#4636](https://github.com/expo/eas-cli/pull/4636) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [build-tools][worker] Mask the credentials `eas/run_agent` leases with the build log secrets filter instead of redacting them in the step. ([#4624](https://github.com/expo/eas-cli/pull/4624) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [build-tools] Add shared opt-in serve-sim Simulator startup support. ([#4605](https://github.com/expo/eas-cli/pull/4605) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [24.14.0](https://github.com/expo/eas-cli/releases/tag/v24.14.0) - 2026-10-14
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--platform` to `eas workflow:insights:maestro` and list Maestro runs and flows per platform. ([#4613](https://github.com/expo/eas-cli/pull/4613) by [@hSATAC](https://github.com/hSATAC))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Allow more time for iOS screen recordings to finalize during session shutdown. ([#4626](https://github.com/expo/eas-cli/pull/4626) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Fix the `channel:protect` and `channel:unprotect` descriptions to include Release Managers and Owners, not only admins. ([#4616](https://github.com/expo/eas-cli/pull/4616) by [@byronkarlen](https://github.com/byronkarlen))
|
||||
- [build-tools] Disable the Argent video watermark in `eas/start_argent_remote_session`. ([#4574](https://github.com/expo/eas-cli/pull/4574) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools] Download the upterm binary through the build cache proxy. ([#4442](https://github.com/expo/eas-cli/pull/4442) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
## [24.13.0](https://github.com/expo/eas-cli/releases/tag/v24.13.0) - 2026-10-12
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Let the MCP server read text and image files from sandboxes. ([#4555](https://github.com/expo/eas-cli/pull/4555) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- [eas-cli][local-build-plugin] Deprecate Node.js 20, and print a warning when running on it. A future release will require Node.js 22 or newer. Development and CI now use Node.js 22. ([#4579](https://github.com/expo/eas-cli/pull/4579) by [@douglowder](https://github.com/douglowder))
|
||||
- [build-tools] Add `eas/run_agent`, a step that runs Claude Code for an agent run and connects it to the Expo MCP server. ([#4552](https://github.com/expo/eas-cli/pull/4552) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [build-tools] Support Codex in `eas/run_agent`. ([#4553](https://github.com/expo/eas-cli/pull/4553) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [build-tools] Write the agent's messages, tool calls, tool results and errors to the `eas/run_agent` job log as plain lines instead of raw JSON events. ([#4568](https://github.com/expo/eas-cli/pull/4568) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [eas-cli] Show the average and P75 end-to-end run duration in `eas workflow:insights`. ([#4566](https://github.com/expo/eas-cli/pull/4566) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [eas-cli] Ask for the App Store Connect API key type (team or individual) in the submission key prompt and skip the Issuer ID prompt for individual keys. ([#4589](https://github.com/expo/eas-cli/pull/4589) by [@sswrk](https://github.com/sswrk))
|
||||
- [build-tools] Add `eas/download_submission_archive` to request a fresh archive URL when the step runs. ([#4539](https://github.com/expo/eas-cli/pull/4539) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-build-job] Add an `uploadArtifact` command to the sandbox daemon protocol. ([#4562](https://github.com/expo/eas-cli/pull/4562) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- [build-tools] Let connected sandboxes upload files as sandbox artifacts through the Expo MCP server. ([#4563](https://github.com/expo/eas-cli/pull/4563) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Look up ffmpeg on PATH instead of running it. ([#4582](https://github.com/expo/eas-cli/pull/4582) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Stop reporting individual App Store Connect API keys as not valid on Apple's servers when adding them in `eas credentials`. ([#4588](https://github.com/expo/eas-cli/pull/4588) by [@sswrk](https://github.com/sswrk))
|
||||
- [build-tools] Install ffmpeg for Appium simulator sessions. ([#4577](https://github.com/expo/eas-cli/pull/4577) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Close the file of an artifact upload before reporting that the upload is done. ([#4609](https://github.com/expo/eas-cli/pull/4609) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
## [24.12.1](https://github.com/expo/eas-cli/releases/tag/v24.12.1) - 2026-10-08
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Stop a simulator session that the server creates after Ctrl+C lands during `eas simulator` create. ([#4522](https://github.com/expo/eas-cli/pull/4522) by [@sjkim-expo](https://github.com/sjkim-expo))
|
||||
|
||||
## [24.12.0](https://github.com/expo/eas-cli/releases/tag/v24.12.0) - 2026-10-07
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Fix `eas workflow:ssh` handling of missing builds IDs. ([#4529](https://github.com/expo/eas-cli/pull/4529) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- [build-tools] Upload the embedded bundle from custom builds that use `eas/build`. ([#4494](https://github.com/expo/eas-cli/pull/4494) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Add the `eas/upload_embedded_bundle` step for custom build configs. ([#4494](https://github.com/expo/eas-cli/pull/4494) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Include `assetMapUrl` and `sourceMapUrl` in the `eas update:view --json` output. Each value is a signed download URL, or null when the update was published without that file. ([#4534](https://github.com/expo/eas-cli/pull/4534) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Improve the embedded bundle upload output in build logs. ([#4557](https://github.com/expo/eas-cli/pull/4557) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Allow the unnotarized mitmproxy cask to run in iOS network capture sessions. ([#4556](https://github.com/expo/eas-cli/pull/4556) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Encode macOS simulator session previews with cwebp, because Homebrew's FFmpeg cannot encode WebP. ([#4549](https://github.com/expo/eas-cli/pull/4549) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools] Open Android URLs with a VIEW intent instead of pinning them to the launch activity. ([#4559](https://github.com/expo/eas-cli/pull/4559) by [@kudo](https://github.com/kudo))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Log analytics events when an `eas simulator` session request is sent, cancelled, or fails before the server answers. ([#4483](https://github.com/expo/eas-cli/pull/4483) by [@sjkim-expo](https://github.com/sjkim-expo))
|
||||
|
||||
## [24.11.0](https://github.com/expo/eas-cli/releases/tag/v24.11.0) - 2026-10-05
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Record iOS Simulator sessions through serve-sim. ([#4474](https://github.com/expo/eas-cli/pull/4474) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add browser device login with resumable commands for chat agents. ([#4502](https://github.com/expo/eas-cli/pull/4502) by [@eiiot](https://github.com/eiiot))
|
||||
- [build-tools] Upload the display rotation agent-device reports for a screenshot as device run session artifact metadata. ([#4508](https://github.com/expo/eas-cli/pull/4508) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools] Refresh a WebP thumbnail of the simulator session screen every minute as a reusable session preview artifact. ([#4398](https://github.com/expo/eas-cli/pull/4398) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Select the containing iOS app when its archive also includes a nested App Clip. ([#4541](https://github.com/expo/eas-cli/pull/4541) by [@mozzius](https://github.com/mozzius))
|
||||
- [build-tools] Allow expo.test subdomains to open local and staging simulator previews. ([#4480](https://github.com/expo/eas-cli/pull/4480) by [@hirbod](https://github.com/hirbod))
|
||||
- [build-tools] Retry App Store Connect connection failures and safely recover interrupted status reads and upload commits. ([#4520](https://github.com/expo/eas-cli/pull/4520) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [build-tools] Remove the `eas/start_agent_device_remote_session` step; agent-device sessions use `eas/start_agent_device_session`. ([#4518](https://github.com/expo/eas-cli/pull/4518) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Report `eas-cli` as the request origin, and the coding agent when one is detected, when `eas simulator` creates a session, for analytics. ([#4523](https://github.com/expo/eas-cli/pull/4523) by [@sjkim-expo](https://github.com/sjkim-expo))
|
||||
|
||||
## [24.10.0](https://github.com/expo/eas-cli/releases/tag/v24.10.0) - 2026-10-02
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--network-capture` to `eas simulator` to record HTTP(S) traffic from apps on the device, and `--network-capture-field` to also keep headers, query values, or request and response bodies. ([#4308](https://github.com/expo/eas-cli/pull/4308) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [24.9.0](https://github.com/expo/eas-cli/releases/tag/v24.9.0) - 2026-10-02
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--force-end-active-rollout` to `eas update`, `eas update:republish`, `eas update:roll-back-to-embedded` and `eas update:rollback`. ([#4233](https://github.com/expo/eas-cli/pull/4233) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Allow `eas simulator:start --platform android --egress local`. On Android, `--egress-allow localhost:<port>` also covers `10.0.2.2:<port>`, and refused destinations are shown as warnings. ([#4519](https://github.com/expo/eas-cli/pull/4519) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [build-tools] Support `eas/start_local_egress` on Linux for Android emulators: an always-listening relay for the emulator's proxy, a systemd scope and nftables fence that refuse traffic that cannot use the tunnel, and fence refusals in the session log. ([#4519](https://github.com/expo/eas-cli/pull/4519) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Add `--upload-source-maps` to `eas update`, which uploads the generated source maps to EAS so that stack traces from an update can be symbolicated. Source maps are stripped of `sourcesContent` before upload. ([#4436](https://github.com/expo/eas-cli/pull/4436) by [@douglowder](https://github.com/douglowder))
|
||||
- [build-tools] Upload the embedded bundle after build by default for projects on SDK 58 and later. Set `EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE` to `0` to disable it. On SDK 57 and below the feature remains experimental and opt-in via `EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE`. ([#4433](https://github.com/expo/eas-cli/pull/4433) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [build-tools] Save manual iOS and Android live preview screenshots as session artifacts when supported by the preview server. ([#4481](https://github.com/expo/eas-cli/pull/4481) by [@vonovak](https://github.com/vonovak))
|
||||
- [build-tools] Add the `eas/install_mitmproxy` step. ([#4307](https://github.com/expo/eas-cli/pull/4307) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Start serve-sim with `--network-capture`, and with `--network-capture-field` for each requested field, when a simulator session requests network capture. ([#4307](https://github.com/expo/eas-cli/pull/4307) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Allow workflow submissions to skip loading app config when project and application identifiers are supplied. ([#4471](https://github.com/expo/eas-cli/pull/4471) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Record Android device sessions and upload the screen recording, including the partial recording a killed Device Hub leaves behind. ([#4418](https://github.com/expo/eas-cli/pull/4418) by [@vonovak](https://github.com/vonovak))
|
||||
- [build-tools] Add start and collect steps that attach bounded simulator logs from serve-sim to device run sessions. ([#4463](https://github.com/expo/eas-cli/pull/4463) by [@sjkim-expo](https://github.com/sjkim-expo))
|
||||
- [build-tools] Add `eas/start_agent_device_session`, one step for agent-device sessions that boots the device, downloads, installs and launches the app, and starts the session, with the independent parts in parallel. ([#4491](https://github.com/expo/eas-cli/pull/4491) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-build-job] Add `sentry` workflow interpolation context schema for Sentry-triggered workflow runs. ([#4453](https://github.com/expo/eas-cli/pull/4453) by [@sswrk](https://github.com/sswrk))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Stop `eas update:rollback` from always forcing non-interactive mode. ([#4504](https://github.com/expo/eas-cli/pull/4504) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Pass the step cancellation signal to `eas/download_build`. ([#4511](https://github.com/expo/eas-cli/pull/4511) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Accept `null` for the TestFlight group `hasAccessToAllBuilds` attribute from App Store Connect. ([#4528](https://github.com/expo/eas-cli/pull/4528) by [@sswrk](https://github.com/sswrk))
|
||||
- [eas-cli] Support `queued` and `starting` status filters in `eas simulator:list` and keep simulator commands compatible with the current GraphQL schema. ([#4527](https://github.com/expo/eas-cli/pull/4527) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Explain how to resolve missing or expired Apple agreements when App Store Connect rejects a request. ([#4513](https://github.com/expo/eas-cli/pull/4513) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] Correct the `eas simulator:start --max-duration-minutes` help text: every plan can set a custom duration up to the plan's maximum, which is also the default. ([#4524](https://github.com/expo/eas-cli/pull/4524) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Fix `eas workflow:validate` crashing with certain workflow schemas, and exit with a non-zero code when validation fails. ([#4507](https://github.com/expo/eas-cli/pull/4507) by [@yuvalgnessin-qz](https://github.com/yuvalgnessin-qz))
|
||||
- [build-tools] Reuse App Store Connect tokens and refresh them before expiry so long uploads and processing waits remain authenticated. ([#4497](https://github.com/expo/eas-cli/pull/4497) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Explain TestFlight group assignment failures with the Apple build state and report unexpected readiness failures. ([#4521](https://github.com/expo/eas-cli/pull/4521) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Clear old Maestro CLI HTML reports before each test attempt so retries cannot upload an earlier report. ([#4409](https://github.com/expo/eas-cli/pull/4409) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Poll the web preview readiness every 250 ms instead of every second, so a session goes live sooner after its host is ready. ([#4496](https://github.com/expo/eas-cli/pull/4496) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Keep the `eas simulator` session spinner on one line, so resizing the terminal no longer leaves copies of it behind. The `eas simulator:stop` command is now printed once before the spinner starts. ([#4488](https://github.com/expo/eas-cli/pull/4488) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools] Disable agent-device's internal idle timeouts in remote sessions, so only the session's own idle and duration limits end it. ([#4485](https://github.com/expo/eas-cli/pull/4485) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Keep `eas simulator:events --follow` running while a session is queued or starting. ([#4506](https://github.com/expo/eas-cli/pull/4506) by [@sjkim-expo](https://github.com/sjkim-expo))
|
||||
|
||||
## [24.8.0](https://github.com/expo/eas-cli/releases/tag/v24.8.0) - 2026-09-24
|
||||
|
||||
### 🎉 New features
|
||||
@@ -19,6 +216,7 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
- [eas-cli] Add `--build-fingerprint` to `eas simulator` to install the newest build with a given fingerprint. ([#4472](https://github.com/expo/eas-cli/pull/4472) by [@tchayen](https://github.com/tchayen))
|
||||
- [eas-cli] Accept build IDs in `workflow:ssh <id>`. ([#4452](https://github.com/expo/eas-cli/pull/4452) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- [eas-cli] Make `eas simulator:*` commands visible in help output. ([#4456](https://github.com/expo/eas-cli/pull/4456) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools] Add `eas/update_testflight_metadata` to set changelogs and TestFlight groups for an uploaded build with an App Store Connect API key. The step fails if a requested group does not exist. ([#4475](https://github.com/expo/eas-cli/pull/4475) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Pass `--share-url` when launching serve-sim so Share copies the expo.dev preview page instead of the tunnel URL. ([#4434](https://github.com/expo/eas-cli/pull/4434) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `eas integrations:supabase:advisors` to list unresolved Supabase Security and Performance Advisor findings for the linked Supabase project. ([#4342](https://github.com/expo/eas-cli/pull/4342) by [@fiberjw](https://github.com/fiberjw))
|
||||
- [eas-cli] Accept individual App Store Connect API keys for submissions, TestFlight setup, and metadata. ([#4249](https://github.com/expo/eas-cli/pull/4249) by [@sswrk](https://github.com/sswrk))
|
||||
@@ -27,16 +225,20 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Report early Argent server exits during simulator startup instead of waiting for the full readiness timeout. ([#4454](https://github.com/expo/eas-cli/pull/4454) by [@sjkim-expo](https://github.com/sjkim-expo))
|
||||
- [eas-cli] Stop spinners from printing a new line on every frame after the terminal gets narrower. ([#4470](https://github.com/expo/eas-cli/pull/4470) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Exclude time waiting for simulator concurrency from the session startup timeout and show queue progress. ([#4411](https://github.com/expo/eas-cli/pull/4411) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] List `eas simulator` only once in `eas --help`. The `sim` shorthand is now a hidden alias, so it still runs but no longer duplicates the entry. ([#4457](https://github.com/expo/eas-cli/pull/4457) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
- [build-tools] Start the Argent tool-server with `ARGENT_EMULATOR_NO_WINDOW=1`, so Argent can boot the Android emulator again after it stops. Without it, the windowed emulator fails on the Linux image because `libpulse.so.0` is missing. ([#4465](https://github.com/expo/eas-cli/pull/4465) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Fix `eas account:view` printing `Role: undefined` for the new Release Manager role. ([#4300](https://github.com/expo/eas-cli/pull/4300) by [@byronkarlen](https://github.com/byronkarlen))
|
||||
- [eas-cli] Include legacy serve-sim sessions in `eas simulator:list --type web-preview-only`, and list `web-preview-only` only once in the `--type` options. ([#4466](https://github.com/expo/eas-cli/pull/4466) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Stop creating `CLAUDE.md` in new apps; use `AGENTS.md` for agent instructions. ([#4446](https://github.com/expo/eas-cli/pull/4446) by [@davidmokos](https://github.com/davidmokos))
|
||||
- Upgrade `typescript` to 6.0.3, `ts-jest` to 29.4.12, `@types/node` to 20.19.43, `gql.tada` to 1.11.3, and `https-proxy-agent` to 7.0.6. ([#XXXX](https://github.com/expo/eas-cli/pull/XXXX) by [@wschurman](https://github.com/wschurman))
|
||||
- Upgrade `typescript` to 6.0.3, `ts-jest` to 29.4.12, `@types/node` to 20.19.43, `gql.tada` to 1.11.3, and `https-proxy-agent` to 7.0.6. ([#4400](https://github.com/expo/eas-cli/pull/4400) by [@wschurman](https://github.com/wschurman))
|
||||
- [build-tools] Use the website origin for simulator preview CORS and framing, and allow deploy previews and local website dev servers on staging. ([#4437](https://github.com/expo/eas-cli/pull/4437) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Report the web preview as `webPreviewUrl` and `webPreviewToken` in addition to the legacy serve-sim keys. ([#4467](https://github.com/expo/eas-cli/pull/4467) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [24.7.0](https://github.com/expo/eas-cli/releases/tag/v24.7.0) - 2026-09-16
|
||||
|
||||
@@ -148,6 +350,7 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Report a system error when project source refresh fails for a remote build. ([#4359](https://github.com/expo/eas-cli/pull/4359) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] Remove the obsolete `appVersion` runtime policy warning that referenced SDK 46 and expo-updates 0.14.4. ([#4375](https://github.com/expo/eas-cli/pull/4375) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [24.0.0](https://github.com/expo/eas-cli/releases/tag/v24.0.0) - 2026-09-09
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@ Each package has independent TypeScript compilation:
|
||||
|
||||
TypeScript configs:
|
||||
|
||||
- `tsconfig.json` - Base configuration (extends @tsconfig/node20)
|
||||
- `tsconfig.json` - Base configuration (extends @tsconfig/node22)
|
||||
- `tsconfig.build.json` - Production builds
|
||||
- `tsconfig.allowUnused.json` - Development with relaxed rules
|
||||
|
||||
@@ -215,7 +215,7 @@ if: ${{ steps.previous_step.outputs.success == 'true' && env.ENVIRONMENT == 'pro
|
||||
Artifacts tracked as `ArtifactToUpload`:
|
||||
|
||||
- Managed artifacts (APK, IPA, AAB) with specific handling
|
||||
- Generic artifacts for any file
|
||||
- Workflow artifacts for any file
|
||||
- Upload via `ctx.uploadArtifact()` or `upload-artifact` step
|
||||
|
||||
## Package Interdependencies
|
||||
@@ -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`.
|
||||
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that you d
|
||||
to access the functionality of and directly benefit from the
|
||||
functionality of the Licensed Work.
|
||||
|
||||
Change Date: 2029-07-15
|
||||
Change Date: 2029-10-01
|
||||
|
||||
Change License: MIT
|
||||
|
||||
|
||||
+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.8.0",
|
||||
"PRODUCTION": "24.8.0"
|
||||
"STAGING": "25.3.0",
|
||||
"PRODUCTION": "25.3.0"
|
||||
}
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "24.8.0",
|
||||
"version": "25.3.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
}
|
||||
@@ -6,4 +6,4 @@ MISE_NODE_COREPACK = "true"
|
||||
actionlint = "1.7.12"
|
||||
gcloud = "570.0.0"
|
||||
jq = "1.8.1"
|
||||
node = "20.19.4"
|
||||
node = "22.23.4"
|
||||
+6
-3
@@ -7,6 +7,7 @@
|
||||
"scripts": {
|
||||
"build": "lerna run build",
|
||||
"typecheck": "lerna run typecheck",
|
||||
"verify-graphql-code": "lerna run verify-graphql-code --concurrency 1",
|
||||
"start": "lerna run watch --stream --parallel",
|
||||
"start-allow-unused": "lerna run watch-allow-unused --stream --parallel",
|
||||
"watch": "yarn start",
|
||||
@@ -15,14 +16,16 @@
|
||||
"lint": "oxlint --type-aware --import-plugin --max-warnings=0 --tsconfig ./tsconfig.oxlint.json .",
|
||||
"fmt": "oxfmt .",
|
||||
"fmt:check": "oxfmt --check .",
|
||||
"lint-changelog": "./scripts/bin/run lint-changelog",
|
||||
"release": "lerna run rebuild --concurrency 1 && ./scripts/bin/run release",
|
||||
"test": "lerna run test",
|
||||
"clean": "lerna run clean && rimraf node_modules coverage"
|
||||
"clean": "lerna run clean && rimraf node_modules coverage",
|
||||
"changeset": "changeset"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "3.0.3",
|
||||
"@changesets/get-github-info": "1.0.1",
|
||||
"@types/jest": "29.5.11",
|
||||
"@types/node": "20.19.43",
|
||||
"@types/node": "22.20.5",
|
||||
"eslint-plugin-async-protect": "3.1.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-watch-typeahead": "2.2.2",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
bin/record-sim
|
||||
|
||||
.secrets*
|
||||
!.secrets.example
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# @expo/build-tools
|
||||
|
||||
## 25.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Let serve-sim boot, install and launch Argent iOS sessions. ([#4602](https://github.com/expo/eas-cli/pull/4602) by [@gwdp](https://github.com/gwdp))
|
||||
- Let serve-sim boot, install and launch Appium iOS sessions. ([#4603](https://github.com/expo/eas-cli/pull/4603) by [@gwdp](https://github.com/gwdp))
|
||||
- Upload the screen recording session manifest as a JSON artifact alongside each video. ([#4689](https://github.com/expo/eas-cli/pull/4689) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Quote Android launch arguments and URLs for the device shell in simulator sessions, so values with spaces or shell characters reach `am start` as one argument; pass raw values and remove any shell quoting added as a workaround. ([#4695](https://github.com/expo/eas-cli/pull/4695) by [@brentvatne](https://github.com/brentvatne))
|
||||
- Wait up to five minutes for Google Play to finish processing an uploaded bundle before giving up on the edit commit. ([#4700](https://github.com/expo/eas-cli/pull/4700) by [@hSATAC](https://github.com/hSATAC))
|
||||
- Reset the device run session idle timer on iOS Simulator preview input. ([#4691](https://github.com/expo/eas-cli/pull/4691) by [@gwdp](https://github.com/gwdp))
|
||||
- Forward captured Simulator traffic through the local egress proxy. ([#4598](https://github.com/expo/eas-cli/pull/4598) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## 25.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Let serve-sim boot, install and launch agent-device iOS sessions with the local-egress guard. ([#4544](https://github.com/expo/eas-cli/pull/4544) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## 25.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Call www only through the device run session worker API in device run session jobs, and remove their GraphQL fallbacks. ([#4638](https://github.com/expo/eas-cli/pull/4638) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## 25.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Pass the `cors_origins` session input to serve-sim and expo-device-hub as `--cors-origin` origins, so a third party page can call the preview host API, for example with `@expo/hub-client`. ([#4657](https://github.com/expo/eas-cli/pull/4657) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
- Pass the `frame_ancestors` session input to serve-sim and expo-device-hub as `--frame-ancestor` origins, so a third party can embed the preview host. ([#4655](https://github.com/expo/eas-cli/pull/4655) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Allocate 4 GiB of guest RAM to Android emulators in EAS Simulator sessions. ([#4640](https://github.com/expo/eas-cli/pull/4640) by [@gwdp](https://github.com/gwdp))
|
||||
- Retry transient Google Play API failures with exponential backoff while keeping uncertain commit results explicit. ([#4629](https://github.com/expo/eas-cli/pull/4629) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Retry Google Play edit commits when an uploaded Android App Bundle is still processing, with bounded retries and progress warnings. ([#4651](https://github.com/expo/eas-cli/pull/4651) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Report Google Play error responses to Sentry so unexplained submission failures can be classified. ([#4652](https://github.com/expo/eas-cli/pull/4652) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## 25.0.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Start `expo-device-hub` for Android simulator sessions with `--require-token` and the website origins, as serve-sim starts for iOS, and report its session token. ([#4578](https://github.com/expo/eas-cli/pull/4578) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
- Respect saved sharing preferences for iOS simulator preview sessions. ([#4620](https://github.com/expo/eas-cli/pull/4620) by [@gwdp](https://github.com/gwdp))
|
||||
- Give agent-device and its simulator preview separate session logs. ([#4585](https://github.com/expo/eas-cli/pull/4585) by [@gwdp](https://github.com/gwdp))
|
||||
- Give Appium and its simulator preview separate session logs. ([#4586](https://github.com/expo/eas-cli/pull/4586) by [@gwdp](https://github.com/gwdp))
|
||||
- Give Argent and its simulator preview separate session logs. ([#4587](https://github.com/expo/eas-cli/pull/4587) by [@gwdp](https://github.com/gwdp))
|
||||
- Let serve-sim start the iOS Simulator for web-preview sessions. ([#4601](https://github.com/expo/eas-cli/pull/4601) by [@gwdp](https://github.com/gwdp))
|
||||
- Stream simulator preview output into its existing session step. ([#4525](https://github.com/expo/eas-cli/pull/4525) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Speed up cold iOS Appium sessions by launching preinstalled WebDriverAgent. ([#4618](https://github.com/expo/eas-cli/pull/4618) by [@gwdp](https://github.com/gwdp))
|
||||
- Call the device run session worker API with the session token when www provides one. ([#4615](https://github.com/expo/eas-cli/pull/4615) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- Clarify Google Play submission messages and reject zero Android submission rollouts. ([#4625](https://github.com/expo/eas-cli/pull/4625) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Config } from '@jest/types';
|
||||
|
||||
import baseConfig from '../../../jest/jest.shared.config';
|
||||
|
||||
const config: Config.InitialOptions = {
|
||||
...baseConfig,
|
||||
rootDir: '../src',
|
||||
testMatch: ['**/__tests__/*.test.ffmpeg.ts'],
|
||||
clearMocks: true,
|
||||
setupFilesAfterEnv: ['<rootDir>/../jest/setup-tests.ts'],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "24.8.0",
|
||||
"version": "25.3.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -23,35 +23,36 @@
|
||||
"watch": "tsc --watch --preserveWatchOutput",
|
||||
"prebuild": "yarn gql",
|
||||
"build": "tsc",
|
||||
"build:record-sim": "mkdir -p bin && record_sim_bin_path=$(swift build -c release --package-path resources/record-sim --build-path resources/record-sim/.build --show-bin-path) && swift build -c release --package-path resources/record-sim --build-path resources/record-sim/.build && cp \"$record_sim_bin_path/record-sim\" bin/record-sim && chmod +x bin/record-sim",
|
||||
"build:egress-guard": "resources/egress-guard/build.sh",
|
||||
"test:egress-guard": "resources/egress-guard/tests/run-policy-tests.sh && resources/egress-guard/tests/run-guard-tests.sh",
|
||||
"typecheck": "tsc",
|
||||
"generate-appium-commands": "mise exec [email protected]2.0 -- node scripts/generate-appium-commands.js",
|
||||
"generate-appium-commands": "mise exec [email protected]3.4 -- node scripts/generate-appium-commands.js",
|
||||
"prepack": "rimraf dist \"*.tsbuildinfo\" && yarn gql && tsc -p tsconfig.build.json",
|
||||
"jest-unit": "jest --config jest/unit-config.ts",
|
||||
"jest-integration": "jest --config jest/integration-config.ts",
|
||||
"jest-ffmpeg": "jest --config jest/ffmpeg-config.ts",
|
||||
"jest-unit-watch": "jest --config jest/unit-config.ts --watch",
|
||||
"jest-integration-watch": "jest --config jest/integration-config.ts --watch",
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\"",
|
||||
"gql": "gql.tada generate-schema ${API_SERVER_URL:-https://api.expo.dev}/graphql --output ./schema.graphql && gql.tada generate-output",
|
||||
"gql:local": "API_SERVER_URL=http://api.expo.test yarn gql",
|
||||
"verify-graphql-code": "./scripts/verify-graphql.sh",
|
||||
"test": "yarn jest-unit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "24.8.0",
|
||||
"@expo/eas-build-job": "24.8.0",
|
||||
"@expo/downloader": "25.3.0",
|
||||
"@expo/eas-build-job": "25.3.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "24.8.0",
|
||||
"@expo/logger": "25.3.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.3.5",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "24.8.0",
|
||||
"@expo/template-file": "24.8.0",
|
||||
"@expo/turtle-spawn": "24.8.0",
|
||||
"@expo/steps": "25.3.0",
|
||||
"@expo/template-file": "25.3.0",
|
||||
"@expo/turtle-spawn": "25.3.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
@@ -88,7 +89,7 @@
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/lodash": "^4.17.4",
|
||||
"@types/node": "20.19.43",
|
||||
"@types/node": "22.20.5",
|
||||
"@types/node-fetch": "^2.6.11",
|
||||
"@types/node-forge": "^1.3.11",
|
||||
"@types/plist": "^3.0.5",
|
||||
|
||||
@@ -71,6 +71,17 @@ by a coverage report from `lsof`, listing any process without the library. Both
|
||||
`packages/worker/package.sh` for the iOS worker tarball and by the
|
||||
`test-egress-guard` EAS workflow, not committed.
|
||||
|
||||
For serve-sim-owned iOS session startup, build-tools passes the guard through
|
||||
`SERVE_SIM_ADDITIONAL_DYLIBS` and its configuration and proxy variables through
|
||||
inherited `SIMCTL_CHILD_` variables. It shuts down an already booted selected
|
||||
device so every process inherits the guard. Serve-sim boots that device while
|
||||
build-tools prepares the app. After serve-sim's control endpoint is available,
|
||||
build-tools runs the packaged guard self-check before requesting installation
|
||||
and launch through the authenticated host API. Preview readiness and application
|
||||
startup both complete before reporting the remote session live. Build-tools keeps
|
||||
the event relay and egress cleanup without rewriting launchd's dylib list after
|
||||
serve-sim adds its capability loader.
|
||||
|
||||
Failure semantics: a missing library, a failed `launchctl setenv`, or a failed
|
||||
self-check fails the session, since a `--egress local` session without the
|
||||
guard would silently leak. Only an unwritable event log is a warning, because
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
.build/
|
||||
.swiftpm/
|
||||
DerivedData/
|
||||
.DS_Store
|
||||
@@ -1,28 +0,0 @@
|
||||
// swift-tools-version: 5.9
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "record-sim",
|
||||
platforms: [
|
||||
.macOS(.v14),
|
||||
],
|
||||
products: [
|
||||
.library(name: "RecordSim", targets: ["RecordSim"]),
|
||||
.executable(name: "record-sim", targets: ["record-sim"]),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "RecordSim",
|
||||
linkerSettings: [
|
||||
.linkedFramework("AVFoundation"),
|
||||
.linkedFramework("CoreGraphics"),
|
||||
.linkedFramework("CoreMedia"),
|
||||
.linkedFramework("CoreVideo"),
|
||||
.linkedFramework("IOSurface"),
|
||||
.linkedFramework("UniformTypeIdentifiers"),
|
||||
]
|
||||
),
|
||||
.executableTarget(name: "record-sim", dependencies: ["RecordSim"]),
|
||||
]
|
||||
)
|
||||
@@ -1,79 +0,0 @@
|
||||
# RecordSim
|
||||
|
||||
Small Swift library for non-exclusive iOS Simulator screen recording.
|
||||
|
||||
It implements one recording path:
|
||||
|
||||
1. Listen to SimRenderServer framebuffer callbacks.
|
||||
2. Copy the live `IOSurface` immediately into an owned `CVPixelBuffer`.
|
||||
3. Feed one continuous `AVAssetWriter`.
|
||||
4. Emit Apple HLS/CMAF-style fragmented MP4 segments.
|
||||
|
||||
The output is suitable for uploading while recording. `session.json` contains
|
||||
the ordered segment metadata needed to build a playlist later.
|
||||
|
||||
## Output
|
||||
|
||||
```text
|
||||
session/
|
||||
init.mp4
|
||||
session.json
|
||||
segments/
|
||||
segment-000000.m4s
|
||||
segment-000001.m4s
|
||||
```
|
||||
|
||||
`init.mp4` is the fMP4 initialization segment. Individual `.m4s` files are not
|
||||
standalone MP4 files. `session.json` stores the wall-clock timestamp for the
|
||||
first video frame plus the metadata needed to build a playlist: HLS version,
|
||||
target duration, media sequence, `initSegment`, and the ordered `segments` array
|
||||
with file path and duration for each media segment.
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
swift run record-sim \
|
||||
--udid <BOOTED_SIMULATOR_UDID> \
|
||||
--output /tmp/sim-recording \
|
||||
--segment-duration 120 \
|
||||
--fps 30 \
|
||||
--bitrate 30000000 \
|
||||
--codec h264
|
||||
```
|
||||
|
||||
For sparse 2h sessions, `--segment-duration 120` is a reasonable default: about
|
||||
60 media objects plus the init segment. The recorder appends a single duplicate
|
||||
hold frame near each segment boundary when the simulator is idle, so long idle
|
||||
periods still produce uploadable segments without encoding idle frames at 60fps.
|
||||
Use `--segment-duration 0` to write one continuous `recording.mp4` instead of
|
||||
`init.mp4` plus media segments.
|
||||
|
||||
Frame timestamps use monotonic host time, not wall-clock time. The recorder also
|
||||
probes the framebuffer seed once per second; if the surface changes without
|
||||
callbacks for 5 seconds, it captures that frame and rewires the private callback
|
||||
registration.
|
||||
|
||||
## Library
|
||||
|
||||
```swift
|
||||
let recorder = SimulatorRecorder(
|
||||
configuration: SimulatorRecordingConfiguration(
|
||||
deviceUDID: udid,
|
||||
outputDirectory: sessionDirectory,
|
||||
segmentDuration: 120
|
||||
)
|
||||
)
|
||||
|
||||
recorder.onSegment = { segment in
|
||||
// Enqueue upload of segment.url.
|
||||
}
|
||||
|
||||
try recorder.start()
|
||||
try recorder.waitUntilFirstFrame()
|
||||
// ...
|
||||
let manifest = try recorder.stop()
|
||||
```
|
||||
|
||||
`session.json` contains the precise wall-clock time for video PTS zero. In
|
||||
segmented mode it also contains the init segment and ordered media segment
|
||||
file/duration entries; in single-file mode it points at `recording.mp4`.
|
||||
-192
@@ -1,192 +0,0 @@
|
||||
import Foundation
|
||||
import IOSurface
|
||||
import ObjectiveC
|
||||
|
||||
final class FramebufferDisplaySource {
|
||||
struct SurfaceSnapshot {
|
||||
let surface: IOSurface
|
||||
let width: Int
|
||||
let height: Int
|
||||
let seed: UInt32
|
||||
}
|
||||
|
||||
private let deviceUDID: String
|
||||
private let callbackQueue: DispatchQueue
|
||||
private let onFrame: () -> Void
|
||||
private let onSurfaceChange: () -> Void
|
||||
private var descriptors: [NSObject] = []
|
||||
private var callbackUUIDs: [ObjectIdentifier: NSUUID] = [:]
|
||||
private var retainedBlocks: [AnyObject] = []
|
||||
private var ioClient: NSObject?
|
||||
|
||||
init(
|
||||
deviceUDID: String,
|
||||
callbackQueue: DispatchQueue,
|
||||
onFrame: @escaping () -> Void,
|
||||
onSurfaceChange: @escaping () -> Void
|
||||
) {
|
||||
self.deviceUDID = deviceUDID
|
||||
self.callbackQueue = callbackQueue
|
||||
self.onFrame = onFrame
|
||||
self.onSurfaceChange = onSurfaceChange
|
||||
}
|
||||
|
||||
func start() throws {
|
||||
try PrivateSimulatorFrameworks.load()
|
||||
guard let device = SimulatorDeviceLookup.find(udid: deviceUDID) else {
|
||||
throw RecorderError.make(1, "Simulator \(deviceUDID) not found")
|
||||
}
|
||||
let state = device.value(forKey: "stateString") as? String ?? "unknown"
|
||||
guard state == "Booted" else {
|
||||
throw RecorderError.make(2, "Simulator \(deviceUDID) is not booted (state: \(state))")
|
||||
}
|
||||
guard
|
||||
let io = device.perform(NSSelectorFromString("io"))?.takeUnretainedValue() as? NSObject
|
||||
else {
|
||||
throw RecorderError.make(3, "Failed to get simulator IO client")
|
||||
}
|
||||
ioClient = io
|
||||
try wireUpFramebuffer()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
unregisterCallbacks()
|
||||
descriptors.removeAll()
|
||||
retainedBlocks.removeAll()
|
||||
ioClient = nil
|
||||
}
|
||||
|
||||
func surfaceSnapshot() -> SurfaceSnapshot? {
|
||||
let surfaceSelector = NSSelectorFromString("framebufferSurface")
|
||||
var bestSnapshot: SurfaceSnapshot?
|
||||
var bestArea = 0
|
||||
for descriptor in descriptors {
|
||||
guard let surfaceObject = descriptor.perform(surfaceSelector)?.takeUnretainedValue()
|
||||
else {
|
||||
continue
|
||||
}
|
||||
let surface = unsafeBitCast(surfaceObject, to: IOSurface.self)
|
||||
let width = IOSurfaceGetWidth(surface)
|
||||
let height = IOSurfaceGetHeight(surface)
|
||||
let area = width * height
|
||||
if area > bestArea {
|
||||
bestSnapshot = SurfaceSnapshot(
|
||||
surface: surface,
|
||||
width: width,
|
||||
height: height,
|
||||
seed: IOSurfaceGetSeed(surface)
|
||||
)
|
||||
bestArea = area
|
||||
}
|
||||
}
|
||||
return bestSnapshot
|
||||
}
|
||||
|
||||
func rewireFramebuffer() throws {
|
||||
try wireUpFramebuffer()
|
||||
}
|
||||
|
||||
private func wireUpFramebuffer() throws {
|
||||
guard let io = ioClient else {
|
||||
throw RecorderError.make(3, "No simulator IO client")
|
||||
}
|
||||
io.perform(NSSelectorFromString("updateIOPorts"))
|
||||
let nextDescriptors = try findFramebufferDescriptors(io: io)
|
||||
unregisterCallbacks()
|
||||
descriptors = nextDescriptors
|
||||
retainedBlocks.removeAll()
|
||||
do {
|
||||
for descriptor in descriptors {
|
||||
try registerCallbacks(descriptor: descriptor)
|
||||
}
|
||||
} catch {
|
||||
unregisterCallbacks()
|
||||
descriptors.removeAll()
|
||||
retainedBlocks.removeAll()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func findFramebufferDescriptors(io: NSObject) throws -> [NSObject] {
|
||||
guard let ports = io.value(forKey: "deviceIOPorts") as? [NSObject] else {
|
||||
throw RecorderError.make(4, "Failed to read simulator IO ports")
|
||||
}
|
||||
let portIdentifierSelector = NSSelectorFromString("portIdentifier")
|
||||
let descriptorSelector = NSSelectorFromString("descriptor")
|
||||
let surfaceSelector = NSSelectorFromString("framebufferSurface")
|
||||
|
||||
var candidates: [NSObject] = []
|
||||
for port in ports {
|
||||
guard port.responds(to: portIdentifierSelector),
|
||||
let portIdentifier = port.perform(portIdentifierSelector)?.takeUnretainedValue(),
|
||||
"\(portIdentifier)" == "com.apple.framebuffer.display",
|
||||
port.responds(to: descriptorSelector),
|
||||
let descriptor = port.perform(descriptorSelector)?.takeUnretainedValue()
|
||||
as? NSObject,
|
||||
descriptor.responds(to: surfaceSelector)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
candidates.append(descriptor)
|
||||
}
|
||||
if candidates.isEmpty {
|
||||
throw RecorderError.make(5, "No framebuffer display descriptor found")
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
private func registerCallbacks(descriptor: NSObject) throws {
|
||||
let selector = NSSelectorFromString(
|
||||
"registerScreenCallbacksWithUUID:callbackQueue:frameCallback:surfacesChangedCallback:propertiesChangedCallback:"
|
||||
)
|
||||
guard descriptor.responds(to: selector) else {
|
||||
throw RecorderError.make(6, "Framebuffer descriptor does not support screen callbacks")
|
||||
}
|
||||
guard let msgSendPointer = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "objc_msgSend")
|
||||
else {
|
||||
throw RecorderError.make(7, "objc_msgSend not found")
|
||||
}
|
||||
typealias MsgSend =
|
||||
@convention(c) (
|
||||
AnyObject, Selector, AnyObject, AnyObject, AnyObject, AnyObject, AnyObject
|
||||
) -> Void
|
||||
let msgSend = unsafeBitCast(msgSendPointer, to: MsgSend.self)
|
||||
|
||||
let uuid = NSUUID()
|
||||
callbackUUIDs[ObjectIdentifier(descriptor)] = uuid
|
||||
|
||||
let frameCallback: @convention(block) () -> Void = { [weak self] in
|
||||
self?.onFrame()
|
||||
}
|
||||
let surfacesCallback: @convention(block) (AnyObject?, AnyObject?) -> Void = {
|
||||
[weak self] _, _ in
|
||||
self?.onSurfaceChange()
|
||||
}
|
||||
let propertiesCallback: @convention(block) () -> Void = {}
|
||||
retainedBlocks.append(frameCallback as AnyObject)
|
||||
retainedBlocks.append(surfacesCallback as AnyObject)
|
||||
retainedBlocks.append(propertiesCallback as AnyObject)
|
||||
|
||||
msgSend(
|
||||
descriptor,
|
||||
selector,
|
||||
uuid,
|
||||
callbackQueue as AnyObject,
|
||||
frameCallback as AnyObject,
|
||||
surfacesCallback as AnyObject,
|
||||
propertiesCallback as AnyObject
|
||||
)
|
||||
}
|
||||
|
||||
private func unregisterCallbacks() {
|
||||
let selector = NSSelectorFromString("unregisterScreenCallbacksWithUUID:")
|
||||
for descriptor in descriptors {
|
||||
if let uuid = callbackUUIDs[ObjectIdentifier(descriptor)],
|
||||
descriptor.responds(to: selector)
|
||||
{
|
||||
descriptor.perform(selector, with: uuid)
|
||||
}
|
||||
}
|
||||
callbackUUIDs.removeAll()
|
||||
}
|
||||
}
|
||||
-96
@@ -1,96 +0,0 @@
|
||||
import Foundation
|
||||
import ObjectiveC
|
||||
|
||||
enum XcodeDeveloperDirectory {
|
||||
static let current: String = {
|
||||
if let developerDir = ProcessInfo.processInfo.environment["DEVELOPER_DIR"],
|
||||
!developerDir.isEmpty
|
||||
{
|
||||
return developerDir
|
||||
}
|
||||
|
||||
let fallback = "/Applications/Xcode.app/Contents/Developer"
|
||||
let pipe = Pipe()
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/xcode-select")
|
||||
process.arguments = ["-p"]
|
||||
process.standardOutput = pipe
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
process.waitUntilExit()
|
||||
return String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? fallback
|
||||
}()
|
||||
}
|
||||
|
||||
enum PrivateSimulatorFrameworks {
|
||||
static func load() throws {
|
||||
try loadResult.get()
|
||||
}
|
||||
|
||||
private static let loadResult: Result<Void, Error> = {
|
||||
let developerDir = XcodeDeveloperDirectory.current
|
||||
let candidates = [
|
||||
"/Library/Developer/PrivateFrameworks/CoreSimulator.framework/CoreSimulator",
|
||||
"\(developerDir)/Library/PrivateFrameworks/CoreSimulator.framework/CoreSimulator",
|
||||
"\(developerDir)/../SharedFrameworks/SimulatorKit.framework/SimulatorKit",
|
||||
"\(developerDir)/Library/PrivateFrameworks/SimulatorKit.framework/SimulatorKit",
|
||||
]
|
||||
var failures: [String] = []
|
||||
for path in candidates {
|
||||
if dlopen(path, RTLD_NOW) == nil {
|
||||
let message = dlerror().map { String(cString: $0) } ?? "unknown error"
|
||||
failures.append("\(path): \(message)")
|
||||
}
|
||||
}
|
||||
guard NSClassFromString("SimServiceContext") != nil else {
|
||||
let tried = candidates.joined(separator: ", ")
|
||||
let errors = failures.isEmpty ? "no dlopen errors" : failures.joined(separator: "; ")
|
||||
return .failure(
|
||||
RecorderError.make(
|
||||
8,
|
||||
"Failed to load CoreSimulator/SimulatorKit (tried: \(tried); errors: \(errors))"
|
||||
)
|
||||
)
|
||||
}
|
||||
return .success(())
|
||||
}()
|
||||
}
|
||||
|
||||
enum SimulatorDeviceLookup {
|
||||
private static let context: NSObject? = {
|
||||
guard let contextClass = NSClassFromString("SimServiceContext") as? NSObject.Type else {
|
||||
return nil
|
||||
}
|
||||
let sharedSelector = NSSelectorFromString("sharedServiceContextForDeveloperDir:error:")
|
||||
return contextClass.perform(
|
||||
sharedSelector, with: XcodeDeveloperDirectory.current, with: nil)?
|
||||
.takeUnretainedValue() as? NSObject
|
||||
}()
|
||||
|
||||
static func find(udid: String) -> NSObject? {
|
||||
guard let context else {
|
||||
return nil
|
||||
}
|
||||
let deviceSetSelector = NSSelectorFromString("defaultDeviceSetWithError:")
|
||||
guard
|
||||
let deviceSet = context.perform(deviceSetSelector, with: nil)?
|
||||
.takeUnretainedValue() as? NSObject
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
guard let devices = deviceSet.value(forKey: "devices") as? [NSObject] else {
|
||||
return nil
|
||||
}
|
||||
return devices.first {
|
||||
($0.value(forKey: "UDID") as? NSUUID)?.uuidString == udid
|
||||
}
|
||||
}
|
||||
|
||||
static func stateString(udid: String) -> String? {
|
||||
find(udid: udid)?.value(forKey: "stateString") as? String
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
public enum RecorderCodec: String, Codable, Sendable {
|
||||
case h264
|
||||
case hevc
|
||||
}
|
||||
|
||||
public struct SimulatorRecordingConfiguration: Sendable {
|
||||
public var deviceUDID: String
|
||||
public var outputDirectory: URL
|
||||
public var fps: Int
|
||||
public var bitrate: Int
|
||||
public var codec: RecorderCodec
|
||||
public var segmentDuration: TimeInterval
|
||||
public var maxPendingFrames: Int
|
||||
public var overwrite: Bool
|
||||
|
||||
public init(
|
||||
deviceUDID: String,
|
||||
outputDirectory: URL,
|
||||
fps: Int = 30,
|
||||
bitrate: Int = 30_000_000,
|
||||
codec: RecorderCodec = .h264,
|
||||
segmentDuration: TimeInterval = 120,
|
||||
maxPendingFrames: Int = 8,
|
||||
overwrite: Bool = true
|
||||
) {
|
||||
self.deviceUDID = deviceUDID
|
||||
self.outputDirectory = outputDirectory
|
||||
self.fps = fps
|
||||
self.bitrate = bitrate
|
||||
self.codec = codec
|
||||
self.segmentDuration = segmentDuration
|
||||
self.maxPendingFrames = maxPendingFrames
|
||||
self.overwrite = overwrite
|
||||
}
|
||||
}
|
||||
|
||||
public enum SegmentKind: String, Codable, Sendable {
|
||||
case initialization
|
||||
case media
|
||||
}
|
||||
|
||||
public struct SegmentOutput: Sendable {
|
||||
public let kind: SegmentKind
|
||||
public let relativePath: String
|
||||
public let url: URL
|
||||
public let byteCount: Int
|
||||
public let durationSeconds: Double?
|
||||
}
|
||||
|
||||
public struct MediaSegmentRecord: Codable, Sendable {
|
||||
public let file: String
|
||||
public let durationSeconds: Double
|
||||
}
|
||||
|
||||
public struct FirstFrameWallClock: Codable, Sendable {
|
||||
public let unixMs: Int64
|
||||
public let iso8601: String
|
||||
}
|
||||
|
||||
public enum RecordingFinalizationStage: Sendable {
|
||||
case captureStopped
|
||||
case videoSaved
|
||||
}
|
||||
|
||||
public struct RecordingManifest: Codable, Sendable {
|
||||
public let firstFrameWallClock: FirstFrameWallClock
|
||||
public let width: Int
|
||||
public let height: Int
|
||||
public let hlsVersion: Int?
|
||||
public let hlsTargetDurationSeconds: Int?
|
||||
public let hlsMediaSequence: Int?
|
||||
public let recording: String?
|
||||
public let initSegment: String?
|
||||
public let segments: [MediaSegmentRecord]
|
||||
}
|
||||
-140
@@ -1,140 +0,0 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
final class RecordingOutputWriter: NSObject, AVAssetWriterDelegate {
|
||||
private let rootDirectory: URL
|
||||
private let segmentsDirectory: URL
|
||||
let singleRecordingURL: URL
|
||||
private let stateQueue = DispatchQueue(label: "record-sim.segment-writer")
|
||||
private let notificationQueue = DispatchQueue(label: "record-sim.segment-notifications")
|
||||
private let onSegment: ((SegmentOutput) -> Void)?
|
||||
private var initSegmentPath: String?
|
||||
private var segments: [MediaSegmentRecord] = []
|
||||
private var nextMediaSegmentIndex = 0
|
||||
private var firstError: Error?
|
||||
|
||||
init(rootDirectory: URL, onSegment: ((SegmentOutput) -> Void)?) {
|
||||
self.rootDirectory = rootDirectory
|
||||
self.segmentsDirectory = rootDirectory.appendingPathComponent("segments", isDirectory: true)
|
||||
self.singleRecordingURL = rootDirectory.appendingPathComponent("recording.mp4")
|
||||
self.onSegment = onSegment
|
||||
super.init()
|
||||
}
|
||||
|
||||
func prepare(overwrite: Bool, segmented: Bool) throws {
|
||||
if overwrite {
|
||||
try? FileManager.default.removeItem(at: rootDirectory)
|
||||
}
|
||||
try FileManager.default.createDirectory(at: rootDirectory, withIntermediateDirectories: true)
|
||||
if segmented {
|
||||
try FileManager.default.createDirectory(at: segmentsDirectory, withIntermediateDirectories: true)
|
||||
}
|
||||
}
|
||||
|
||||
var error: Error? {
|
||||
stateQueue.sync { firstError }
|
||||
}
|
||||
|
||||
func assetWriter(
|
||||
_ writer: AVAssetWriter,
|
||||
didOutputSegmentData segmentData: Data,
|
||||
segmentType: AVAssetSegmentType,
|
||||
segmentReport: AVAssetSegmentReport?
|
||||
) {
|
||||
var notification: SegmentOutput?
|
||||
|
||||
stateQueue.sync {
|
||||
if firstError != nil {
|
||||
return
|
||||
}
|
||||
do {
|
||||
switch segmentType {
|
||||
case .initialization:
|
||||
let relativePath = "init.mp4"
|
||||
let url = rootDirectory.appendingPathComponent(relativePath)
|
||||
try segmentData.write(to: url, options: .atomic)
|
||||
initSegmentPath = relativePath
|
||||
notification = SegmentOutput(
|
||||
kind: .initialization,
|
||||
relativePath: relativePath,
|
||||
url: url,
|
||||
byteCount: segmentData.count,
|
||||
durationSeconds: nil
|
||||
)
|
||||
case .separable:
|
||||
let index = nextMediaSegmentIndex
|
||||
let relativePath = String(format: "segments/segment-%06d.m4s", index)
|
||||
let url = rootDirectory.appendingPathComponent(relativePath)
|
||||
try segmentData.write(to: url, options: .atomic)
|
||||
|
||||
let videoReport = segmentReport?.trackReports.first(where: { $0.mediaType == .video })
|
||||
let duration = videoReport.flatMap { timeSeconds($0.duration) } ?? 0
|
||||
let record = MediaSegmentRecord(
|
||||
file: relativePath,
|
||||
durationSeconds: duration
|
||||
)
|
||||
nextMediaSegmentIndex += 1
|
||||
segments.append(record)
|
||||
notification = SegmentOutput(
|
||||
kind: .media,
|
||||
relativePath: relativePath,
|
||||
url: url,
|
||||
byteCount: segmentData.count,
|
||||
durationSeconds: duration
|
||||
)
|
||||
default:
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
firstError = error
|
||||
}
|
||||
}
|
||||
|
||||
if let notification, let onSegment {
|
||||
notificationQueue.async {
|
||||
onSegment(notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func writeManifest(
|
||||
configuration: SimulatorRecordingConfiguration,
|
||||
firstFrameWallClock: Date,
|
||||
width: Int,
|
||||
height: Int
|
||||
) throws -> RecordingManifest {
|
||||
try stateQueue.sync {
|
||||
if let firstError {
|
||||
throw firstError
|
||||
}
|
||||
let segmented = configuration.segmentDuration > 0
|
||||
if segmented, initSegmentPath == nil {
|
||||
throw RecorderError.make(50, "Missing initialization segment")
|
||||
}
|
||||
if segmented, segments.isEmpty {
|
||||
throw RecorderError.make(51, "No media segments were written")
|
||||
}
|
||||
let targetDuration = segmented
|
||||
? max(1, Int(ceil(segments.map(\.durationSeconds).max() ?? configuration.segmentDuration)))
|
||||
: nil
|
||||
let manifest = RecordingManifest(
|
||||
firstFrameWallClock: FirstFrameWallClock(
|
||||
unixMs: unixMs(firstFrameWallClock),
|
||||
iso8601: iso8601(firstFrameWallClock)
|
||||
),
|
||||
width: width,
|
||||
height: height,
|
||||
hlsVersion: segmented ? 7 : nil,
|
||||
hlsTargetDurationSeconds: targetDuration,
|
||||
hlsMediaSequence: segmented ? 0 : nil,
|
||||
recording: segmented ? nil : "recording.mp4",
|
||||
initSegment: segmented ? initSegmentPath : nil,
|
||||
segments: segments
|
||||
)
|
||||
let data = try JSONEncoder.prettySorted.encode(manifest)
|
||||
try data.write(to: rootDirectory.appendingPathComponent("session.json"), options: .atomic)
|
||||
return manifest
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,644 +0,0 @@
|
||||
import AVFoundation
|
||||
import CoreVideo
|
||||
import Foundation
|
||||
import IOSurface
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
public final class SimulatorRecorder {
|
||||
public var onSegment: ((SegmentOutput) -> Void)?
|
||||
public var onSimulatorStopped: ((String) -> Void)?
|
||||
public var onFinalizationStage: ((RecordingFinalizationStage) -> Void)?
|
||||
|
||||
private static let callbackStalenessTimeout: TimeInterval = 5
|
||||
private static let firstFrameRewireInterval: TimeInterval = 1
|
||||
private let configuration: SimulatorRecordingConfiguration
|
||||
private let callbackQueue = DispatchQueue(
|
||||
label: "record-sim.frame-callbacks", qos: .userInteractive)
|
||||
private let writerQueue = DispatchQueue(label: "record-sim.asset-writer", qos: .userInitiated)
|
||||
private let eventQueue = DispatchQueue(label: "record-sim.events", qos: .utility)
|
||||
private let pendingLock = NSLock()
|
||||
private var pendingFrames = 0
|
||||
private var displaySource: FramebufferDisplaySource?
|
||||
private var outputWriter: RecordingOutputWriter?
|
||||
private var writer: AVAssetWriter?
|
||||
private var input: AVAssetWriterInput?
|
||||
private var adaptor: AVAssetWriterInputPixelBufferAdaptor?
|
||||
private var boundaryTimer: DispatchSourceTimer?
|
||||
private var monotonicClock = MonotonicClock()
|
||||
private var firstAcceptedCaptureTime: CMTime?
|
||||
private var firstAcceptedWallClock: Date?
|
||||
private var recordingWidth: Int?
|
||||
private var recordingHeight: Int?
|
||||
private var lastPTS: CMTime?
|
||||
private var lastSeed: UInt32?
|
||||
private var lastFrameCallbackElapsed: TimeInterval?
|
||||
private var lastFirstFrameRewireElapsed: TimeInterval = 0
|
||||
private var lastAppendedPixelBuffer: CVPixelBuffer?
|
||||
private var nextBoundaryElapsed: TimeInterval = 0
|
||||
private var simulatorStoppedReason: String?
|
||||
private var stopped = false
|
||||
private var firstFrameReady = false
|
||||
private let firstFrameSemaphore = DispatchSemaphore(value: 0)
|
||||
private var firstError: Error?
|
||||
|
||||
public init(configuration: SimulatorRecordingConfiguration) {
|
||||
self.configuration = configuration
|
||||
}
|
||||
|
||||
public func start() throws {
|
||||
try validateConfiguration()
|
||||
|
||||
let outputWriter = RecordingOutputWriter(
|
||||
rootDirectory: configuration.outputDirectory,
|
||||
onSegment: { [weak self] segment in
|
||||
self?.onSegment?(segment)
|
||||
})
|
||||
try outputWriter.prepare(
|
||||
overwrite: configuration.overwrite, segmented: configuration.segmentDuration > 0)
|
||||
self.outputWriter = outputWriter
|
||||
|
||||
monotonicClock = MonotonicClock()
|
||||
nextBoundaryElapsed = configuration.segmentDuration
|
||||
lastFirstFrameRewireElapsed = 0
|
||||
stopped = false
|
||||
|
||||
let source = FramebufferDisplaySource(
|
||||
deviceUDID: configuration.deviceUDID,
|
||||
callbackQueue: callbackQueue,
|
||||
onFrame: { [weak self] in
|
||||
self?.captureFrame(force: false, reason: .callback)
|
||||
},
|
||||
onSurfaceChange: { [weak self] in
|
||||
self?.captureFrame(force: true, reason: .surfaceChange)
|
||||
}
|
||||
)
|
||||
try source.start()
|
||||
displaySource = source
|
||||
|
||||
startBoundaryTimer()
|
||||
}
|
||||
|
||||
public func waitUntilFirstFrame(timeoutSeconds: TimeInterval = 15) throws {
|
||||
let alreadyReady = try writerQueue.sync {
|
||||
if let firstError {
|
||||
throw firstError
|
||||
}
|
||||
return firstFrameReady
|
||||
}
|
||||
if alreadyReady {
|
||||
return
|
||||
}
|
||||
if firstFrameSemaphore.wait(timeout: .now() + timeoutSeconds) == .timedOut {
|
||||
throw RecorderError.make(20, "Timed out waiting for first frame")
|
||||
}
|
||||
try writerQueue.sync {
|
||||
if let firstError {
|
||||
throw firstError
|
||||
}
|
||||
if !firstFrameReady {
|
||||
throw RecorderError.make(21, "Recorder stopped before first frame")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func simulatorStopReason() -> String? {
|
||||
callbackQueue.sync {
|
||||
simulatorStoppedReason
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func stop() throws -> RecordingManifest {
|
||||
callbackQueue.sync {
|
||||
stopped = true
|
||||
boundaryTimer?.cancel()
|
||||
boundaryTimer = nil
|
||||
displaySource?.stop()
|
||||
displaySource = nil
|
||||
}
|
||||
onFinalizationStage?(.captureStopped)
|
||||
|
||||
return try writerQueue.sync {
|
||||
if let firstError {
|
||||
throw firstError
|
||||
}
|
||||
guard let writer, let input, let outputWriter else {
|
||||
let error = RecorderError.make(22, "No frames were captured")
|
||||
firstError = error
|
||||
signalFirstFrameReadyIfNeeded()
|
||||
throw error
|
||||
}
|
||||
try appendTailFrameIfNeeded(finalPTS: monotonicClock.elapsedTime())
|
||||
input.markAsFinished()
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
writer.finishWriting {
|
||||
semaphore.signal()
|
||||
}
|
||||
if semaphore.wait(timeout: .now() + 60) == .timedOut {
|
||||
throw RecorderError.make(23, "Timed out finishing AVAssetWriter")
|
||||
}
|
||||
if writer.status == .failed {
|
||||
throw writer.error ?? RecorderError.make(24, "AVAssetWriter failed")
|
||||
}
|
||||
if let error = outputWriter.error {
|
||||
throw error
|
||||
}
|
||||
onFinalizationStage?(.videoSaved)
|
||||
guard let firstAcceptedWallClock else {
|
||||
throw RecorderError.make(25, "Missing first frame wall-clock timestamp")
|
||||
}
|
||||
guard let recordingWidth, let recordingHeight else {
|
||||
throw RecorderError.make(27, "Missing recording dimensions")
|
||||
}
|
||||
return try outputWriter.writeManifest(
|
||||
configuration: configuration,
|
||||
firstFrameWallClock: firstAcceptedWallClock,
|
||||
width: recordingWidth,
|
||||
height: recordingHeight
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private enum CaptureReason {
|
||||
case callback
|
||||
case surfaceChange
|
||||
case boundary
|
||||
case healthProbe
|
||||
}
|
||||
|
||||
private func validateConfiguration() throws {
|
||||
guard !configuration.deviceUDID.isEmpty else {
|
||||
throw RecorderError.make(30, "deviceUDID must not be empty")
|
||||
}
|
||||
guard configuration.fps > 0, configuration.fps <= 120 else {
|
||||
throw RecorderError.make(31, "fps must be between 1 and 120")
|
||||
}
|
||||
guard configuration.bitrate > 0 else {
|
||||
throw RecorderError.make(32, "bitrate must be positive")
|
||||
}
|
||||
guard configuration.segmentDuration >= 0 else {
|
||||
throw RecorderError.make(33, "segmentDuration must be non-negative")
|
||||
}
|
||||
guard configuration.maxPendingFrames > 0 else {
|
||||
throw RecorderError.make(34, "maxPendingFrames must be positive")
|
||||
}
|
||||
}
|
||||
|
||||
private func startBoundaryTimer() {
|
||||
let timer = DispatchSource.makeTimerSource(queue: callbackQueue)
|
||||
timer.schedule(deadline: .now() + 1, repeating: 1, leeway: .milliseconds(100))
|
||||
timer.setEventHandler { [weak self] in
|
||||
self?.probeSimulatorState()
|
||||
self?.probeFramebufferHealth()
|
||||
self?.appendBoundaryFrameIfNeeded()
|
||||
}
|
||||
timer.resume()
|
||||
boundaryTimer = timer
|
||||
}
|
||||
|
||||
private func appendBoundaryFrameIfNeeded() {
|
||||
if stopped {
|
||||
return
|
||||
}
|
||||
guard configuration.segmentDuration > 0 else {
|
||||
return
|
||||
}
|
||||
writerQueue.sync {
|
||||
guard firstAcceptedCaptureTime != nil else {
|
||||
return
|
||||
}
|
||||
let currentPTSSeconds = CMTimeGetSeconds(
|
||||
normalizedPresentationTime(for: monotonicClock.elapsedTime()))
|
||||
guard currentPTSSeconds >= nextBoundaryElapsed else {
|
||||
return
|
||||
}
|
||||
let boundary = nextBoundaryElapsed
|
||||
guard let lastPTS else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
if CMTimeGetSeconds(lastPTS) < boundary {
|
||||
let boundaryPTS = CMTime(seconds: boundary, preferredTimescale: 1_000_000_000)
|
||||
guard try appendHeldFrame(at: boundaryPTS) else {
|
||||
return
|
||||
}
|
||||
}
|
||||
nextBoundaryElapsed = boundary + configuration.segmentDuration
|
||||
} catch {
|
||||
firstError = error
|
||||
signalFirstFrameReadyIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func captureFrame(force: Bool, reason: CaptureReason) {
|
||||
if stopped {
|
||||
return
|
||||
}
|
||||
if reason == .callback {
|
||||
lastFrameCallbackElapsed = monotonicClock.elapsedSeconds()
|
||||
}
|
||||
guard let snapshot = displaySource?.surfaceSnapshot() else {
|
||||
return
|
||||
}
|
||||
let surface = snapshot.surface
|
||||
let surfaceWidth = snapshot.width
|
||||
let surfaceHeight = snapshot.height
|
||||
guard surfaceWidth > 0, surfaceHeight > 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
let seed = snapshot.seed
|
||||
let matchesLastAppendedSeed = writerQueue.sync { lastSeed == seed }
|
||||
if !force, matchesLastAppendedSeed {
|
||||
return
|
||||
}
|
||||
|
||||
guard reservePendingFrame() else {
|
||||
return
|
||||
}
|
||||
|
||||
let capturedAt = monotonicClock.elapsedTime()
|
||||
let capturedAtWallClock = Date()
|
||||
do {
|
||||
let pixelBuffer = try copySurfaceToOwnedPixelBuffer(
|
||||
surface,
|
||||
width: surfaceWidth,
|
||||
height: surfaceHeight
|
||||
)
|
||||
appendOwned(
|
||||
pixelBuffer: pixelBuffer,
|
||||
seed: seed,
|
||||
capturedAt: capturedAt,
|
||||
capturedAtWallClock: capturedAtWallClock
|
||||
)
|
||||
} catch {
|
||||
releasePendingFrame()
|
||||
writerQueue.async {
|
||||
self.firstError = error
|
||||
self.signalFirstFrameReadyIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func probeFramebufferHealth() {
|
||||
guard !stopped, let displaySource else {
|
||||
return
|
||||
}
|
||||
let elapsed = monotonicClock.elapsedSeconds()
|
||||
if !isFirstFrameReady(),
|
||||
elapsed - lastFirstFrameRewireElapsed >= Self.firstFrameRewireInterval
|
||||
{
|
||||
lastFirstFrameRewireElapsed = elapsed
|
||||
rewireFramebuffer()
|
||||
captureFrame(force: true, reason: .healthProbe)
|
||||
return
|
||||
}
|
||||
|
||||
guard let snapshot = displaySource.surfaceSnapshot(),
|
||||
snapshot.width > 0,
|
||||
snapshot.height > 0
|
||||
else {
|
||||
rewireFramebuffer()
|
||||
return
|
||||
}
|
||||
|
||||
let observedSeed = snapshot.seed
|
||||
let lastAppendedSeed = writerQueue.sync { lastSeed }
|
||||
guard let lastAppendedSeed, observedSeed != lastAppendedSeed else {
|
||||
return
|
||||
}
|
||||
|
||||
let lastCallback = lastFrameCallbackElapsed ?? 0
|
||||
if elapsed - lastCallback >= Self.callbackStalenessTimeout {
|
||||
captureFrame(force: false, reason: .healthProbe)
|
||||
rewireFramebuffer()
|
||||
}
|
||||
}
|
||||
|
||||
private func probeSimulatorState() {
|
||||
guard !stopped, simulatorStoppedReason == nil else {
|
||||
return
|
||||
}
|
||||
let state = SimulatorDeviceLookup.stateString(udid: configuration.deviceUDID) ?? "missing"
|
||||
guard state != "Booted" else {
|
||||
return
|
||||
}
|
||||
simulatorStoppedReason = state
|
||||
let error = RecorderError.make(26, "Simulator stopped before first frame (state: \(state))")
|
||||
writerQueue.async {
|
||||
if self.firstError == nil, !self.firstFrameReady {
|
||||
self.firstError = error
|
||||
self.signalFirstFrameReadyIfNeeded()
|
||||
}
|
||||
}
|
||||
let onSimulatorStopped = onSimulatorStopped
|
||||
eventQueue.async {
|
||||
onSimulatorStopped?(state)
|
||||
}
|
||||
}
|
||||
|
||||
private func rewireFramebuffer() {
|
||||
guard let displaySource else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
try displaySource.rewireFramebuffer()
|
||||
} catch {
|
||||
writerQueue.async {
|
||||
self.firstError = error
|
||||
self.signalFirstFrameReadyIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reservePendingFrame() -> Bool {
|
||||
pendingLock.lock()
|
||||
defer { pendingLock.unlock() }
|
||||
if pendingFrames >= configuration.maxPendingFrames {
|
||||
return false
|
||||
}
|
||||
pendingFrames += 1
|
||||
return true
|
||||
}
|
||||
|
||||
private func releasePendingFrame() {
|
||||
pendingLock.lock()
|
||||
pendingFrames = max(0, pendingFrames - 1)
|
||||
pendingLock.unlock()
|
||||
}
|
||||
|
||||
private func copySurfaceToOwnedPixelBuffer(
|
||||
_ surface: IOSurface,
|
||||
width: Int,
|
||||
height: Int
|
||||
) throws -> CVPixelBuffer {
|
||||
let pool = try writerQueue.sync {
|
||||
if let firstError {
|
||||
throw firstError
|
||||
}
|
||||
if writer == nil {
|
||||
try startWriter(width: width, height: height)
|
||||
}
|
||||
guard let pool = adaptor?.pixelBufferPool else {
|
||||
throw RecorderError.make(40, "AVAssetWriter pixel buffer pool is unavailable")
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
var output: CVPixelBuffer?
|
||||
let status = CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, pool, &output)
|
||||
guard status == kCVReturnSuccess, let destination = output else {
|
||||
throw RecorderError.make(41, "Failed to allocate pixel buffer from pool: \(status)")
|
||||
}
|
||||
|
||||
let destinationLockStatus = CVPixelBufferLockBaseAddress(destination, [])
|
||||
guard destinationLockStatus == kCVReturnSuccess else {
|
||||
throw RecorderError.make(50, "Failed to lock pixel buffer: \(destinationLockStatus)")
|
||||
}
|
||||
let surfaceLockStatus = IOSurfaceLock(surface, .readOnly, nil)
|
||||
guard surfaceLockStatus == KERN_SUCCESS else {
|
||||
CVPixelBufferUnlockBaseAddress(destination, [])
|
||||
throw RecorderError.make(49, "Failed to lock IOSurface: \(surfaceLockStatus)")
|
||||
}
|
||||
defer {
|
||||
IOSurfaceUnlock(surface, .readOnly, nil)
|
||||
CVPixelBufferUnlockBaseAddress(destination, [])
|
||||
}
|
||||
|
||||
guard let destinationAddress = CVPixelBufferGetBaseAddress(destination) else {
|
||||
throw RecorderError.make(42, "Pixel buffer has no base address")
|
||||
}
|
||||
let sourceAddress = IOSurfaceGetBaseAddress(surface)
|
||||
let sourceStride = IOSurfaceGetBytesPerRow(surface)
|
||||
let destinationStride = CVPixelBufferGetBytesPerRow(destination)
|
||||
let copyWidth = min(width, IOSurfaceGetWidth(surface), CVPixelBufferGetWidth(destination))
|
||||
let copyHeight = min(
|
||||
height, IOSurfaceGetHeight(surface), CVPixelBufferGetHeight(destination))
|
||||
let copyBytes = min(copyWidth * 4, sourceStride, destinationStride)
|
||||
for row in 0..<copyHeight {
|
||||
memcpy(
|
||||
destinationAddress.advanced(by: row * destinationStride),
|
||||
sourceAddress.advanced(by: row * sourceStride),
|
||||
copyBytes
|
||||
)
|
||||
}
|
||||
return destination
|
||||
}
|
||||
|
||||
private func appendOwned(
|
||||
pixelBuffer: CVPixelBuffer,
|
||||
seed: UInt32,
|
||||
capturedAt: CMTime,
|
||||
capturedAtWallClock: Date
|
||||
) {
|
||||
writerQueue.async {
|
||||
defer { self.releasePendingFrame() }
|
||||
if self.firstError != nil {
|
||||
return
|
||||
}
|
||||
do {
|
||||
try self.appendOnWriterQueue(
|
||||
pixelBuffer: pixelBuffer,
|
||||
seed: seed,
|
||||
capturedAt: capturedAt,
|
||||
capturedAtWallClock: capturedAtWallClock
|
||||
)
|
||||
} catch {
|
||||
self.firstError = error
|
||||
self.signalFirstFrameReadyIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func appendOnWriterQueue(
|
||||
pixelBuffer: CVPixelBuffer,
|
||||
seed: UInt32,
|
||||
capturedAt: CMTime,
|
||||
capturedAtWallClock: Date
|
||||
) throws {
|
||||
guard let writer, let input, let adaptor else {
|
||||
throw RecorderError.make(43, "AVAssetWriter is not initialized")
|
||||
}
|
||||
let isFirstFrame = firstAcceptedCaptureTime == nil
|
||||
let pts = isFirstFrame ? .zero : normalizedPresentationTime(for: capturedAt)
|
||||
if let lastPTS, CMTimeCompare(pts, lastPTS) <= 0 {
|
||||
return
|
||||
}
|
||||
guard try appendPendingBoundaryFrames(before: pts) else {
|
||||
return
|
||||
}
|
||||
guard input.isReadyForMoreMediaData else {
|
||||
return
|
||||
}
|
||||
if !adaptor.append(pixelBuffer, withPresentationTime: pts) {
|
||||
throw writer.error ?? RecorderError.make(44, "Failed to append pixel buffer")
|
||||
}
|
||||
if isFirstFrame {
|
||||
firstAcceptedCaptureTime = capturedAt
|
||||
firstAcceptedWallClock = capturedAtWallClock
|
||||
}
|
||||
lastPTS = pts
|
||||
lastSeed = seed
|
||||
lastAppendedPixelBuffer = pixelBuffer
|
||||
signalFirstFrameReadyIfNeeded()
|
||||
}
|
||||
|
||||
private func appendTailFrameIfNeeded(finalPTS: CMTime) throws {
|
||||
let deadline = Date().addingTimeInterval(5)
|
||||
while true {
|
||||
if try appendTailFrame(finalPTS: finalPTS) {
|
||||
return
|
||||
}
|
||||
if Date() >= deadline {
|
||||
throw RecorderError.make(45, "Timed out waiting to append tail frame")
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 0.05)
|
||||
}
|
||||
}
|
||||
|
||||
private func appendTailFrame(finalPTS: CMTime) throws -> Bool {
|
||||
let pts = normalizedPresentationTime(for: finalPTS)
|
||||
return try appendHeldFrame(at: pts)
|
||||
}
|
||||
|
||||
private func appendPendingBoundaryFrames(before pts: CMTime) throws -> Bool {
|
||||
guard configuration.segmentDuration > 0,
|
||||
firstAcceptedCaptureTime != nil
|
||||
else {
|
||||
return true
|
||||
}
|
||||
|
||||
while CMTimeGetSeconds(pts) > nextBoundaryElapsed {
|
||||
if let lastPTS, CMTimeGetSeconds(lastPTS) >= nextBoundaryElapsed {
|
||||
nextBoundaryElapsed += configuration.segmentDuration
|
||||
continue
|
||||
}
|
||||
|
||||
let boundaryPTS = CMTime(
|
||||
seconds: nextBoundaryElapsed, preferredTimescale: 1_000_000_000)
|
||||
guard try appendHeldFrame(at: boundaryPTS) else {
|
||||
return false
|
||||
}
|
||||
nextBoundaryElapsed += configuration.segmentDuration
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func appendHeldFrame(at pts: CMTime) throws -> Bool {
|
||||
guard let input,
|
||||
let adaptor,
|
||||
let lastPTS,
|
||||
let lastAppendedPixelBuffer
|
||||
else {
|
||||
return true
|
||||
}
|
||||
if CMTimeCompare(pts, lastPTS) <= 0 {
|
||||
return true
|
||||
}
|
||||
guard input.isReadyForMoreMediaData else {
|
||||
return false
|
||||
}
|
||||
if !adaptor.append(lastAppendedPixelBuffer, withPresentationTime: pts) {
|
||||
throw writer?.error ?? RecorderError.make(45, "Failed to append held frame")
|
||||
}
|
||||
self.lastPTS = pts
|
||||
return true
|
||||
}
|
||||
|
||||
private func normalizedPresentationTime(for capturedAt: CMTime) -> CMTime {
|
||||
let start = firstAcceptedCaptureTime ?? capturedAt
|
||||
return CMTimeSubtract(capturedAt, start)
|
||||
}
|
||||
|
||||
private func startWriter(width: Int, height: Int) throws {
|
||||
guard let outputWriter else {
|
||||
throw RecorderError.make(46, "Missing output writer")
|
||||
}
|
||||
|
||||
let writer: AVAssetWriter
|
||||
if configuration.segmentDuration > 0 {
|
||||
writer = AVAssetWriter(contentType: .mpeg4Movie)
|
||||
writer.delegate = outputWriter
|
||||
writer.outputFileTypeProfile = .mpeg4AppleHLS
|
||||
writer.preferredOutputSegmentInterval = CMTime(
|
||||
seconds: configuration.segmentDuration,
|
||||
preferredTimescale: 600
|
||||
)
|
||||
} else {
|
||||
writer = try AVAssetWriter(outputURL: outputWriter.singleRecordingURL, fileType: .mp4)
|
||||
}
|
||||
writer.initialSegmentStartTime = .zero
|
||||
|
||||
let input = AVAssetWriterInput(
|
||||
mediaType: .video,
|
||||
outputSettings: assetWriterSettings(width: width, height: height)
|
||||
)
|
||||
input.expectsMediaDataInRealTime = true
|
||||
|
||||
let attributes: [String: Any] = [
|
||||
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
|
||||
kCVPixelBufferWidthKey as String: width,
|
||||
kCVPixelBufferHeightKey as String: height,
|
||||
kCVPixelBufferIOSurfacePropertiesKey as String: [:],
|
||||
]
|
||||
let adaptor = AVAssetWriterInputPixelBufferAdaptor(
|
||||
assetWriterInput: input,
|
||||
sourcePixelBufferAttributes: attributes
|
||||
)
|
||||
|
||||
guard writer.canAdd(input) else {
|
||||
throw RecorderError.make(47, "AVAssetWriter cannot add video input")
|
||||
}
|
||||
writer.add(input)
|
||||
guard writer.startWriting() else {
|
||||
throw writer.error ?? RecorderError.make(48, "AVAssetWriter failed to start")
|
||||
}
|
||||
writer.startSession(atSourceTime: .zero)
|
||||
|
||||
self.writer = writer
|
||||
self.input = input
|
||||
self.adaptor = adaptor
|
||||
recordingWidth = width
|
||||
recordingHeight = height
|
||||
}
|
||||
|
||||
private func assetWriterSettings(width: Int, height: Int) -> [String: Any] {
|
||||
let codecType: AVVideoCodecType = configuration.codec == .hevc ? .hevc : .h264
|
||||
var compression: [String: Any] = [
|
||||
AVVideoAverageBitRateKey: configuration.bitrate,
|
||||
AVVideoExpectedSourceFrameRateKey: configuration.fps,
|
||||
AVVideoAllowFrameReorderingKey: true,
|
||||
]
|
||||
if configuration.segmentDuration > 0 {
|
||||
compression[AVVideoMaxKeyFrameIntervalKey] = max(
|
||||
1,
|
||||
Int((configuration.segmentDuration * Double(configuration.fps)).rounded())
|
||||
)
|
||||
}
|
||||
if configuration.codec == .h264 {
|
||||
compression[AVVideoProfileLevelKey] = AVVideoProfileLevelH264HighAutoLevel
|
||||
}
|
||||
return [
|
||||
AVVideoCodecKey: codecType,
|
||||
AVVideoWidthKey: width,
|
||||
AVVideoHeightKey: height,
|
||||
AVVideoCompressionPropertiesKey: compression,
|
||||
]
|
||||
}
|
||||
|
||||
private func signalFirstFrameReadyIfNeeded() {
|
||||
if firstFrameReady {
|
||||
return
|
||||
}
|
||||
firstFrameReady = true
|
||||
firstFrameSemaphore.signal()
|
||||
}
|
||||
|
||||
private func isFirstFrameReady() -> Bool {
|
||||
writerQueue.sync {
|
||||
firstFrameReady
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import CoreMedia
|
||||
import Foundation
|
||||
|
||||
enum RecorderError {
|
||||
static func make(_ code: Int, _ message: String) -> NSError {
|
||||
NSError(
|
||||
domain: "RecordSim",
|
||||
code: code,
|
||||
userInfo: [NSLocalizedDescriptionKey: message]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension JSONEncoder {
|
||||
static var prettySorted: JSONEncoder {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
return encoder
|
||||
}
|
||||
}
|
||||
|
||||
func timeSeconds(_ time: CMTime) -> Double? {
|
||||
guard time.isValid, !time.isIndefinite, !time.isNegativeInfinity, !time.isPositiveInfinity else {
|
||||
return nil
|
||||
}
|
||||
return CMTimeGetSeconds(time)
|
||||
}
|
||||
|
||||
func iso8601(_ date: Date) -> String {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
func unixMs(_ date: Date) -> Int64 {
|
||||
Int64((date.timeIntervalSince1970 * 1000).rounded())
|
||||
}
|
||||
|
||||
struct MonotonicClock {
|
||||
private static let timescale: CMTimeScale = 1_000_000_000
|
||||
|
||||
let startedAtUptimeNanoseconds: UInt64
|
||||
|
||||
init() {
|
||||
startedAtUptimeNanoseconds = DispatchTime.now().uptimeNanoseconds
|
||||
}
|
||||
|
||||
func elapsedSeconds() -> TimeInterval {
|
||||
TimeInterval(elapsedNanoseconds()) / TimeInterval(Self.timescale)
|
||||
}
|
||||
|
||||
func elapsedTime() -> CMTime {
|
||||
CMTime(value: CMTimeValue(elapsedNanoseconds()), timescale: Self.timescale)
|
||||
}
|
||||
|
||||
private func elapsedNanoseconds() -> UInt64 {
|
||||
DispatchTime.now().uptimeNanoseconds - startedAtUptimeNanoseconds
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
import Foundation
|
||||
import RecordSim
|
||||
|
||||
struct CLIOptions {
|
||||
var udid: String?
|
||||
var output: String?
|
||||
var segmentDuration: TimeInterval = 120
|
||||
var fps = 30
|
||||
var bitrate = 30_000_000
|
||||
var codec: RecorderCodec = .h264
|
||||
}
|
||||
|
||||
private var requestedStopSignal: CInt = 0
|
||||
private var simulatorStopped: CInt = 0
|
||||
|
||||
private func handleStopSignal(_ signal: CInt) {
|
||||
requestedStopSignal = signal
|
||||
}
|
||||
|
||||
private func isStopRequested() -> Bool {
|
||||
requestedStopSignal != 0 || simulatorStopped != 0
|
||||
}
|
||||
|
||||
func usage() -> String {
|
||||
"""
|
||||
Usage:
|
||||
record-sim --udid <UDID> --output <session-dir> [options]
|
||||
|
||||
Options:
|
||||
--segment-duration <seconds> Target fMP4 media segment duration. Use 0 for one MP4. Default: 120
|
||||
--fps <fps> Expected source frame rate. Default: 30
|
||||
--bitrate <bits/sec> AVAssetWriter average bitrate. Default: 30000000
|
||||
--codec <h264|hevc> Video codec. Default: h264
|
||||
-h, --help Show this help
|
||||
"""
|
||||
}
|
||||
|
||||
func parseOptions(_ args: [String]) throws -> CLIOptions {
|
||||
var options = CLIOptions()
|
||||
var i = 0
|
||||
|
||||
func next(_ flag: String) throws -> String {
|
||||
i += 1
|
||||
guard i < args.count else {
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing value for \(flag)"])
|
||||
}
|
||||
return args[i]
|
||||
}
|
||||
|
||||
while i < args.count {
|
||||
let arg = args[i]
|
||||
switch arg {
|
||||
case "--udid", "-u":
|
||||
options.udid = try next(arg)
|
||||
case "--output", "--output-dir", "--out", "-o":
|
||||
options.output = try next(arg)
|
||||
case "--segment-duration":
|
||||
options.segmentDuration = Double(try next(arg)) ?? -1
|
||||
case "--fps":
|
||||
options.fps = Int(try next(arg)) ?? 0
|
||||
case "--bitrate":
|
||||
options.bitrate = Int(try next(arg)) ?? 0
|
||||
case "--codec":
|
||||
let raw = try next(arg)
|
||||
guard let codec = RecorderCodec(rawValue: raw) else {
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--codec must be h264 or hevc"])
|
||||
}
|
||||
options.codec = codec
|
||||
case "--help", "-h":
|
||||
print(usage())
|
||||
exit(0)
|
||||
default:
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Unknown argument: \(arg)"])
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
|
||||
guard options.udid != nil else {
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing required --udid"])
|
||||
}
|
||||
guard options.output != nil else {
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing required --output"])
|
||||
}
|
||||
guard options.segmentDuration >= 0 else {
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--segment-duration must be non-negative"])
|
||||
}
|
||||
guard options.fps > 0, options.fps <= 120 else {
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--fps must be between 1 and 120"])
|
||||
}
|
||||
guard options.bitrate > 0 else {
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--bitrate must be positive"])
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
do {
|
||||
signal(SIGINT, handleStopSignal)
|
||||
signal(SIGTERM, handleStopSignal)
|
||||
|
||||
let options = try parseOptions(Array(CommandLine.arguments.dropFirst()))
|
||||
let configuration = SimulatorRecordingConfiguration(
|
||||
deviceUDID: options.udid!,
|
||||
outputDirectory: URL(fileURLWithPath: options.output!),
|
||||
fps: options.fps,
|
||||
bitrate: options.bitrate,
|
||||
codec: options.codec,
|
||||
segmentDuration: options.segmentDuration
|
||||
)
|
||||
|
||||
let recorder = SimulatorRecorder(configuration: configuration)
|
||||
recorder.onSimulatorStopped = { state in
|
||||
simulatorStopped = 1
|
||||
fputs("[record-sim] simulator stopped state=\(state); finalizing recording\n", stderr)
|
||||
}
|
||||
recorder.onFinalizationStage = { stage in
|
||||
switch stage {
|
||||
case .captureStopped:
|
||||
fputs("[record-sim] screen capture stopped; saving final video\n", stderr)
|
||||
case .videoSaved:
|
||||
fputs("[record-sim] final video saved; writing recording details\n", stderr)
|
||||
}
|
||||
}
|
||||
recorder.onSegment = { segment in
|
||||
switch segment.kind {
|
||||
case .initialization:
|
||||
fputs("[record-sim] init \(segment.relativePath) bytes=\(segment.byteCount)\n", stderr)
|
||||
case .media:
|
||||
let duration = segment.durationSeconds ?? 0
|
||||
fputs(String(format: "[record-sim] segment %@ duration=%.3fs bytes=%d\n", segment.relativePath, duration, segment.byteCount), stderr)
|
||||
}
|
||||
}
|
||||
|
||||
try recorder.start()
|
||||
try recorder.waitUntilFirstFrame()
|
||||
fputs("[record-sim] recording -> \(options.output!)\n", stderr)
|
||||
|
||||
while !isStopRequested() {
|
||||
Thread.sleep(forTimeInterval: 0.1)
|
||||
}
|
||||
|
||||
if requestedStopSignal != 0 {
|
||||
let signalName = switch requestedStopSignal {
|
||||
case SIGINT: "SIGINT"
|
||||
case SIGTERM: "SIGTERM"
|
||||
default: "signal \(requestedStopSignal)"
|
||||
}
|
||||
fputs("[record-sim] received \(signalName); finishing recording\n", stderr)
|
||||
}
|
||||
let manifest = try recorder.stop()
|
||||
if let recording = manifest.recording {
|
||||
fputs("[record-sim] done recording=\(recording)\n", stderr)
|
||||
} else {
|
||||
fputs("[record-sim] done segments=\(manifest.segments.count)\n", stderr)
|
||||
}
|
||||
fputs("[record-sim] manifest \(URL(fileURLWithPath: options.output!).appendingPathComponent("session.json").path)\n", stderr)
|
||||
} catch {
|
||||
fputs("error: \(error.localizedDescription)\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
+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(
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Android } from '@expo/eas-build-job';
|
||||
|
||||
export function resolveArtifactPath(job: Android.Job): string {
|
||||
return job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}';
|
||||
}
|
||||
@@ -60,7 +60,8 @@ jest.mock('../../utils/expoUpdates', () => ({
|
||||
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync: jest.fn(async () => null),
|
||||
}));
|
||||
jest.mock('../../utils/expoUpdatesEmbedded', () => ({
|
||||
uploadEmbeddedBundleAsync: jest.fn(),
|
||||
...jest.requireActual('../../utils/expoUpdatesEmbedded'),
|
||||
uploadEmbeddedBundleAsync: jest.fn(async () => ({ status: 'uploaded' })),
|
||||
}));
|
||||
jest.mock('../../utils/hooks', () => ({
|
||||
Hook: {
|
||||
@@ -317,36 +318,108 @@ describe(androidBuilder, () => {
|
||||
expect(runBuilderWithHooksAsync).toHaveBeenCalledWith(ctx, expect.any(Function));
|
||||
});
|
||||
|
||||
it('runs the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is set', async () => {
|
||||
const ctx = new BuildContext(createTestAndroidJob(), {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE: '1',
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
describe('embedded bundle upload phase', () => {
|
||||
function createContext({
|
||||
env = {},
|
||||
sdkVersion,
|
||||
}: {
|
||||
env?: Record<string, string>;
|
||||
sdkVersion?: string;
|
||||
}): BuildContext<Android.Job> {
|
||||
return new BuildContext(createTestAndroidJob(), {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
...env,
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
metadata: sdkVersion ? { sdkVersion } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
it('runs by default on SDK 58 and later', async () => {
|
||||
const ctx = createContext({ sdkVersion: '58.0.0' });
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ job: ctx.job, env: ctx.env, projectDir: '/workingdir/build' })
|
||||
);
|
||||
});
|
||||
|
||||
await androidBuilder(ctx);
|
||||
it.each([
|
||||
['uploaded', BuildPhaseResult.SUCCESS],
|
||||
['skipped', BuildPhaseResult.SKIPPED],
|
||||
['failed', BuildPhaseResult.WARNING],
|
||||
] as const)('marks the phase from the %s result', async (uploadResult, phaseResult) => {
|
||||
jest.mocked(uploadEmbeddedBundleAsync).mockResolvedValueOnce({ status: uploadResult });
|
||||
const ctx = createContext({ sdkVersion: '58.0.0' });
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
|
||||
});
|
||||
await androidBuilder(ctx);
|
||||
|
||||
it('skips the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is not set', async () => {
|
||||
const ctx = new BuildContext(createTestAndroidJob(), {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
expect(datadogDistributionMock).toHaveBeenCalledWith(
|
||||
'eas.build.phase_duration',
|
||||
expect.any(Number),
|
||||
expect.objectContaining({
|
||||
build_phase: BuildPhase.UPLOAD_EMBEDDED_BUNDLE.toLowerCase(),
|
||||
result: phaseResult,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await androidBuilder(ctx);
|
||||
it('skips by default on SDK 57 and earlier', async () => {
|
||||
const ctx = createContext({ sdkVersion: '57.0.0' });
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips by default when the SDK version is unknown', async () => {
|
||||
const ctx = createContext({});
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs on SDK 57 and earlier when EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE is 1', async () => {
|
||||
const ctx = createContext({
|
||||
sdkVersion: '57.0.0',
|
||||
env: { EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE: '1' },
|
||||
});
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ job: ctx.job })
|
||||
);
|
||||
});
|
||||
|
||||
it('skips on SDK 58 and later when EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE is 0', async () => {
|
||||
const ctx = createContext({
|
||||
sdkVersion: '58.0.0',
|
||||
env: { EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE: '0' },
|
||||
});
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs on SDK 57 and earlier when the experimental EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is 1', async () => {
|
||||
const ctx = createContext({
|
||||
sdkVersion: '57.0.0',
|
||||
env: { EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE: '1' },
|
||||
});
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ job: ctx.job })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
runGradleCommand,
|
||||
} from '../android/gradle';
|
||||
import { formatGradleProfileReport, parseGradleProfile } from '../android/gradleProfile';
|
||||
import { resolveArtifactPath } from '../android/resolve';
|
||||
import { Sentry } from '../sentry';
|
||||
import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle';
|
||||
import { prebuildAsync } from '../common/prebuild';
|
||||
@@ -32,7 +33,10 @@ import {
|
||||
configureExpoUpdatesIfInstalledAsync,
|
||||
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
|
||||
} from '../utils/expoUpdates';
|
||||
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
|
||||
import {
|
||||
shouldUploadEmbeddedBundle,
|
||||
uploadEmbeddedBundleAsync,
|
||||
} from '../utils/expoUpdatesEmbedded';
|
||||
import { Hook, runHookIfPresent } from '../utils/hooks';
|
||||
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
|
||||
|
||||
@@ -217,15 +221,26 @@ async function buildInnerAsync(
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => {
|
||||
await uploadApplicationArchive(ctx, {
|
||||
patternOrPath: ctx.job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}',
|
||||
patternOrPath: resolveArtifactPath(ctx.job),
|
||||
rootDir: ctx.getReactNativeProjectDirectory(),
|
||||
logger: ctx.logger,
|
||||
});
|
||||
});
|
||||
|
||||
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
|
||||
if (shouldUploadEmbeddedBundle(ctx)) {
|
||||
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
|
||||
await uploadEmbeddedBundleAsync(ctx);
|
||||
const { status } = await uploadEmbeddedBundleAsync({
|
||||
job: ctx.job,
|
||||
env: ctx.env,
|
||||
logger: ctx.logger,
|
||||
projectDir: ctx.getReactNativeProjectDirectory(),
|
||||
appConfig: await ctx.appConfig,
|
||||
});
|
||||
if (status === 'skipped') {
|
||||
ctx.markBuildPhaseSkipped();
|
||||
} else if (status === 'failed') {
|
||||
ctx.markBuildPhaseHasWarnings();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,10 @@ import {
|
||||
configureExpoUpdatesIfInstalledAsync,
|
||||
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
|
||||
} from '../utils/expoUpdates';
|
||||
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
|
||||
import {
|
||||
shouldUploadEmbeddedBundle,
|
||||
uploadEmbeddedBundleAsync,
|
||||
} from '../utils/expoUpdatesEmbedded';
|
||||
import { Hook, runHookIfPresent } from '../utils/hooks';
|
||||
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
|
||||
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
|
||||
@@ -234,15 +237,26 @@ async function buildInnerAsync(
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => {
|
||||
await uploadApplicationArchive(ctx, {
|
||||
patternOrPath: resolveArtifactPath(ctx),
|
||||
patternOrPath: resolveArtifactPath(ctx.job),
|
||||
rootDir: ctx.getReactNativeProjectDirectory(),
|
||||
logger: ctx.logger,
|
||||
});
|
||||
});
|
||||
|
||||
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
|
||||
if (shouldUploadEmbeddedBundle(ctx)) {
|
||||
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
|
||||
await uploadEmbeddedBundleAsync(ctx);
|
||||
const { status } = await uploadEmbeddedBundleAsync({
|
||||
job: ctx.job,
|
||||
env: ctx.env,
|
||||
logger: ctx.logger,
|
||||
projectDir: ctx.getReactNativeProjectDirectory(),
|
||||
appConfig: await ctx.appConfig,
|
||||
});
|
||||
if (status === 'skipped') {
|
||||
ctx.markBuildPhaseSkipped();
|
||||
} else if (status === 'failed') {
|
||||
ctx.markBuildPhaseHasWarnings();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -16,11 +16,13 @@ function createCtx(
|
||||
{ workingdir = '/tmp/wd' }: { workingdir?: string } = {}
|
||||
): {
|
||||
ctx: BuildContext<Ios.Job>;
|
||||
info: jest.Mock;
|
||||
warn: jest.Mock;
|
||||
} {
|
||||
const info = jest.fn();
|
||||
const warn = jest.fn();
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
info,
|
||||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warn,
|
||||
@@ -36,7 +38,7 @@ function createCtx(
|
||||
workingdir,
|
||||
}
|
||||
);
|
||||
return { ctx, warn };
|
||||
return { ctx, info, warn };
|
||||
}
|
||||
|
||||
// Project checkout lives at <workingdir>/build (job projectRootDirectory is '.').
|
||||
@@ -79,22 +81,31 @@ describe(parseJobHooksAsync, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('treats an unknown hook key as inert and warns', async () => {
|
||||
const { ctx, warn } = createCtx({
|
||||
it('writes nothing to the build log while parsing', async () => {
|
||||
const { ctx, info, warn } = createCtx({
|
||||
before_install_node_modules: [{ run: 'echo hi' }],
|
||||
before_submit: [{ run: 'echo hi' }],
|
||||
before_bogus: [{ run: 'echo hi' }],
|
||||
});
|
||||
await parseJobHooksAsync(ctx, INSTALL);
|
||||
expect(info).not.toHaveBeenCalled();
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats an unknown hook key as inert', async () => {
|
||||
const { ctx } = createCtx({
|
||||
before_bogus: [{ run: 'echo hi' }],
|
||||
});
|
||||
const parsed = await parseJobHooksAsync(ctx, INSTALL);
|
||||
expect(parsed!.hookEntriesByKey).toEqual({});
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('unknown hook key "before_bogus"'));
|
||||
});
|
||||
|
||||
it('shape-checks a registered-but-unwrapped key, then warns and skips it', async () => {
|
||||
const { ctx, warn } = createCtx({
|
||||
it('shape-checks a registered-but-unwrapped key, then skips it', async () => {
|
||||
const { ctx } = createCtx({
|
||||
before_submit: [{ run: 'echo hi' }],
|
||||
});
|
||||
const parsed = await parseJobHooksAsync(ctx, INSTALL);
|
||||
expect(parsed!.hookEntriesByKey.before_submit).toBeUndefined();
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('hooks.before_submit'));
|
||||
});
|
||||
|
||||
it('shape-validates a registered-but-unwrapped key even though it is skipped', async () => {
|
||||
@@ -192,10 +203,9 @@ describe(parseJobHooksAsync, () => {
|
||||
});
|
||||
|
||||
it('never loads a composite function referenced under a registered-but-unwrapped key', async () => {
|
||||
const { ctx, warn } = createCtx({ before_submit: [{ uses: './.eas/functions/missing' }] });
|
||||
const { ctx } = createCtx({ before_submit: [{ uses: './.eas/functions/missing' }] });
|
||||
const parsed = await parseJobHooksAsync(ctx, INSTALL);
|
||||
expect(parsed!.hookEntriesByKey).toEqual({});
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('hooks.before_submit'));
|
||||
});
|
||||
|
||||
it('wraps a cross-key duplicate step id in the aggregate error', async () => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import spawn from '@expo/turtle-spawn';
|
||||
import { randomBytes, randomUUID } from 'crypto';
|
||||
import { vol } from 'memfs';
|
||||
import fetch, { Response } from 'node-fetch';
|
||||
import { Readable } from 'stream';
|
||||
import { setTimeout } from 'timers/promises';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
@@ -390,7 +391,13 @@ describe('projectSources', () => {
|
||||
ctx.graphqlClient.mutation = mockGraphqlMutation as any;
|
||||
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
fetchMock.mockImplementation(async () => ({ ok: true }) as Response);
|
||||
let uploadedBody = '';
|
||||
fetchMock.mockImplementation(async (_url, request) => {
|
||||
for await (const chunk of request!.body as Readable) {
|
||||
uploadedBody += chunk;
|
||||
}
|
||||
return { ok: true } as Response;
|
||||
});
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: ctx.job.projectArchive }),
|
||||
@@ -414,10 +421,14 @@ describe('projectSources', () => {
|
||||
'https://storage.example.com/upload',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
headers: { 'x-custom-header': 'value' },
|
||||
body: '{"archiveContent":["project/README.md","project/package.json"]}',
|
||||
headers: {
|
||||
'x-custom-header': 'value',
|
||||
'content-length': '63',
|
||||
'content-type': 'text/plain;charset=UTF-8',
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(uploadedBody).toBe('{"archiveContent":["project/README.md","project/package.json"]}');
|
||||
|
||||
// Verify build metadata was updated
|
||||
expect(mockGraphqlMutation).toHaveBeenNthCalledWith(
|
||||
|
||||
@@ -44,9 +44,8 @@ export async function runHookableBuildPhaseAsync<TJob extends BuildJob, T>({
|
||||
if (hooks) {
|
||||
try {
|
||||
await runHookEntriesAsync({ ctx, hooks, timing: 'after', anchor, anchorFailed: true });
|
||||
} catch (hookError) {
|
||||
// The phase error wins; still surface the after-hook failure in the log.
|
||||
ctx.logger.error({ err: hookError }, `after_${anchor} hook failed`);
|
||||
} catch {
|
||||
// The phase error wins.
|
||||
}
|
||||
}
|
||||
throw phaseError;
|
||||
|
||||
@@ -53,19 +53,17 @@ export async function parseJobHooksAsync<TJob extends BuildJob>(
|
||||
const globalContext = new BuildStepGlobalContext(customBuildContext, false);
|
||||
// Hook steps run in the checked-out project directory, not the pre-checkout
|
||||
// target directory the global context defaults to.
|
||||
globalContext.markAsCheckedOut(ctx.logger);
|
||||
globalContext.markAsCheckedOut();
|
||||
|
||||
const externalFunctions = getEasFunctions(customBuildContext);
|
||||
const externalFunctionGroups = getEasFunctionGroups(customBuildContext);
|
||||
|
||||
// Shape-validate every REGISTERED key, reachable or not (steps-world parity:
|
||||
// a malformed `before_submit` fails a steps build, so it fails here too), and
|
||||
// warn on keys this build won't run. An unknown key is inert — a key newer
|
||||
// than this worker must be ignored, never fail the job.
|
||||
// a malformed `before_submit` fails a steps build, so it fails here too). An
|
||||
// unknown key is inert — a key newer than this worker must be ignored, never
|
||||
// fail the job.
|
||||
for (const [key, steps] of Object.entries(hooks)) {
|
||||
const parsed = parseHookKey(key);
|
||||
if (parsed === null) {
|
||||
ctx.logger.warn(`Ignoring unknown hook key "${key}".`);
|
||||
if (parseHookKey(key) === null) {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(steps) && steps.length === 0) {
|
||||
@@ -81,15 +79,6 @@ export async function parseJobHooksAsync<TJob extends BuildJob>(
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
// Registered but not wrapped by this build: shape-checked above, then
|
||||
// skipped. Function existence is deliberately NOT checked (that happens only
|
||||
// during construction) — an unknown `uses:` under an unwrapped key warns
|
||||
// rather than failing the build.
|
||||
if (!wrappedAnchors.includes(parsed.anchorId)) {
|
||||
ctx.logger.warn(
|
||||
`Ignoring "hooks.${key}": this build does not run the "${parsed.anchorId}" step.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Construct entries for the wrapped anchors in EXECUTION order (per anchor,
|
||||
|
||||
@@ -5,13 +5,13 @@ import { asyncResult } from '@expo/results';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'fs/promises';
|
||||
import { graphql } from 'gql.tada';
|
||||
import fetch from 'node-fetch';
|
||||
import nullthrows from 'nullthrows';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { shallowCloneRepositoryAsync } from './git';
|
||||
import { BuildContext } from '../context';
|
||||
import { uploadContentWithSignedUrl } from '../storage/uploadWithSignedUrl';
|
||||
import { turtleFetch } from '../utils/turtleFetch';
|
||||
|
||||
export async function prepareProjectSourcesAsync<TJob extends Job>(
|
||||
@@ -208,10 +208,9 @@ async function uploadProjectMetadataAsync(
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
|
||||
await fetch(uploadSession.url, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ archiveContent: sortedFiles }),
|
||||
headers: uploadSession.headers,
|
||||
await uploadContentWithSignedUrl({
|
||||
signedUrl: uploadSession,
|
||||
content: JSON.stringify({ archiveContent: sortedFiles }),
|
||||
});
|
||||
|
||||
const updateMetadataResult = await ctx.graphqlClient
|
||||
|
||||
@@ -4,13 +4,11 @@ import {
|
||||
BuildPhaseResult,
|
||||
Env,
|
||||
EnvironmentSecretType,
|
||||
GenericArtifactType,
|
||||
Job,
|
||||
LogMarker,
|
||||
ManagedArtifactType,
|
||||
Metadata,
|
||||
errors,
|
||||
isGenericArtifact,
|
||||
} from '@expo/eas-build-job';
|
||||
import { BuildTrigger } from '@expo/eas-build-job/dist/common';
|
||||
import { bunyan } from '@expo/logger';
|
||||
@@ -42,7 +40,7 @@ export type ArtifactToUpload =
|
||||
paths: string[];
|
||||
}
|
||||
| {
|
||||
type: GenericArtifactType;
|
||||
type: null;
|
||||
name: string;
|
||||
paths: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
@@ -69,6 +67,7 @@ export interface BuildContextOptions {
|
||||
metadata?: Metadata;
|
||||
expoApiV2BaseUrl?: string;
|
||||
mcpServerUrl?: string;
|
||||
addBuildLogMask?: (secret: string) => void;
|
||||
}
|
||||
|
||||
export class SkipNativeBuildError extends Error {}
|
||||
@@ -86,6 +85,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 +115,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 = {
|
||||
@@ -238,7 +239,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
logger: bunyan;
|
||||
}): Promise<{ artifactId: string | null }> {
|
||||
const result = await this._uploadArtifact({ artifact, logger });
|
||||
if (result.filename && !isGenericArtifact(artifact)) {
|
||||
if (result.filename && artifact.type !== null) {
|
||||
this.artifacts[artifact.type] = result.filename;
|
||||
}
|
||||
return { artifactId: result.artifactId ?? null };
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface BuilderRuntimeApi {
|
||||
uploadArtifact: (spec: { artifact: ArtifactToUpload; logger: bunyan }) => Promise<{
|
||||
artifactId: string | null;
|
||||
}>;
|
||||
addBuildLogMask?: (secret: string) => void;
|
||||
}
|
||||
|
||||
export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuildContextProvider {
|
||||
@@ -63,12 +64,12 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
|
||||
public readonly logger: bunyan;
|
||||
public readonly graphqlClient: Client;
|
||||
public readonly runtimeApi: BuilderRuntimeApi;
|
||||
public readonly expoApiV2BaseUrl?: string;
|
||||
public readonly mcpServerUrl?: string;
|
||||
public job: TJob;
|
||||
public metadata?: Metadata;
|
||||
|
||||
private _env: Env;
|
||||
private readonly expoApiV2BaseUrl?: string;
|
||||
private readonly pendingMetricUploads: Promise<void>[] = [];
|
||||
|
||||
constructor(buildCtx: BuildContext<TJob>) {
|
||||
@@ -84,6 +85,7 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
|
||||
this.buildLogsDirectory = path.join(buildCtx.workingdir, 'logs');
|
||||
this.runtimeApi = {
|
||||
uploadArtifact: (...args) => buildCtx['uploadArtifact'](...args),
|
||||
addBuildLogMask: buildCtx.addBuildLogMask,
|
||||
};
|
||||
this.mcpServerUrl = buildCtx.mcpServerUrl;
|
||||
this.expoApiV2BaseUrl = buildCtx.expoApiV2BaseUrl;
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { Sentry } from './sentry';
|
||||
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from './steps/utils/deviceRunSessionWorkerApi';
|
||||
import { turtleFetch } from './utils/turtleFetch';
|
||||
|
||||
type MetricsTarget =
|
||||
| { kind: 'build'; turtleBuildId: string }
|
||||
| { kind: 'jobRun'; turtleJobRunId: string };
|
||||
| { kind: 'jobRun'; turtleJobRunId: string }
|
||||
// A device run session job with a session token. The token names the session and its job run.
|
||||
| { kind: 'deviceRunSession' };
|
||||
|
||||
type DatadogSetupOptions = {
|
||||
expoApiV2BaseUrl: string;
|
||||
robotAccessToken: string;
|
||||
target: MetricsTarget;
|
||||
};
|
||||
type DatadogSetupOptions =
|
||||
| {
|
||||
expoApiV2BaseUrl: string;
|
||||
robotAccessToken: string;
|
||||
target: Exclude<MetricsTarget, { kind: 'deviceRunSession' }>;
|
||||
}
|
||||
| {
|
||||
expoApiV2BaseUrl: string;
|
||||
deviceRunSessionToken: string;
|
||||
target: Extract<MetricsTarget, { kind: 'deviceRunSession' }>;
|
||||
};
|
||||
|
||||
let setupOptions: DatadogSetupOptions | null = null;
|
||||
let pendingUploads: Promise<unknown>[] = [];
|
||||
@@ -18,9 +27,30 @@ function withTargetTag(
|
||||
tags: Record<string, string>,
|
||||
target: MetricsTarget
|
||||
): Record<string, string> {
|
||||
// A device run session job is a job run, so its metrics keep the job run tag.
|
||||
return { ...tags, target: target.kind === 'build' ? 'build' : 'job_run' };
|
||||
}
|
||||
|
||||
function getMetricsRequest(options: DatadogSetupOptions): {
|
||||
path: string;
|
||||
headers: Record<string, string>;
|
||||
} {
|
||||
if ('deviceRunSessionToken' in options) {
|
||||
return {
|
||||
path: 'device-run-session-worker/metrics',
|
||||
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: options.deviceRunSessionToken },
|
||||
};
|
||||
}
|
||||
const { target, robotAccessToken } = options;
|
||||
return {
|
||||
path:
|
||||
target.kind === 'build'
|
||||
? `turtle-builds/${target.turtleBuildId}/metrics`
|
||||
: `turtle-job-runs/${target.turtleJobRunId}/metrics`,
|
||||
headers: { Authorization: `Bearer ${robotAccessToken}` },
|
||||
};
|
||||
}
|
||||
|
||||
export const Datadog = {
|
||||
setup(opts: DatadogSetupOptions | null): void {
|
||||
setupOptions = opts;
|
||||
@@ -30,7 +60,7 @@ export const Datadog = {
|
||||
if (!setupOptions) {
|
||||
return;
|
||||
}
|
||||
const { expoApiV2BaseUrl, robotAccessToken, target } = setupOptions;
|
||||
const { expoApiV2BaseUrl, target } = setupOptions;
|
||||
const metrics = [
|
||||
{
|
||||
name,
|
||||
@@ -40,16 +70,11 @@ export const Datadog = {
|
||||
},
|
||||
];
|
||||
|
||||
const metricsPath =
|
||||
target.kind === 'build'
|
||||
? `turtle-builds/${target.turtleBuildId}/metrics`
|
||||
: `turtle-job-runs/${target.turtleJobRunId}/metrics`;
|
||||
const { path: metricsPath, headers } = getMetricsRequest(setupOptions);
|
||||
|
||||
const uploadPromise = turtleFetch(new URL(metricsPath, expoApiV2BaseUrl).toString(), 'POST', {
|
||||
json: { metrics },
|
||||
headers: {
|
||||
Authorization: `Bearer ${robotAccessToken}`,
|
||||
},
|
||||
headers,
|
||||
retries: 2,
|
||||
}).catch(err => {
|
||||
Sentry.capture(`Failed to report turtle ${target.kind} metric`, err, {
|
||||
@@ -61,7 +86,9 @@ export const Datadog = {
|
||||
},
|
||||
|
||||
log(message: string, tags: Record<string, string> = {}): void {
|
||||
if (!setupOptions) {
|
||||
// The device run session worker API has no logs route, and the session token is not a bearer
|
||||
// credential, so a device run session job does not report these logs.
|
||||
if (!setupOptions || !('robotAccessToken' in setupOptions)) {
|
||||
return;
|
||||
}
|
||||
const { expoApiV2BaseUrl, robotAccessToken, target } = setupOptions;
|
||||
|
||||
@@ -3,8 +3,16 @@ import HttpLogStream from './logging/HttpLogStream';
|
||||
import RemoteLoggerStream from './logging/RemoteLoggerStream';
|
||||
|
||||
export { Builders, HttpLogStream, RemoteLoggerStream };
|
||||
export { uploadWithSignedUrl } from './storage/uploadWithSignedUrl';
|
||||
export type { SignedUrl, UploadWithSignedUrlParams } from './storage/uploadWithSignedUrl';
|
||||
export {
|
||||
SignedUrlUploadApiError,
|
||||
SignedUrlUploadNetworkError,
|
||||
uploadWithSignedUrl,
|
||||
} from './storage/uploadWithSignedUrl';
|
||||
export type {
|
||||
SignedUrl,
|
||||
UploadWithSignedUrlOptions,
|
||||
UploadWithSignedUrlParams,
|
||||
} from './storage/uploadWithSignedUrl';
|
||||
export { downloadCacheAsync } from './steps/functions/restoreCache';
|
||||
export { uploadCacheAsync } from './steps/functions/saveCache';
|
||||
|
||||
@@ -33,5 +41,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';
|
||||
@@ -15,10 +15,10 @@ export function resolveScheme(ctx: BuildContext<Ios.Job>): string {
|
||||
return schemes[0];
|
||||
}
|
||||
|
||||
export function resolveArtifactPath(ctx: BuildContext<Ios.Job>): string {
|
||||
if (ctx.job.applicationArchivePath) {
|
||||
return ctx.job.applicationArchivePath;
|
||||
} else if (ctx.job.simulator) {
|
||||
export function resolveArtifactPath(job: Ios.Job): string {
|
||||
if (job.applicationArchivePath) {
|
||||
return job.applicationArchivePath;
|
||||
} else if (job.simulator) {
|
||||
return 'ios/build/Build/Products/*simulator/*.app';
|
||||
} else {
|
||||
return 'ios/build/*.ipa';
|
||||
|
||||
@@ -56,10 +56,10 @@ class RemoteLoggerStream extends Writable {
|
||||
return null;
|
||||
}
|
||||
|
||||
public async init(): Promise<string> {
|
||||
public async init(): Promise<void> {
|
||||
this.fileHandle = await fs.open(this.temporaryLogsPath, 'w+', 0o660);
|
||||
this.flushInterval = setInterval(() => this.flush(), this.options.uploadIntervalMs);
|
||||
return (await this.flush(true)) as string;
|
||||
await this.flush(true);
|
||||
}
|
||||
|
||||
public async cleanUp(): Promise<void> {
|
||||
@@ -121,17 +121,18 @@ class RemoteLoggerStream extends Writable {
|
||||
}
|
||||
}
|
||||
|
||||
private async flush(force = false): Promise<string | void> {
|
||||
private async flush(force = false): Promise<void> {
|
||||
if (force || this.hasChangesToUpload) {
|
||||
if (this.uploadingPromise) {
|
||||
await this.uploadingPromise;
|
||||
return await this.flush(force);
|
||||
await this.flush(force);
|
||||
return;
|
||||
}
|
||||
return await this.flushInternal();
|
||||
await this.flushInternal();
|
||||
}
|
||||
}
|
||||
|
||||
private async flushInternal(): Promise<string | void> {
|
||||
private async flushInternal(): Promise<void> {
|
||||
await this.safeWriteToFile();
|
||||
|
||||
let resolveFn: PromiseResolveFn;
|
||||
@@ -139,21 +140,17 @@ class RemoteLoggerStream extends Writable {
|
||||
this.uploadingPromise = new Promise(res => {
|
||||
resolveFn = res;
|
||||
});
|
||||
return await this.upload()
|
||||
.then(result => {
|
||||
return result;
|
||||
})
|
||||
await this.upload()
|
||||
.catch(err => {
|
||||
this.logger.error({ err }, 'Failed to upload logs file');
|
||||
})
|
||||
.then(result => {
|
||||
.then(() => {
|
||||
this.uploadingPromise = undefined;
|
||||
resolveFn();
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
private async upload(): Promise<string | void> {
|
||||
private async upload(): Promise<void> {
|
||||
if (!this.uploadMethod) {
|
||||
return;
|
||||
}
|
||||
@@ -165,7 +162,7 @@ class RemoteLoggerStream extends Writable {
|
||||
);
|
||||
};
|
||||
|
||||
return await uploadWithSignedUrl({
|
||||
await uploadWithSignedUrl({
|
||||
signedUrl: this.uploadMethod.signedUrl,
|
||||
srcGeneratorAsync,
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import { getEasFunctions } from '../easFunctions';
|
||||
jest.mock('fs');
|
||||
|
||||
describe('hook anchor self-declarations', () => {
|
||||
it('exactly the four v0 functions declare anchors, with these bindings', () => {
|
||||
it('declares the expected hook anchors', () => {
|
||||
const declarations: Record<string, HookAnchorId> = {};
|
||||
for (const buildFunction of getEasFunctions(createCustomBuildContextMock())) {
|
||||
if (buildFunction.__hookId !== undefined) {
|
||||
@@ -24,6 +24,7 @@ describe('hook anchor self-declarations', () => {
|
||||
'eas/checkout': 'checkout',
|
||||
'eas/install_node_modules': 'install_node_modules',
|
||||
'eas/upload_to_asc': 'submit',
|
||||
'eas/submit_to_google_play': 'submit',
|
||||
'eas/maestro_tests': 'maestro_tests',
|
||||
});
|
||||
});
|
||||
@@ -87,6 +88,17 @@ describe('hook anchor self-declarations', () => {
|
||||
bundle_short_version: '1.0.0',
|
||||
},
|
||||
],
|
||||
[
|
||||
'eas/submit_to_google_play',
|
||||
'before_submit',
|
||||
'after_submit',
|
||||
{
|
||||
artifact_path: 'app.aab',
|
||||
artifact_type: 'aab',
|
||||
package_name: 'com.example.app',
|
||||
service_account_key_path: 'key.json',
|
||||
},
|
||||
],
|
||||
[
|
||||
'eas/maestro_tests',
|
||||
'before_maestro_tests',
|
||||
|
||||
@@ -10,6 +10,7 @@ import { configureIosVersionFunction } from './functions/configureIosVersion';
|
||||
import { createSubmissionEntityFunction } from './functions/createSubmissionEntity';
|
||||
import { createDownloadArtifactFunction } from './functions/downloadArtifact';
|
||||
import { createDownloadBuildFunction } from './functions/downloadBuild';
|
||||
import { createDownloadSubmissionArchiveFunction } from './functions/downloadSubmissionArchive';
|
||||
import { createEasDeployBuildFunction } from './functions/deploy';
|
||||
import { createEasExportBuildFunction } from './functions/export';
|
||||
import { eagerBundleBuildFunction } from './functions/eagerBundle';
|
||||
@@ -19,6 +20,7 @@ import { generateGymfileFromTemplateFunction } from './functions/generateGymfile
|
||||
import { createGetCredentialsForBuildTriggeredByGithubIntegration } from './functions/getCredentialsForBuildTriggeredByGitHubIntegration';
|
||||
import { injectAndroidCredentialsFunction } from './functions/injectAndroidCredentials';
|
||||
import { createInstallMaestroBuildFunction } from './functions/installMaestro';
|
||||
import { createInstallMitmproxyBuildFunction } from './functions/installMitmproxy';
|
||||
import { createInstallBuildFunction } from './functions/installBuild';
|
||||
import { createInstallNodeModulesBuildFunction } from './functions/installNodeModules';
|
||||
import { createInstallPodsBuildFunction } from './functions/installPods';
|
||||
@@ -26,6 +28,8 @@ import { createLaunchApplicationFunction } from './functions/launchApplication';
|
||||
import { createPrebuildBuildFunction } from './functions/prebuild';
|
||||
import { createReadAppConfigBuildFunction } from './functions/readAppConfig';
|
||||
import { createReadIpaInfoBuildFunction } from './functions/readIpaInfo';
|
||||
import { createReadAndroidAppInfoBuildFunction } from './functions/readAndroidAppInfo';
|
||||
import { createSubmitToGooglePlayBuildFunction } from './functions/submitToGooglePlay';
|
||||
import { createReadPackageJsonBuildFunction } from './functions/readPackageJson';
|
||||
import { createRepackBuildFunction } from './functions/repack';
|
||||
import { createReportMaestroTestResultsFunction } from './functions/reportMaestroTestResults';
|
||||
@@ -42,9 +46,10 @@ import { runGradleFunction } from './functions/runGradle';
|
||||
import { createMaestroTestsBuildFunction } from './functions/maestroTests';
|
||||
import { createSaveBuildCacheFunction } from './functions/saveBuildCache';
|
||||
import { createRolloutPosthogFlagFunction } from './functions/rolloutPosthogFlag';
|
||||
import { createRunAgentBuildFunction } from './functions/runAgent';
|
||||
import { createSaveCacheFunction } from './functions/saveCache';
|
||||
import { createSendSlackMessageFunction } from './functions/sendSlackMessage';
|
||||
import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/startAgentDeviceRemoteSession';
|
||||
import { createStartAgentDeviceSessionBuildFunction } from './functions/startAgentDeviceSession';
|
||||
import { createStartAndroidEmulatorBuildFunction } from './functions/startAndroidEmulator';
|
||||
import { createStartArgentRemoteSessionBuildFunction } from './functions/startArgentRemoteSession';
|
||||
import { createStartAppiumRemoteSessionBuildFunction } from './functions/startAppiumRemoteSession';
|
||||
@@ -54,11 +59,15 @@ import { createStartIosSimulatorRecordingsBuildFunction } from './functions/star
|
||||
import { createStartLocalEgressBuildFunction } from './functions/startLocalEgress';
|
||||
import { createStartWebPreviewRemoteSessionBuildFunction } from './functions/startWebPreviewRemoteSession';
|
||||
import { createStartServeSimMetricsBuildFunction } from './functions/startServeSimMetrics';
|
||||
import { createStartServeSimLogsBuildFunction } from './functions/startServeSimLogs';
|
||||
import { createCollectServeSimLogsBuildFunction } from './functions/collectServeSimLogs';
|
||||
import { createStartSandboxBuildFunction } from './functions/startSandbox';
|
||||
import { createCollectServeSimMetricsBuildFunction } from './functions/collectServeSimMetrics';
|
||||
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
|
||||
import { createUploadDeviceRunSessionScreenRecordingsBuildFunction } from './functions/uploadDeviceRunSessionScreenRecordings';
|
||||
import { createUploadEmbeddedBundleBuildFunction } from './functions/uploadEmbeddedBundle';
|
||||
import { createUploadToAscBuildFunction } from './functions/uploadToAsc';
|
||||
import { createUpdateTestFlightMetadataBuildFunction } from './functions/updateTestFlightMetadata';
|
||||
import { createSetUpNpmrcBuildFunction } from './functions/useNpmToken';
|
||||
import { createWaitForPosthogMetricFunction } from './functions/waitForPosthogMetric';
|
||||
import { createUploadPosthogSourcemapsFunction } from './functions/uploadPosthogSourcemaps';
|
||||
@@ -77,7 +86,9 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createInstallNodeModulesBuildFunction(),
|
||||
createPrebuildBuildFunction(),
|
||||
createReadIpaInfoBuildFunction(),
|
||||
createReadAndroidAppInfoBuildFunction(),
|
||||
createDownloadBuildFunction(ctx),
|
||||
createDownloadSubmissionArchiveFunction(ctx),
|
||||
createInstallBuildFunction(),
|
||||
createLaunchApplicationFunction(),
|
||||
createEasExportBuildFunction(),
|
||||
@@ -99,7 +110,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
generateGymfileFromTemplateFunction(),
|
||||
runFastlaneFunction(),
|
||||
parseXcactivitylogFunction(),
|
||||
createStartAgentDeviceRemoteSessionBuildFunction(ctx),
|
||||
createStartAgentDeviceSessionBuildFunction(ctx),
|
||||
createStartArgentRemoteSessionBuildFunction(ctx),
|
||||
createStartAppiumRemoteSessionBuildFunction(ctx),
|
||||
createStartAndroidEmulatorBuildFunction(),
|
||||
@@ -111,9 +122,13 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx),
|
||||
createStartWebPreviewRemoteSessionBuildFunction(ctx),
|
||||
createStartServeSimMetricsBuildFunction(),
|
||||
createStartServeSimLogsBuildFunction(),
|
||||
createCollectServeSimLogsBuildFunction(ctx),
|
||||
createStartSandboxBuildFunction(ctx),
|
||||
createRunAgentBuildFunction(ctx),
|
||||
createCollectServeSimMetricsBuildFunction(ctx),
|
||||
createInstallMaestroBuildFunction(),
|
||||
createInstallMitmproxyBuildFunction(),
|
||||
|
||||
createInstallPodsBuildFunction(),
|
||||
createSendSlackMessageFunction(),
|
||||
@@ -127,7 +142,9 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
calculateEASUpdateRuntimeVersionFunction(),
|
||||
|
||||
createSubmissionEntityFunction(),
|
||||
createSubmitToGooglePlayBuildFunction(),
|
||||
createUploadToAscBuildFunction(),
|
||||
createUpdateTestFlightMetadataBuildFunction(),
|
||||
|
||||
createReportMaestroTestResultsFunction(ctx),
|
||||
createMaestroTestsBuildFunction(ctx),
|
||||
@@ -137,6 +154,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
functions.push(
|
||||
...[
|
||||
createFindAndUploadBuildArtifactsBuildFunction(ctx),
|
||||
createUploadEmbeddedBundleBuildFunction(ctx),
|
||||
createResolveBuildConfigBuildFunction(ctx),
|
||||
createGetCredentialsForBuildTriggeredByGithubIntegration(ctx),
|
||||
]
|
||||
|
||||
@@ -219,4 +219,71 @@ describe(createEasBuildBuildFunctionGroup, () => {
|
||||
'Build function group is not supported in generic jobs.'
|
||||
);
|
||||
});
|
||||
|
||||
describe('embedded bundle upload', () => {
|
||||
function getStepNames(
|
||||
options: Parameters<typeof createMockBuildToolsContext>[0],
|
||||
{ sdkVersion, env = {} }: { sdkVersion?: string; env?: Record<string, string> }
|
||||
): string[] {
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(createMockBuildToolsContext(options));
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger: createMockLogger(),
|
||||
staticContextContent: { metadata: sdkVersion ? { sdkVersion } : null },
|
||||
});
|
||||
globalCtx.updateEnv(env);
|
||||
return functionGroup
|
||||
.createBuildStepsFromFunctionGroupCall(globalCtx)
|
||||
.map(step => step.displayName);
|
||||
}
|
||||
|
||||
it.each([
|
||||
['Android', { platform: Platform.ANDROID }],
|
||||
['Android with credentials', { platform: Platform.ANDROID, buildCredentials: { test: {} } }],
|
||||
['iOS with credentials', { platform: Platform.IOS, buildCredentials: { test: {} } }],
|
||||
])('uploads the embedded bundle after the build artifacts on SDK 58 (%s)', (_, options) => {
|
||||
const stepNames = getStepNames(options, { sdkVersion: '58.0.0' });
|
||||
|
||||
expect(stepNames.indexOf('Upload embedded bundle')).toBe(
|
||||
stepNames.indexOf('Find and upload build artifacts') + 1
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Android', { platform: Platform.ANDROID }],
|
||||
['Android with credentials', { platform: Platform.ANDROID, buildCredentials: { test: {} } }],
|
||||
['iOS with credentials', { platform: Platform.IOS, buildCredentials: { test: {} } }],
|
||||
])('passes ignore_error to the embedded bundle step (%s)', (_, options) => {
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(createMockBuildToolsContext(options));
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger: createMockLogger(),
|
||||
staticContextContent: { metadata: { sdkVersion: '58.0.0' } },
|
||||
});
|
||||
|
||||
const uploadStep = functionGroup
|
||||
.createBuildStepsFromFunctionGroupCall(globalCtx)
|
||||
.find(step => step.displayName === 'Upload embedded bundle');
|
||||
|
||||
expect(uploadStep).toBeDefined();
|
||||
expect(uploadStep!.inputs?.find(input => input.id === 'ignore_error')?.rawValue).toBe(true);
|
||||
});
|
||||
|
||||
it('does not upload the embedded bundle on SDK 57 without the opt-in', () => {
|
||||
expect(getStepNames({}, { sdkVersion: '57.0.0' })).not.toContain('Upload embedded bundle');
|
||||
});
|
||||
|
||||
it('uploads the embedded bundle on SDK 57 with the opt-in', () => {
|
||||
expect(
|
||||
getStepNames(
|
||||
{},
|
||||
{ sdkVersion: '57.0.0', env: { EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE: '1' } }
|
||||
)
|
||||
).toContain('Upload embedded bundle');
|
||||
});
|
||||
|
||||
it('does not upload the embedded bundle for iOS simulator builds', () => {
|
||||
expect(
|
||||
getStepNames({ platform: Platform.IOS, simulator: true }, { sdkVersion: '58.0.0' })
|
||||
).not.toContain('Upload embedded bundle');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import path from 'path';
|
||||
|
||||
import { shouldUseEagerBundle } from '../../common/eagerBundle';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { shouldUploadEmbeddedBundle } from '../../utils/expoUpdatesEmbedded';
|
||||
import { calculateEASUpdateRuntimeVersionFunction } from '../functions/calculateEASUpdateRuntimeVersion';
|
||||
import { createCheckoutBuildFunction } from '../functions/checkout';
|
||||
import { configureAndroidVersionFunction } from '../functions/configureAndroidVersion';
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
import { runFastlaneFunction } from '../functions/runFastlane';
|
||||
import { runGradleFunction } from '../functions/runGradle';
|
||||
import { createSaveBuildCacheFunction } from '../functions/saveBuildCache';
|
||||
import { createUploadEmbeddedBundleBuildFunction } from '../functions/uploadEmbeddedBundle';
|
||||
import { createSetUpNpmrcBuildFunction } from '../functions/useNpmToken';
|
||||
|
||||
interface HelperFunctionsInput {
|
||||
@@ -303,6 +305,19 @@ function createStepsForIosBuildWithCredentials({
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
...(shouldUploadEmbeddedBundle({
|
||||
env: globalCtx.env,
|
||||
metadata: globalCtx.staticContext.metadata,
|
||||
})
|
||||
? [
|
||||
createUploadEmbeddedBundleBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: { ignore_error: true },
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
@@ -390,6 +405,19 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
...(shouldUploadEmbeddedBundle({
|
||||
env: globalCtx.env,
|
||||
metadata: globalCtx.staticContext.metadata,
|
||||
})
|
||||
? [
|
||||
createUploadEmbeddedBundleBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: { ignore_error: true },
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
@@ -483,6 +511,19 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
...(shouldUploadEmbeddedBundle({
|
||||
env: globalCtx.env,
|
||||
metadata: globalCtx.staticContext.metadata,
|
||||
})
|
||||
? [
|
||||
createUploadEmbeddedBundleBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: { ignore_error: true },
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+47
@@ -0,0 +1,47 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { readAndroidArtifactInfoAsync } from '../../utils/android/appArtifact';
|
||||
import { createReadAndroidAppInfoBuildFunction } from '../readAndroidAppInfo';
|
||||
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
jest.unmock('@expo/spawn-async');
|
||||
|
||||
const fixturesDirectory = path.join(__dirname, 'fixtures/android-app-info');
|
||||
|
||||
it.each(['apk', 'aab'])('reads a real %s and publishes build step outputs', async type => {
|
||||
const step = createReadAndroidAppInfoBuildFunction().createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({
|
||||
projectTargetDirectory: fixturesDirectory,
|
||||
staticContextContent: { job: {} },
|
||||
}),
|
||||
{ callInputs: { artifact_path: `app.${type}` } }
|
||||
);
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(step.outputById.artifact_type.value).toBe(type);
|
||||
expect(step.outputById.package_name.value).toBe('com.example.app');
|
||||
});
|
||||
|
||||
it('reports an invalid ZIP as a user error', async () => {
|
||||
await expect(readAndroidArtifactInfoAsync(__filename)).rejects.toMatchObject({
|
||||
constructor: UserError,
|
||||
errorCode: 'EAS_READ_ANDROID_APP_INFO_INVALID_ARTIFACT',
|
||||
cause: expect.any(Error),
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['apk', 'aab'])('retains the native tool error for an invalid %s manifest', async type => {
|
||||
await expect(
|
||||
readAndroidArtifactInfoAsync(path.join(fixturesDirectory, `invalid-manifest-${type}.zip`))
|
||||
).rejects.toMatchObject({
|
||||
constructor: UserError,
|
||||
errorCode: 'EAS_READ_ANDROID_APP_INFO_INVALID_MANIFEST',
|
||||
cause: expect.any(Error),
|
||||
});
|
||||
});
|
||||
@@ -48,7 +48,10 @@ describe(createCheckoutBuildFunction, () => {
|
||||
});
|
||||
|
||||
it('moves project sources to the target directory when no inputs are given', async () => {
|
||||
const logger = createMockLogger();
|
||||
jest.mocked(logger.child).mockReturnValue(logger);
|
||||
const { globalCtx, executeAsync } = createCheckoutStep({
|
||||
logger,
|
||||
projectArchive: GIT_PROJECT_ARCHIVE,
|
||||
});
|
||||
|
||||
@@ -56,6 +59,9 @@ describe(createCheckoutBuildFunction, () => {
|
||||
|
||||
expect(await vol.promises.readFile(`${projectTargetDirectory}/app.json`, 'utf8')).toBe('{}');
|
||||
expect(globalCtx.wasCheckedOut()).toBe(true);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Changing default working directory to')
|
||||
);
|
||||
expect(fetchAndCheckoutRefAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -96,14 +102,11 @@ describe(createCheckoutBuildFunction, () => {
|
||||
});
|
||||
|
||||
it('rejects the ref input when the project is already checked out', async () => {
|
||||
const logger = createMockLogger();
|
||||
jest.mocked(logger.child).mockReturnValue(logger);
|
||||
const { globalCtx, executeAsync } = createCheckoutStep({
|
||||
logger,
|
||||
projectArchive: GIT_PROJECT_ARCHIVE,
|
||||
callInputs: { ref: 'feature/add-icon' },
|
||||
});
|
||||
globalCtx.markAsCheckedOut(logger);
|
||||
globalCtx.markAsCheckedOut();
|
||||
|
||||
await expect(executeAsync()).rejects.toMatchObject({
|
||||
errorCode: 'EAS_CHECKOUT_REF_AFTER_CHECKOUT',
|
||||
|
||||
+401
@@ -0,0 +1,401 @@
|
||||
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import type { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../../utils/agentDeviceEvents';
|
||||
import { startAppiumEventCollectionAsync } from '../../utils/appiumEvents';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import {
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
waitForFileAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceSession';
|
||||
import { createStartAppiumRemoteSessionBuildFunction } from '../startAppiumRemoteSession';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../../../utils/AndroidEmulatorUtils');
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/agentDeviceEvents');
|
||||
jest.mock('../../utils/appiumEvents');
|
||||
jest.mock('../../utils/agentDeviceArtifacts');
|
||||
// The agent-device session step boots the emulator itself; this test covers the session host.
|
||||
jest.mock('../startAndroidEmulator', () => ({
|
||||
startAndroidEmulatorAsync: jest.fn().mockResolvedValue({
|
||||
serialId: 'emulator-5554',
|
||||
emulatorPromise: Promise.resolve(),
|
||||
shouldAdjustAnimationScale: true,
|
||||
}),
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
spawnDetached: jest.fn(),
|
||||
startNgrokTunnelAsync: jest.fn(),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
waitForFileAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const finishHost = jest.fn();
|
||||
const closePreview = jest.fn();
|
||||
const stopTool = jest.fn();
|
||||
const stopTunnel = jest.fn();
|
||||
const stopEvents = jest.fn();
|
||||
const openPreview = jest.fn();
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// The daemon package is external. No installation or daemon process runs in these tests.
|
||||
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
// Only the emulator logcat folder: the Appium step writes into its own temp folder.
|
||||
const mkdtemp = fs.promises.mkdtemp.bind(fs.promises);
|
||||
jest
|
||||
.spyOn(fs.promises, 'mkdtemp')
|
||||
.mockImplementation(async prefix =>
|
||||
String(prefix).includes('eas-android-emulator-logcat-')
|
||||
? '/tmp/eas-android-emulator-logcat-test'
|
||||
: mkdtemp(prefix)
|
||||
);
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: '{}' } as never);
|
||||
jest
|
||||
.mocked(AndroidEmulatorUtils.getAttachedDevicesAsync)
|
||||
.mockResolvedValue([{ serialId: 'emulator-5554', state: 'device' } as never]);
|
||||
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as never);
|
||||
jest.mocked(waitForFileAsync).mockResolvedValue({ port: 4567, token: 'daemon-token' });
|
||||
for (const stop of [finishHost, closePreview, stopTool, stopTunnel, stopEvents]) {
|
||||
stop.mockResolvedValue(undefined);
|
||||
}
|
||||
openPreview.mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://preview.example.test',
|
||||
closeAsync: closePreview,
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: openPreview,
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: finishHost,
|
||||
});
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: undefined,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: stopTool,
|
||||
});
|
||||
jest.mocked(startNgrokTunnelAsync).mockResolvedValue({
|
||||
url: 'https://tool.example.test',
|
||||
subdomainId: 'tool-id',
|
||||
stopAsync: stopTunnel,
|
||||
});
|
||||
const events = { stopAsync: stopEvents, getLastEventObservedAt: () => undefined };
|
||||
jest.mocked(startAgentDeviceEventCollectionAsync).mockResolvedValue(events);
|
||||
jest.mocked(startAppiumEventCollectionAsync).mockResolvedValue(events);
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe.each([
|
||||
['Appium', createStartAppiumRemoteSessionBuildFunction],
|
||||
['Agent Device', createStartAgentDeviceSessionBuildFunction],
|
||||
] as const)('%s host ownership', (name, createFunction) => {
|
||||
async function runAsync(
|
||||
publicPreview?: boolean,
|
||||
frameAncestors?: string[],
|
||||
corsOrigins?: string[]
|
||||
) {
|
||||
const fn = createFunction({} as CustomBuildContext);
|
||||
await fn.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
// Inputs a step does not get default to undefined, like in a real step call.
|
||||
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',
|
||||
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'example.test',
|
||||
NGROK_AUTHTOKEN: 'token',
|
||||
},
|
||||
} as never
|
||||
);
|
||||
}
|
||||
|
||||
it('holds the host until session completion', async () => {
|
||||
await runAsync();
|
||||
expect(openPreview).toHaveBeenCalledWith({ baseDomain: 'example.test', authtoken: 'token' });
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: expect.objectContaining({
|
||||
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
previewApiUrl: 'https://preview.example.test',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
expect(closePreview).not.toHaveBeenCalled();
|
||||
expect(finishHost.mock.invocationCallOrder[0]).toBeGreaterThan(
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(stopTool).toHaveBeenCalledTimes(1);
|
||||
expect(stopEvents).toHaveBeenCalledTimes(1);
|
||||
expect(stopTunnel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('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;
|
||||
const pendingFinish = new Promise<void>(resolve => {
|
||||
release = resolve;
|
||||
});
|
||||
const toolStopped = new Promise<void>(resolve => {
|
||||
stopped = resolve;
|
||||
});
|
||||
finishHost.mockReturnValueOnce(pendingFinish);
|
||||
stopTool.mockImplementationOnce(async () => {
|
||||
stopped();
|
||||
});
|
||||
let completed = false;
|
||||
const running = runAsync().then(() => {
|
||||
completed = true;
|
||||
});
|
||||
try {
|
||||
await toolStopped;
|
||||
expect(stopTunnel).toHaveBeenCalledTimes(1);
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
expect(completed).toBe(false);
|
||||
} finally {
|
||||
release();
|
||||
await running;
|
||||
}
|
||||
});
|
||||
|
||||
it('finalizes recording and stops automation while the tunnel close is pending', async () => {
|
||||
let release!: () => void;
|
||||
let finished!: () => void;
|
||||
const pendingClose = new Promise<void>(resolve => {
|
||||
release = resolve;
|
||||
});
|
||||
const hostFinished = new Promise<void>(resolve => {
|
||||
finished = resolve;
|
||||
});
|
||||
const toolStopped = new Promise<void>(resolve => {
|
||||
stopTool.mockImplementationOnce(async () => resolve());
|
||||
});
|
||||
stopTunnel.mockReturnValueOnce(pendingClose);
|
||||
finishHost.mockImplementationOnce(async () => {
|
||||
finished();
|
||||
});
|
||||
const running = runAsync();
|
||||
try {
|
||||
// Both settle while the tunnel close is still held open.
|
||||
await Promise.all([hostFinished, toolStopped]);
|
||||
expect(stopTunnel).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
release();
|
||||
await running;
|
||||
}
|
||||
});
|
||||
|
||||
it('still stops the automation process when event collection stop rejects', async () => {
|
||||
stopEvents.mockRejectedValueOnce(new Error('collector failed'));
|
||||
// Agent Device wraps its collector stop in a catch-all helper; Appium does not.
|
||||
const outcome = await runAsync().then(
|
||||
() => 'resolved',
|
||||
(err: Error) => err.message
|
||||
);
|
||||
expect(outcome).toBe(name === 'Appium' ? 'collector failed' : 'resolved');
|
||||
expect(stopTool).toHaveBeenCalledTimes(1);
|
||||
expect(stopTunnel).toHaveBeenCalledTimes(1);
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('still stops automation when recording finalization rejects', async () => {
|
||||
finishHost.mockRejectedValueOnce(new Error('recording cleanup failed'));
|
||||
await expect(runAsync()).rejects.toThrow('recording cleanup failed');
|
||||
expect(stopTool).toHaveBeenCalledTimes(1);
|
||||
expect(stopTunnel).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['preview', 'config', 'wait'])('finishes the host after %s fails', async phase => {
|
||||
const error = new Error(`${phase} failed`);
|
||||
// A teardown failure must not replace the error that ended the session.
|
||||
finishHost.mockRejectedValueOnce(new Error('recording cleanup failed'));
|
||||
if (phase === 'preview') {
|
||||
openPreview.mockRejectedValueOnce(error);
|
||||
} else if (phase === 'config') {
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValueOnce(error);
|
||||
} else {
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValueOnce(error);
|
||||
}
|
||||
await expect(runAsync()).rejects.toBe(error);
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
// An agent-device preview failure aborts the daemon task, which then may not start its
|
||||
// daemon or tunnel. Everything that started must be stopped.
|
||||
const abortsDaemonTask = name === 'Agent Device' && phase === 'preview';
|
||||
expect(stopTool).toHaveBeenCalledTimes(
|
||||
abortsDaemonTask ? jest.mocked(spawnDetached).mock.calls.length : 1
|
||||
);
|
||||
expect(stopTunnel).toHaveBeenCalledTimes(
|
||||
abortsDaemonTask ? jest.mocked(startNgrokTunnelAsync).mock.calls.length : 1
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.objectContaining({ message: 'recording cleanup failed' }) },
|
||||
'Could not stop the session host during remote session teardown.'
|
||||
);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Could not stop the session host after the remote session failed',
|
||||
expect.objectContaining({ message: 'recording cleanup failed' }),
|
||||
{ level: 'warning' }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Appium ffmpeg setup', () => {
|
||||
async function runAppiumAsync(): Promise<void> {
|
||||
const fn = createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
await fn.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: new Proxy({} as Record<string, { value: unknown }>, {
|
||||
get: (target, id: string) => target[id] ?? { value: undefined },
|
||||
}),
|
||||
outputs: {},
|
||||
env: {
|
||||
DEVICE_RUN_SESSION_ID: 'session-id',
|
||||
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'example.test',
|
||||
NGROK_AUTHTOKEN: 'token',
|
||||
},
|
||||
} as never
|
||||
);
|
||||
}
|
||||
|
||||
function holdFfmpegInstall({ appiumError }: { appiumError?: Error } = {}) {
|
||||
let finish!: (outcome: 'installed' | 'failed') => void;
|
||||
const install = new Promise((resolve, reject) => {
|
||||
finish = outcome =>
|
||||
outcome === 'installed' ? resolve({}) : reject(new Error('apt-get failed'));
|
||||
});
|
||||
jest.mocked(spawn).mockImplementation(((command: string, args: string[]) => {
|
||||
if (command === 'ffmpeg') {
|
||||
return Promise.reject(new Error('ffmpeg missing'));
|
||||
}
|
||||
if (args.includes('install') && args.includes('ffmpeg')) {
|
||||
return install;
|
||||
}
|
||||
return command === 'npm' && appiumError
|
||||
? Promise.reject(appiumError)
|
||||
: Promise.resolve({ stdout: '{}' });
|
||||
}) as never);
|
||||
return finish;
|
||||
}
|
||||
|
||||
it.each(['installed', 'failed'] as const)(
|
||||
'starts Appium after the ffmpeg install settles (%s)',
|
||||
async outcome => {
|
||||
const finishFfmpegInstall = holdFfmpegInstall();
|
||||
|
||||
const run = runAppiumAsync();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
|
||||
finishFfmpegInstall(outcome);
|
||||
await run;
|
||||
expect(spawnDetached).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('waits for the ffmpeg install before failing on an Appium install error', async () => {
|
||||
const appiumError = new Error('npm failed');
|
||||
const finishFfmpegInstall = holdFfmpegInstall({ appiumError });
|
||||
|
||||
let settled = false;
|
||||
const run = runAppiumAsync().finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(settled).toBe(false);
|
||||
|
||||
finishFfmpegInstall('installed');
|
||||
await expect(run).rejects.toBe(appiumError);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,8 @@ import * as tar from 'tar';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { Datadog } from '../../../datadog';
|
||||
import * as files from '../../../utils/files';
|
||||
import { createDownloadBuildFunction, downloadBuildAsync } from '../downloadBuild';
|
||||
|
||||
// contains a 'TestApp.app/TestApp' file with 'i am executable' content
|
||||
@@ -29,11 +31,17 @@ const APPLICATION_ARCHIVE_URL = `https://expo.dev/artifacts/eas/${randomUUID()}.
|
||||
function createMockGraphqlClient({
|
||||
applicationArchiveUrl,
|
||||
error,
|
||||
transientError,
|
||||
}: {
|
||||
applicationArchiveUrl?: string | null;
|
||||
error?: Error;
|
||||
transientError?: Error;
|
||||
}): Client {
|
||||
const toPromise = jest.fn().mockResolvedValue(
|
||||
const toPromise = jest.fn();
|
||||
if (transientError) {
|
||||
toPromise.mockResolvedValueOnce({ error: transientError, data: undefined });
|
||||
}
|
||||
toPromise.mockResolvedValue(
|
||||
error
|
||||
? { error, data: undefined }
|
||||
: {
|
||||
@@ -61,7 +69,7 @@ function createSuccessfulResponse({
|
||||
url,
|
||||
headers,
|
||||
}: {
|
||||
body: Buffer;
|
||||
body: Buffer | Readable;
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}): Response {
|
||||
@@ -71,7 +79,7 @@ function createSuccessfulResponse({
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
body: Readable.from(body),
|
||||
body: body instanceof Readable ? body : Readable.from(body),
|
||||
url,
|
||||
headers: {
|
||||
get: (name: string): string | null => normalizedHeaders[name.toLowerCase()] ?? null,
|
||||
@@ -79,6 +87,47 @@ function createSuccessfulResponse({
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function createUndiciNetworkError(code: string): CombinedError {
|
||||
return new CombinedError({
|
||||
networkError: Object.assign(new TypeError('fetch failed'), {
|
||||
cause: Object.assign(new Error(`read ${code}`), { code }),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const { FetchError: ActualFetchError } = jest.requireActual(
|
||||
'node-fetch'
|
||||
) as typeof import('node-fetch');
|
||||
|
||||
function createSocketFetchError(url: string, code: string): Error {
|
||||
return new ActualFetchError(`request to ${url} failed, reason: connect ${code}`, 'system', {
|
||||
code,
|
||||
} as any);
|
||||
}
|
||||
|
||||
function useFakeBackoffTimers(): void {
|
||||
// Streams and fs callbacks need the real nextTick and setImmediate; only the backoff timers are faked.
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
|
||||
}
|
||||
|
||||
const MAX_IO_TURNS_BEFORE_BACKOFF = 1000;
|
||||
|
||||
async function waitForBackoffTimerAsync(): Promise<void> {
|
||||
for (let turns = 0; jest.getTimerCount() === 0; turns++) {
|
||||
if (turns === MAX_IO_TURNS_BEFORE_BACKOFF) {
|
||||
throw new Error('No backoff timer was scheduled.');
|
||||
}
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
async function fireBackoffTimersAsync(count: number): Promise<void> {
|
||||
for (let i = 0; i < count; i++) {
|
||||
await waitForBackoffTimerAsync();
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
}
|
||||
}
|
||||
|
||||
async function createFlatAppTarGzBufferAsync(): Promise<Buffer> {
|
||||
const sourceDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'flat-app-source-'));
|
||||
const archivePath = path.join(os.tmpdir(), `${randomUUID()}.tar.gz`);
|
||||
@@ -107,6 +156,11 @@ async function createFlatAppTarGzBufferAsync(): Promise<Buffer> {
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('downloadBuild', () => {
|
||||
it('downloads from applicationArchiveUrl returned by GraphQL', async () => {
|
||||
const buildId = randomUUID();
|
||||
@@ -255,6 +309,144 @@ describe('downloadBuild', () => {
|
||||
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
|
||||
});
|
||||
|
||||
it('passes the abort signal to the build lookup', async () => {
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValue(
|
||||
createSuccessfulResponse({ body: APP_TAR_GZ_BUFFER, url: APPLICATION_ARCHIVE_URL })
|
||||
);
|
||||
const buildId = randomUUID();
|
||||
|
||||
await downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(graphqlClient.query).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ buildId },
|
||||
{ fetch: expect.any(Function) }
|
||||
);
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
APPLICATION_ARCHIVE_URL,
|
||||
expect.objectContaining({ signal: expect.any(Object) })
|
||||
);
|
||||
});
|
||||
|
||||
it('stops a pending build lookup when aborted', async () => {
|
||||
// The lookup request runs through the abort-aware fetch from graphqlAbortContext and
|
||||
// hangs until its signal fires, like a stalled API call.
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = jest.fn(
|
||||
(_input: unknown, init?: RequestInit) =>
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
init!.signal!.addEventListener('abort', () => reject(init!.signal!.reason));
|
||||
})
|
||||
) as typeof globalThis.fetch;
|
||||
const graphqlClient = {
|
||||
query: jest.fn((_query, _variables, context: { fetch: typeof globalThis.fetch }) => ({
|
||||
toPromise: () =>
|
||||
context.fetch('https://api.expo.dev/graphql', {}).then(
|
||||
() => ({ data: undefined }),
|
||||
(networkError: Error) => ({ error: new CombinedError({ networkError }) })
|
||||
),
|
||||
})),
|
||||
} as unknown as Client;
|
||||
const controller = new AbortController();
|
||||
const failure = new Error('daemon failed');
|
||||
|
||||
try {
|
||||
const download = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId: randomUUID(),
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
signal: controller.signal,
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
controller.abort(failure);
|
||||
|
||||
await expect(download).rejects.toBe(failure);
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('lets a started extraction finish before it reports an abort', async () => {
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValue(
|
||||
createSuccessfulResponse({ body: APP_TAR_GZ_BUFFER, url: APPLICATION_ARCHIVE_URL })
|
||||
);
|
||||
const { decompressTarAsync } = jest.requireActual<typeof files>('../../../utils/files');
|
||||
const controller = new AbortController();
|
||||
const failure = new Error('daemon failed');
|
||||
let extractionDirectory: string | undefined;
|
||||
let extractionFinished = false;
|
||||
const decompressSpy = jest.spyOn(files, 'decompressTarAsync').mockImplementation(async args => {
|
||||
extractionDirectory = args.destinationDirectory;
|
||||
// The startup fails while the archive is being extracted.
|
||||
controller.abort(failure);
|
||||
await decompressTarAsync(args);
|
||||
extractionFinished = true;
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
graphqlClient: createMockGraphqlClient({}),
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
signal: controller.signal,
|
||||
})
|
||||
).rejects.toBe(failure);
|
||||
|
||||
// The download settled only after the extraction wrote its last file.
|
||||
expect(extractionFinished).toBe(true);
|
||||
expect(
|
||||
await fs.promises.readFile(
|
||||
path.join(extractionDirectory!, 'TestApp.app', 'TestApp'),
|
||||
'utf8'
|
||||
)
|
||||
).toBe('i am executable\n');
|
||||
} finally {
|
||||
decompressSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not look up the build when already aborted', async () => {
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
const failure = new Error('boot failed');
|
||||
|
||||
await expect(
|
||||
downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId: randomUUID(),
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
signal: AbortSignal.abort(failure),
|
||||
})
|
||||
).rejects.toBe(failure);
|
||||
expect(graphqlClient.query).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects missing or ambiguous build sources', async () => {
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
@@ -326,13 +518,13 @@ describe('downloadBuild', () => {
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws SystemError when GraphQL request fails with a network error', async () => {
|
||||
it('throws SystemError with the cause code after retrying a failing build lookup', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
error: new CombinedError({
|
||||
networkError: new Error('Network request failed'),
|
||||
}),
|
||||
error: createUndiciNetworkError('ECONNRESET'),
|
||||
});
|
||||
const datadogLog = jest.spyOn(Datadog, 'log');
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
@@ -341,19 +533,81 @@ describe('downloadBuild', () => {
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
});
|
||||
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await fireBackoffTimersAsync(5);
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await rejection;
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
message: `Could not fetch build ${buildId}: [Network] Network request failed`,
|
||||
message: `Could not fetch build ${buildId}: [Network] fetch failed (ECONNRESET)`,
|
||||
});
|
||||
expect(graphqlClient.query).toHaveBeenCalledTimes(6);
|
||||
expect(datadogLog).toHaveBeenCalledWith('download_build.build_lookup failed on attempt 6', {
|
||||
event: 'network_retry',
|
||||
operation: 'download_build.build_lookup',
|
||||
outcome: 'failed',
|
||||
attempts: '6',
|
||||
code: 'ECONNRESET',
|
||||
});
|
||||
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws SystemError when GraphQL request returns 5xx', async () => {
|
||||
it.each([
|
||||
['a network error', createUndiciNetworkError('ECONNRESET'), 'ECONNRESET'],
|
||||
[
|
||||
'a transient GraphQL error',
|
||||
new CombinedError({
|
||||
graphQLErrors: [
|
||||
{
|
||||
message: 'Too many requests',
|
||||
extensions: { isTransient: true, errorCode: 'TOO_MANY_REQUESTS' },
|
||||
},
|
||||
],
|
||||
response: { status: 200 },
|
||||
}),
|
||||
'TOO_MANY_REQUESTS',
|
||||
],
|
||||
])('downloads after a build lookup that failed once with %s', async (_, transientError, code) => {
|
||||
useFakeBackoffTimers();
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
error: Object.assign(new Error('Internal Server Error'), {
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
transientError,
|
||||
});
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValue(
|
||||
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
|
||||
);
|
||||
|
||||
const datadogLog = jest.spyOn(Datadog, 'log');
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
});
|
||||
await fireBackoffTimersAsync(1);
|
||||
|
||||
const { artifactPath } = await promise;
|
||||
expect(path.basename(artifactPath)).toBe('app.apk');
|
||||
expect(graphqlClient.query).toHaveBeenCalledTimes(2);
|
||||
expect(datadogLog).toHaveBeenCalledWith('download_build.build_lookup recovered on attempt 2', {
|
||||
event: 'network_retry',
|
||||
operation: 'download_build.build_lookup',
|
||||
outcome: 'recovered',
|
||||
attempts: '2',
|
||||
code,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws SystemError when GraphQL request returns 5xx', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
error: new CombinedError({
|
||||
graphQLErrors: [{ message: 'Internal Server Error' }],
|
||||
response: { status: 500 },
|
||||
}),
|
||||
});
|
||||
@@ -365,12 +619,14 @@ describe('downloadBuild', () => {
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
});
|
||||
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await fireBackoffTimersAsync(5);
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await rejection;
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
message: `Could not fetch build ${buildId}: Internal Server Error`,
|
||||
message: `Could not fetch build ${buildId}: [GraphQL] Internal Server Error`,
|
||||
});
|
||||
|
||||
expect(graphqlClient.query).toHaveBeenCalledTimes(6);
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -395,10 +651,193 @@ describe('downloadBuild', () => {
|
||||
errorCode: 'EAS_DOWNLOAD_BUILD_FETCH_FAILED',
|
||||
message: `Could not fetch build ${buildId}: [GraphQL] Build not found`,
|
||||
});
|
||||
|
||||
expect(graphqlClient.query).toHaveBeenCalledTimes(1);
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('downloads after an archive request that failed once with a socket error', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockRejectedValueOnce(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ECONNRESET'))
|
||||
.mockResolvedValueOnce(
|
||||
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
|
||||
);
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
});
|
||||
await fireBackoffTimersAsync(1);
|
||||
|
||||
const { artifactPath } = await promise;
|
||||
expect(path.basename(artifactPath)).toBe('app.apk');
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('throws SystemError with the cause code after retrying a failing build archive request', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockRejectedValue(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ETIMEDOUT'));
|
||||
const datadogLog = jest.spyOn(Datadog, 'log');
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId: randomUUID(),
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
});
|
||||
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await fireBackoffTimersAsync(5);
|
||||
|
||||
await rejection;
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
message: `Could not download the application archive: request to ${APPLICATION_ARCHIVE_URL} failed, reason: connect ETIMEDOUT`,
|
||||
});
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(6);
|
||||
expect(datadogLog).toHaveBeenCalledWith('download_build.archive_download failed on attempt 6', {
|
||||
event: 'network_retry',
|
||||
operation: 'download_build.archive_download',
|
||||
outcome: 'failed',
|
||||
attempts: '6',
|
||||
code: 'ETIMEDOUT',
|
||||
});
|
||||
});
|
||||
|
||||
function createBrokenBody(code: string): Readable {
|
||||
return Readable.from(
|
||||
(async function* () {
|
||||
yield Buffer.from('he');
|
||||
throw Object.assign(new Error(`body failed with ${code}`), { code });
|
||||
})()
|
||||
);
|
||||
}
|
||||
|
||||
it.each(['ERR_STREAM_PREMATURE_CLOSE', 'ECONNRESET'])(
|
||||
'downloads again when the archive body breaks off with %s after the headers arrived',
|
||||
async code => {
|
||||
useFakeBackoffTimers();
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
const brokenBody = createBrokenBody(code);
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValueOnce(
|
||||
createSuccessfulResponse({ body: brokenBody, url: 'https://cdn.test/app.apk' })
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
|
||||
);
|
||||
const datadogLog = jest.spyOn(Datadog, 'log');
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
});
|
||||
await fireBackoffTimersAsync(1);
|
||||
|
||||
const { artifactPath } = await promise;
|
||||
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(2);
|
||||
expect(datadogLog).toHaveBeenCalledWith(
|
||||
'download_build.archive_download recovered on attempt 2',
|
||||
expect.objectContaining({ outcome: 'recovered', code })
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('throws SystemError when the build archive body keeps breaking off', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
jest.mocked(fetch).mockImplementation(async () =>
|
||||
createSuccessfulResponse({
|
||||
body: createBrokenBody('ECONNRESET'),
|
||||
url: 'https://cdn.test/app.apk',
|
||||
})
|
||||
);
|
||||
const datadogLog = jest.spyOn(Datadog, 'log');
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId: randomUUID(),
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
});
|
||||
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await fireBackoffTimersAsync(5);
|
||||
|
||||
await rejection;
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
message: 'Could not download the application archive: body failed with ECONNRESET',
|
||||
});
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(6);
|
||||
expect(datadogLog).toHaveBeenCalledWith(
|
||||
'download_build.archive_download failed on attempt 6',
|
||||
expect.objectContaining({ outcome: 'failed', code: 'ECONNRESET' })
|
||||
);
|
||||
});
|
||||
|
||||
it('does not start another archive attempt after an abort during the backoff', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockRejectedValueOnce(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ECONNRESET'));
|
||||
const controller = new AbortController();
|
||||
const reason = new Error('step timed out');
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
signal: controller.signal,
|
||||
});
|
||||
const rejection = expect(promise).rejects.toBe(reason);
|
||||
await waitForBackoffTimerAsync();
|
||||
controller.abort(reason);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
|
||||
await rejection;
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rethrows the raw error of a failing user-supplied archive URL after the retries', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockRejectedValue(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ENOTFOUND'));
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
});
|
||||
const rejection = expect(promise).rejects.toBeInstanceOf(ActualFetchError);
|
||||
await fireBackoffTimersAsync(5);
|
||||
|
||||
await rejection;
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
it('should throw an error if no matching files are found', async () => {
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
@@ -424,6 +863,159 @@ describe('downloadBuild', () => {
|
||||
});
|
||||
|
||||
describe('createDownloadBuildFunction', () => {
|
||||
it('aborts a pending archive request when the step times out', async () => {
|
||||
let requestAborted = false;
|
||||
jest.mocked(fetch).mockImplementation(
|
||||
async (_url, options) =>
|
||||
await new Promise<Response>((_resolve, reject) => {
|
||||
options?.signal?.addEventListener('abort', () => {
|
||||
requestAborted = true;
|
||||
reject(new Error('Archive request aborted'));
|
||||
});
|
||||
})
|
||||
);
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
const step = createDownloadBuildFunction({
|
||||
graphqlClient,
|
||||
} as any).createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({ staticContextContent: { job: {} } }),
|
||||
{
|
||||
callInputs: { application_archive_url: APPLICATION_ARCHIVE_URL, extensions: ['ipa'] },
|
||||
timeoutMs: 100,
|
||||
}
|
||||
);
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow('timed out');
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(requestAborted).toBe(true);
|
||||
expect(graphqlClient.query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('with a device run session token in the job env', () => {
|
||||
const { Response: ActualResponse } = jest.requireActual(
|
||||
'node-fetch'
|
||||
) as typeof import('node-fetch');
|
||||
const workerApiUrl = 'https://api.expo.test/v2/device-run-session-worker/application';
|
||||
|
||||
function createSessionCtx(graphqlClient: Client): any {
|
||||
return {
|
||||
graphqlClient,
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'session-token' },
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
};
|
||||
}
|
||||
|
||||
function createApplicationResponse(buildId: string): Response {
|
||||
return new ActualResponse(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
build: {
|
||||
id: buildId,
|
||||
gitCommitHash: null,
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
function createStep(ctx: any, callInputs: Record<string, unknown>) {
|
||||
return createDownloadBuildFunction(ctx).createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({
|
||||
logger: createMockLogger(),
|
||||
staticContextContent: { job: { secrets: { robotAccessToken: 'session-token' } } },
|
||||
}),
|
||||
{ callInputs }
|
||||
);
|
||||
}
|
||||
|
||||
it('looks up build_id through the worker API and downloads without an Authorization header', async () => {
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
jest.mocked(fetch).mockImplementation(async url => {
|
||||
if (url === workerApiUrl) {
|
||||
return createApplicationResponse(buildId);
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
text: () => Promise.resolve('Internal Server Error'),
|
||||
status: 500,
|
||||
} as unknown as Response;
|
||||
});
|
||||
|
||||
await expect(
|
||||
createStep(createSessionCtx(graphqlClient), {
|
||||
build_id: buildId,
|
||||
extensions: ['app'],
|
||||
}).executeAsync()
|
||||
).rejects.toThrow('Internal Server Error');
|
||||
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
workerApiUrl,
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
headers: { 'expo-device-run-session-token': 'session-token' },
|
||||
})
|
||||
);
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
APPLICATION_ARCHIVE_URL,
|
||||
expect.objectContaining({ headers: undefined })
|
||||
);
|
||||
expect(graphqlClient.query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries the worker API lookup after a 5xx response', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValueOnce(new ActualResponse('Service Unavailable', { status: 503 }))
|
||||
.mockResolvedValueOnce(createApplicationResponse(buildId))
|
||||
.mockResolvedValueOnce(
|
||||
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
|
||||
);
|
||||
|
||||
const execution = createStep(createSessionCtx(graphqlClient), {
|
||||
build_id: buildId,
|
||||
extensions: ['apk'],
|
||||
}).executeAsync();
|
||||
await fireBackoffTimersAsync(1);
|
||||
|
||||
await execution;
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(3);
|
||||
expect(jest.mocked(fetch).mock.calls.map(([url]) => url)).toEqual([
|
||||
workerApiUrl,
|
||||
workerApiUrl,
|
||||
APPLICATION_ARCHIVE_URL,
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not call the worker API for an application_archive_url input', async () => {
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
text: () => Promise.resolve('Internal Server Error'),
|
||||
status: 500,
|
||||
} as unknown as Response);
|
||||
|
||||
await expect(
|
||||
createStep(createSessionCtx(graphqlClient), {
|
||||
application_archive_url: APPLICATION_ARCHIVE_URL,
|
||||
extensions: ['app'],
|
||||
}).executeAsync()
|
||||
).rejects.toThrow('Internal Server Error');
|
||||
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(1);
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
APPLICATION_ARCHIVE_URL,
|
||||
expect.objectContaining({ headers: undefined })
|
||||
);
|
||||
expect(graphqlClient.query).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should download a build', async () => {
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { Client, CombinedError } from '@urql/core';
|
||||
import fetch, { Response } from 'node-fetch';
|
||||
import fs from 'node:fs';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { createDownloadSubmissionArchiveFunction } from '../downloadSubmissionArchive';
|
||||
|
||||
const submissionId = 'aabbccdd-1234-4321-abcd-123456789012';
|
||||
const url = 'https://storage.example/app.ipa?signature=secret';
|
||||
|
||||
it('reports invalid submission ID and extensions together', async () => {
|
||||
const mutation = jest.fn();
|
||||
const step = createDownloadSubmissionArchiveFunction({
|
||||
graphqlClient: { mutation } as unknown as Client,
|
||||
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
|
||||
callInputs: { submission_id: 'invalid', extensions: [123] },
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toMatchObject({
|
||||
issues: expect.arrayContaining([
|
||||
expect.objectContaining({ path: ['submissionId'] }),
|
||||
expect.objectContaining({ path: ['extensions', 0] }),
|
||||
]),
|
||||
});
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requests a fresh URL on each execution, downloads without auth and does not log the URL', async () => {
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => ({
|
||||
data: {
|
||||
submission: { generateSubmissionArchiveDownloadUrl: url },
|
||||
},
|
||||
}),
|
||||
}));
|
||||
const logger = createMockLogger();
|
||||
const fn = createDownloadSubmissionArchiveFunction({
|
||||
graphqlClient: { mutation } as unknown as Client,
|
||||
} as CustomBuildContext);
|
||||
for (let i = 0; i < 2; i++) {
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
url,
|
||||
body: Readable.from(Buffer.from('IPA bytes')),
|
||||
headers: { get: () => null },
|
||||
} as unknown as Response);
|
||||
const step = fn.createBuildStepFromFunctionCall(createGlobalContextMock({ logger }), {
|
||||
callInputs: { submission_id: submissionId },
|
||||
});
|
||||
await step.executeAsync();
|
||||
expect(await fs.promises.readFile(step.getOutputValueByName('artifact_path')!, 'utf8')).toBe(
|
||||
'IPA bytes'
|
||||
);
|
||||
}
|
||||
expect(mutation).toHaveBeenCalledTimes(2);
|
||||
expect(fetch).toHaveBeenCalledWith(url, { headers: undefined, signal: undefined });
|
||||
expect(JSON.stringify(jest.mocked(logger.info).mock.calls)).not.toContain(url);
|
||||
});
|
||||
|
||||
it('preserves the API error as the cause without downloading', async () => {
|
||||
const error = new CombinedError({ networkError: new Error('Connection reset') });
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => ({
|
||||
error,
|
||||
}),
|
||||
}));
|
||||
const step = createDownloadSubmissionArchiveFunction({
|
||||
graphqlClient: { mutation } as unknown as Client,
|
||||
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
|
||||
callInputs: { submission_id: submissionId },
|
||||
});
|
||||
await expect(step.executeAsync()).rejects.toMatchObject({
|
||||
message: 'Could not request the submission archive. Try again later.',
|
||||
cause: error,
|
||||
});
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(mutation).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('retries a temporary URL request failure and then downloads the archive', async () => {
|
||||
const mutation = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce({
|
||||
toPromise: async () => ({
|
||||
error: new CombinedError({ networkError: new Error('Connection reset') }),
|
||||
}),
|
||||
})
|
||||
.mockReturnValue({
|
||||
toPromise: async () => ({
|
||||
data: { submission: { generateSubmissionArchiveDownloadUrl: url } },
|
||||
}),
|
||||
});
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
url,
|
||||
body: Readable.from(Buffer.from('IPA bytes')),
|
||||
headers: { get: () => null },
|
||||
} as unknown as Response);
|
||||
const step = createDownloadSubmissionArchiveFunction({
|
||||
graphqlClient: { mutation } as unknown as Client,
|
||||
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
|
||||
callInputs: { submission_id: submissionId },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mutation).toHaveBeenCalledTimes(2);
|
||||
expect(await fs.promises.readFile(step.getOutputValueByName('artifact_path')!, 'utf8')).toBe(
|
||||
'IPA bytes'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not retry an access error', async () => {
|
||||
const error = new CombinedError({ graphQLErrors: [{ message: 'Not authorized' }] });
|
||||
const mutation = jest.fn(() => ({ toPromise: async () => ({ error }) }));
|
||||
const step = createDownloadSubmissionArchiveFunction({
|
||||
graphqlClient: { mutation } as unknown as Client,
|
||||
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
|
||||
callInputs: { submission_id: submissionId },
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toMatchObject({ cause: error });
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not send another URL request after the step times out during retry backoff', async () => {
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => ({
|
||||
error: new CombinedError({ networkError: new Error('Connection reset') }),
|
||||
}),
|
||||
}));
|
||||
const step = createDownloadSubmissionArchiveFunction({
|
||||
graphqlClient: { mutation } as unknown as Client,
|
||||
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
|
||||
callInputs: { submission_id: submissionId },
|
||||
timeoutMs: 100,
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow('timed out');
|
||||
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the requested extension for an archive without a filename extension', async () => {
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => ({
|
||||
data: {
|
||||
submission: { generateSubmissionArchiveDownloadUrl: 'https://storage.example/archive' },
|
||||
},
|
||||
}),
|
||||
}));
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
url: 'https://storage.example/archive',
|
||||
body: Readable.from(Buffer.from('Android archive bytes')),
|
||||
headers: { get: () => null },
|
||||
} as unknown as Response);
|
||||
const step = createDownloadSubmissionArchiveFunction({
|
||||
graphqlClient: { mutation } as unknown as Client,
|
||||
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
|
||||
callInputs: { submission_id: submissionId, extensions: ['aab'] },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
const artifactPath = step.getOutputValueByName('artifact_path')!;
|
||||
expect(artifactPath).toMatch(/\.aab$/);
|
||||
expect(await fs.promises.readFile(artifactPath, 'utf8')).toBe('Android archive bytes');
|
||||
});
|
||||
|
||||
it('propagates the original download error', async () => {
|
||||
const error = new Error('Download failed');
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => ({
|
||||
data: { submission: { generateSubmissionArchiveDownloadUrl: url } },
|
||||
}),
|
||||
}));
|
||||
jest.mocked(fetch).mockRejectedValue(error);
|
||||
const step = createDownloadSubmissionArchiveFunction({
|
||||
graphqlClient: { mutation } as unknown as Client,
|
||||
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
|
||||
callInputs: { submission_id: submissionId },
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toBe(error);
|
||||
});
|
||||
@@ -463,114 +463,127 @@ describe('createInstallMaestroBuildFunction', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('installs the prebuilt WDA cache for each available iOS runtime', async () => {
|
||||
const homeDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'install-maestro-test-')
|
||||
);
|
||||
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
|
||||
if (command === 'maestro-runner') {
|
||||
return { stdout: 'maestro-runner 1.2.3\n' };
|
||||
}
|
||||
if (command === 'xcrun') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
runtimes: [
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-18-3',
|
||||
isAvailable: true,
|
||||
version: '18.3.1',
|
||||
},
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-0',
|
||||
isAvailable: true,
|
||||
version: '26.0',
|
||||
},
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-17-5',
|
||||
isAvailable: false,
|
||||
version: '17.5',
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (command === 'xcodebuild') {
|
||||
return { stdout: 'Xcode 26.0\nBuild version 17A324\n' };
|
||||
}
|
||||
if (command === 'tar') {
|
||||
const maestroRunnerHome = args[args.indexOf('-C') + 1];
|
||||
const productsDirectory = path.join(
|
||||
maestroRunnerHome,
|
||||
'cache',
|
||||
'wda-builds',
|
||||
'generic',
|
||||
'DerivedData',
|
||||
'Build',
|
||||
'Products'
|
||||
);
|
||||
await fs.promises.mkdir(productsDirectory, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(productsDirectory, 'WebDriverAgentRunner.xctestrun'),
|
||||
'cached'
|
||||
);
|
||||
}
|
||||
return { stdout: '' };
|
||||
}) as any);
|
||||
|
||||
try {
|
||||
await writeInstalledWdaVersion(homeDirectory, '11.1.3');
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
globalCtx.updateEnv({
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
EAS_BUILD_COCOAPODS_CACHE_URL: 'https://cache.example.com',
|
||||
HOME: homeDirectory,
|
||||
});
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner' },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedDownloadFile).toHaveBeenCalledWith(
|
||||
'https://cache.example.com/storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.0-wda-11.1.3.tar.gz',
|
||||
expect.stringMatching(/install_maestro_runner_wda_cache.*\/wda-cache\.tar\.gz$/),
|
||||
{ retry: 3, timeout: 20_000 }
|
||||
it.each(['1.1.27', '1.1.28', '1.2.3'])(
|
||||
'prepares the WDA cache only for legacy Runner versions (%s)',
|
||||
async version => {
|
||||
const homeDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'install-maestro-test-')
|
||||
);
|
||||
for (const runtimeVersion of ['18.3', '26.0']) {
|
||||
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
|
||||
if (command === 'maestro-runner') {
|
||||
return { stdout: `maestro-runner ${version}\n` };
|
||||
}
|
||||
if (command === 'xcrun') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
runtimes: [
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-18-3',
|
||||
isAvailable: true,
|
||||
version: '18.3.1',
|
||||
},
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-0',
|
||||
isAvailable: true,
|
||||
version: '26.0',
|
||||
},
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-17-5',
|
||||
isAvailable: false,
|
||||
version: '17.5',
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (command === 'xcodebuild') {
|
||||
return { stdout: 'Xcode 26.0\nBuild version 17A324\n' };
|
||||
}
|
||||
if (command === 'tar') {
|
||||
const maestroRunnerHome = args[args.indexOf('-C') + 1];
|
||||
const productsDirectory = path.join(
|
||||
maestroRunnerHome,
|
||||
'cache',
|
||||
'wda-builds',
|
||||
'generic',
|
||||
'DerivedData',
|
||||
'Build',
|
||||
'Products'
|
||||
);
|
||||
await fs.promises.mkdir(productsDirectory, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(productsDirectory, 'WebDriverAgentRunner.xctestrun'),
|
||||
'cached'
|
||||
);
|
||||
}
|
||||
return { stdout: '' };
|
||||
}) as any);
|
||||
|
||||
try {
|
||||
await writeInstalledWdaVersion(homeDirectory, '11.1.3');
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
globalCtx.updateEnv({
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
EAS_BUILD_COCOAPODS_CACHE_URL: 'https://cache.example.com',
|
||||
HOME: homeDirectory,
|
||||
});
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner' },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe(version);
|
||||
if (version !== '1.1.27') {
|
||||
expect(mockedDownloadFile).not.toHaveBeenCalled();
|
||||
expect(mockedSpawn.mock.calls.map(([command]) => command)).not.toContain('xcrun');
|
||||
await expect(
|
||||
fs.promises.access(path.join(homeDirectory, '.maestro-runner', 'cache'))
|
||||
).rejects.toThrow();
|
||||
return;
|
||||
}
|
||||
|
||||
expect(mockedDownloadFile).toHaveBeenCalledWith(
|
||||
'https://cache.example.com/storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.0-wda-11.1.3.tar.gz',
|
||||
expect.stringMatching(/install_maestro_runner_wda_cache.*\/wda-cache\.tar\.gz$/),
|
||||
{ retry: 3, timeout: 20_000 }
|
||||
);
|
||||
for (const runtimeVersion of ['18.3', '26.0']) {
|
||||
await expect(
|
||||
fs.promises.readFile(
|
||||
path.join(
|
||||
homeDirectory,
|
||||
'.maestro-runner',
|
||||
'cache',
|
||||
'wda-builds',
|
||||
`sim-ios${runtimeVersion}-iphone`,
|
||||
'DerivedData',
|
||||
'Build',
|
||||
'Products',
|
||||
'WebDriverAgentRunner.xctestrun'
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
).resolves.toBe('cached');
|
||||
}
|
||||
await expect(
|
||||
fs.promises.readFile(
|
||||
path.join(
|
||||
homeDirectory,
|
||||
'.maestro-runner',
|
||||
'cache',
|
||||
'wda-builds',
|
||||
`sim-ios${runtimeVersion}-iphone`,
|
||||
'DerivedData',
|
||||
'Build',
|
||||
'Products',
|
||||
'WebDriverAgentRunner.xctestrun'
|
||||
),
|
||||
'utf8'
|
||||
fs.promises.access(
|
||||
path.join(homeDirectory, '.maestro-runner', 'cache', 'wda-builds', 'sim-ios17.5-iphone')
|
||||
)
|
||||
).resolves.toBe('cached');
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
await fs.promises.rm(homeDirectory, { force: true, recursive: true });
|
||||
}
|
||||
await expect(
|
||||
fs.promises.access(
|
||||
path.join(homeDirectory, '.maestro-runner', 'cache', 'wda-builds', 'sim-ios17.5-iphone')
|
||||
)
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
await fs.promises.rm(homeDirectory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
it('does not download the WDA cache when the installed WDA version is unknown', async () => {
|
||||
mockedSpawn.mockImplementation((async (command: string) => ({
|
||||
stdout:
|
||||
command === 'xcodebuild' ? 'Xcode 26.5\nBuild version 17F90\n' : 'maestro-runner 1.2.3\n',
|
||||
command === 'xcodebuild' ? 'Xcode 26.5\nBuild version 17F90\n' : 'maestro-runner 1.1.27\n',
|
||||
})) as any);
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
@@ -593,7 +606,7 @@ describe('createInstallMaestroBuildFunction', () => {
|
||||
mockedSpawn.mockImplementation((async (command: string) => {
|
||||
switch (command) {
|
||||
case 'maestro-runner':
|
||||
return { stdout: 'maestro-runner 1.2.3\n' };
|
||||
return { stdout: 'maestro-runner 1.1.27\n' };
|
||||
case 'xcodebuild':
|
||||
return { stdout: 'Xcode 26.5\nBuild version 17F90\n' };
|
||||
case 'xcrun':
|
||||
@@ -633,7 +646,7 @@ describe('createInstallMaestroBuildFunction', () => {
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).resolves.toBeUndefined();
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('1.2.3');
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('1.1.27');
|
||||
expect(mockedDownloadFile.mock.calls.map(([url]) => url)).toEqual([
|
||||
'https://cache.example.com/storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.5-wda-11.1.3.tar.gz',
|
||||
'https://storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.5-wda-11.1.3.tar.gz',
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { BuildRuntimePlatform, BuildStep } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { fs, vol } from 'memfs';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createInstallMitmproxyBuildFunction } from '../installMitmproxy';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
|
||||
// An x86 Homebrew prefix, to check that the app is found from PATH and not from process.arch.
|
||||
const APP = '/usr/local/Caskroom/mitmproxy/12.2.3/mitmproxy.app';
|
||||
const LINK = '/usr/local/bin/mitmdump';
|
||||
|
||||
function installCask(): void {
|
||||
vol.fromJSON({ [`${APP}/Contents/MacOS/mitmdump`]: '', '/usr/local/bin': null });
|
||||
fs.symlinkSync(`${APP}/Contents/MacOS/mitmdump`, LINK);
|
||||
}
|
||||
|
||||
/**
|
||||
* `which` finds mitmdump once the cask is installed, `brew` installs it, `xattr -p` reports
|
||||
* quarantine while `stuck` is set, and each `mitmdump --version` takes the next result.
|
||||
*/
|
||||
function mockCommands({
|
||||
installed = false,
|
||||
stuck = false,
|
||||
runs = [],
|
||||
}: {
|
||||
installed?: boolean;
|
||||
stuck?: boolean;
|
||||
runs?: boolean[];
|
||||
}): void {
|
||||
if (installed) {
|
||||
installCask();
|
||||
}
|
||||
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
|
||||
if (command === 'which' && !installed) {
|
||||
throw new Error('not found');
|
||||
}
|
||||
if (command === 'which') {
|
||||
return { stdout: `${LINK}\n` };
|
||||
}
|
||||
if (command === 'brew' && !installed) {
|
||||
installed = true;
|
||||
installCask();
|
||||
}
|
||||
if (command === 'xattr' && args[0] === '-p' && !stuck) {
|
||||
throw new Error('No such xattr: com.apple.quarantine');
|
||||
}
|
||||
if (command === 'mitmdump' && !runs.shift()) {
|
||||
throw new Error('not found');
|
||||
}
|
||||
return { stdout: '' };
|
||||
}) as never);
|
||||
}
|
||||
|
||||
function spawnedCommands(): string[] {
|
||||
return mockedSpawn.mock.calls.map(([command, args]) =>
|
||||
command === 'xattr' ? `xattr ${args?.[0]}` : command
|
||||
);
|
||||
}
|
||||
|
||||
describe('createInstallMitmproxyBuildFunction', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function createStep(env: Record<string, string>): BuildStep {
|
||||
const globalCtx = createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.DARWIN });
|
||||
globalCtx.updateEnv(env);
|
||||
return createInstallMitmproxyBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: {},
|
||||
});
|
||||
}
|
||||
|
||||
it('takes the app behind mitmdump on PATH out of quarantine before running it', async () => {
|
||||
mockCommands({ installed: true, runs: [true] });
|
||||
|
||||
await createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'xattr',
|
||||
['-dr', 'com.apple.quarantine', APP],
|
||||
expect.anything()
|
||||
);
|
||||
expect(spawnedCommands()).toEqual(['which', 'xattr -dr', 'xattr -p', 'mitmdump']);
|
||||
});
|
||||
|
||||
it('kills a mitmdump --version that does not return', async () => {
|
||||
mockCommands({ installed: true, runs: [true] });
|
||||
|
||||
await createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'mitmdump',
|
||||
['--version'],
|
||||
expect.objectContaining({ stdio: 'ignore', timeout: 60_000, killSignal: 'SIGKILL' })
|
||||
);
|
||||
});
|
||||
|
||||
it('does not run a mitmdump that stays quarantined', async () => {
|
||||
mockCommands({ installed: true, stuck: true });
|
||||
|
||||
await expect(
|
||||
createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync()
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(spawnedCommands()).not.toContain('mitmdump');
|
||||
});
|
||||
|
||||
it('does not change quarantine outside EAS Build VMs', async () => {
|
||||
mockCommands({ installed: true, stuck: true, runs: [true] });
|
||||
|
||||
await createStep({}).executeAsync();
|
||||
|
||||
expect(spawnedCommands()).toEqual(['mitmdump']);
|
||||
});
|
||||
|
||||
it('installs the cask with Homebrew and takes it out of quarantine', async () => {
|
||||
mockCommands({ runs: [false, true] });
|
||||
|
||||
await createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'brew',
|
||||
['install', '--cask', 'mitmproxy'],
|
||||
expect.objectContaining({ env: expect.objectContaining({ HOMEBREW_NO_AUTO_UPDATE: '1' }) })
|
||||
);
|
||||
expect(spawnedCommands()).toEqual([
|
||||
'which',
|
||||
'mitmdump',
|
||||
'brew',
|
||||
'which',
|
||||
'xattr -dr',
|
||||
'xattr -p',
|
||||
'mitmdump',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not fail the job when mitmdump is still not runnable', async () => {
|
||||
mockCommands({ runs: [false, false] });
|
||||
|
||||
await expect(
|
||||
createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync()
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
@@ -179,17 +180,7 @@ describe(launchApplicationAsync, () => {
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'-a',
|
||||
'android.intent.action.VIEW',
|
||||
'-d',
|
||||
'exp://example.test',
|
||||
'-n',
|
||||
'host.exp.exponent/host.exp.exponent.MainActivity',
|
||||
],
|
||||
['shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', 'exp://example.test'],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(logger.info).toHaveBeenNthCalledWith(
|
||||
@@ -211,6 +202,263 @@ describe(launchApplicationAsync, () => {
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('quotes Android launch arguments and URLs for the device shell', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'dev.example.app',
|
||||
activityName: 'dev.example.app.MainActivity',
|
||||
launchArgs: ['-d', 'euxy://tempo', '--es', 'probe', 'hello world', '--es', 'quote', "it's"],
|
||||
openUrl: 'exp://example.test/path?a=1&b=2',
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'-d',
|
||||
'euxy://tempo',
|
||||
'--es',
|
||||
'probe',
|
||||
"'hello world'",
|
||||
'--es',
|
||||
'quote',
|
||||
"'it'\\''s'",
|
||||
'-n',
|
||||
'dev.example.app/dev.example.app.MainActivity',
|
||||
],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'-a',
|
||||
'android.intent.action.VIEW',
|
||||
'-d',
|
||||
"'exp://example.test/path?a=1&b=2'",
|
||||
],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(logger.info).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
`Launching dev.example.app with arguments ${JSON.stringify(['-d', 'euxy://tempo', '--es', 'probe', 'hello world', '--es', 'quote', "it's"])}.`
|
||||
);
|
||||
});
|
||||
|
||||
it('passes iOS launch arguments with spaces unquoted', async () => {
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
launchArgs: ['-name', 'hello world'],
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
['simctl', 'launch', 'booted', 'com.example.app', '-name', 'hello world'],
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('round-trips quoted Android arguments through a POSIX shell', async () => {
|
||||
const component = 'dev.example.app/dev.example.app.MainActivity$Inner';
|
||||
const launchArgs = [
|
||||
'--es',
|
||||
'text',
|
||||
'hello world',
|
||||
'--es',
|
||||
'json',
|
||||
'{"a":1,"b":2}',
|
||||
'--es',
|
||||
'shell',
|
||||
'$(printf injected) `printf injected` $HOME ; & | < >',
|
||||
'--es',
|
||||
'glob',
|
||||
'*',
|
||||
'--es',
|
||||
'home',
|
||||
'~',
|
||||
'--esa',
|
||||
'list',
|
||||
'a\\,b,c',
|
||||
'--es',
|
||||
'whitespace',
|
||||
'one\ntwo\tthree',
|
||||
'--es',
|
||||
'unicode',
|
||||
'héllo 🌍',
|
||||
'--es',
|
||||
'empty',
|
||||
'',
|
||||
];
|
||||
const openUrl = 'exp://example.test/path?a=1&b=2#fragment';
|
||||
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'dev.example.app',
|
||||
activityName: 'dev.example.app.MainActivity$Inner',
|
||||
launchArgs,
|
||||
openUrl,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
// adb joins the words after `shell` with spaces and the device shell parses the result.
|
||||
const parsedCommands = mockedSpawn.mock.calls.map(([, args]) => {
|
||||
const { stdout } = spawnSync(
|
||||
'/bin/sh',
|
||||
['-c', `am() { printf '%s\\0' "$@"; }; ${(args as string[]).slice(1).join(' ')}`],
|
||||
{ encoding: 'utf8' }
|
||||
);
|
||||
return stdout.split('\0').slice(0, -1);
|
||||
});
|
||||
expect(parsedCommands).toEqual([
|
||||
['start', ...launchArgs, '-n', component],
|
||||
['start', '-a', 'android.intent.action.VIEW', '-d', openUrl],
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([[['--es', 'key', '']], [['-e', 'key', '']], [['-d', '']]])(
|
||||
'passes empty Android option values as empty arguments (%j)',
|
||||
async launchArgs => {
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
launchArgs,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
...launchArgs.map(argument => (argument === '' ? "''" : argument)),
|
||||
'-n',
|
||||
'com.example.app/com.example.app.MainActivity',
|
||||
],
|
||||
expect.any(Object)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('explains an Android launch failure when a launch argument is empty', async () => {
|
||||
const launchError = new Error('adb shell am start exited with non-zero code: 1');
|
||||
mockedSpawn.mockRejectedValueOnce(launchError);
|
||||
|
||||
await expect(
|
||||
launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
launchArgs: ['--ez', 'isTest', 'true', ''],
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
errorCode: 'EAS_LAUNCH_APPLICATION_FAILED',
|
||||
message: expect.stringContaining('empty argument at position 4'),
|
||||
cause: launchError,
|
||||
});
|
||||
});
|
||||
|
||||
it('rethrows an Android launch failure unchanged when no launch argument is empty', async () => {
|
||||
const launchError = new Error('adb shell am start exited with non-zero code: 1');
|
||||
mockedSpawn.mockRejectedValueOnce(launchError);
|
||||
|
||||
await expect(
|
||||
launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
launchArgs: ['--ez', 'isTest', 'true'],
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
})
|
||||
).rejects.toBe(launchError);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ launchArgs: ['--es', 'key', 'a\0b'] },
|
||||
{ launchArgs: [], openUrl: 'exp://example.test/a\0b' },
|
||||
])('rejects NUL characters on Android (%j)', async ({ launchArgs, openUrl }) => {
|
||||
await expect(
|
||||
launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
launchArgs,
|
||||
openUrl,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
})
|
||||
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
|
||||
expect(mockedSpawn).not.toHaveBeenCalledWith(
|
||||
'adb',
|
||||
expect.arrayContaining([expect.stringContaining('\0')]),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts an Android launch command of exactly 65,000 bytes', async () => {
|
||||
const fixedLength = 'am start --es key -n com.example.app/com.example.app.MainActivity'.length;
|
||||
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
launchArgs: ['--es', 'key', 'a'.repeat(65_000 - fixedLength)],
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects an Android URL command of 65,001 bytes', async () => {
|
||||
const fixedLength = 'am start -a android.intent.action.VIEW -d exp://example.test/'.length;
|
||||
|
||||
await expect(
|
||||
launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
openUrl: `exp://example.test/${'a'.repeat(65_001 - fixedLength)}`,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
})
|
||||
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects an Android launch command that is too long after quoting', async () => {
|
||||
await expect(
|
||||
launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
launchArgs: ['--es', 'quotes', "'".repeat(20_000)],
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
})
|
||||
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('launches using application metadata passed to the build step', async () => {
|
||||
const launchApplication = createLaunchApplicationFunction();
|
||||
const buildStep = launchApplication.createBuildStepFromFunctionCall(
|
||||
@@ -240,16 +488,36 @@ describe(launchApplicationAsync, () => {
|
||||
],
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'adb',
|
||||
['shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', 'exp://example.test'],
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('quotes Android launch arguments passed to the build step', async () => {
|
||||
const launchApplication = createLaunchApplicationFunction();
|
||||
const buildStep = launchApplication.createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX }),
|
||||
{
|
||||
callInputs: {
|
||||
application_identifier: 'com.example.app',
|
||||
activity_name: 'com.example.app.MainActivity',
|
||||
launch_args: ['--es', 'probe', 'hello world'],
|
||||
},
|
||||
}
|
||||
);
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'-a',
|
||||
'android.intent.action.VIEW',
|
||||
'-d',
|
||||
'exp://example.test',
|
||||
'--es',
|
||||
'probe',
|
||||
"'hello world'",
|
||||
'-n',
|
||||
'com.example.app/com.example.app.MainActivity',
|
||||
],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GenericArtifactType, SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
@@ -271,20 +271,123 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
expect(mockedSpawn.mock.calls[0][1]).toContain('--parallel=2');
|
||||
});
|
||||
|
||||
it('rejects a non-junit output_format with maestro-runner', async () => {
|
||||
it.each(['html', 'allure'])(
|
||||
'accepts %s output_format with maestro-runner and keeps JUnit',
|
||||
async format => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const copyFileSpy = jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
backend: 'maestro-runner',
|
||||
output_format: format,
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn.mock.calls[0][1]).toEqual(
|
||||
expect.arrayContaining([
|
||||
'--output=/home/expo/.maestro/tests/android-maestro-runner-attempt-0',
|
||||
])
|
||||
);
|
||||
expect(copyFileSpy).toHaveBeenCalledWith(
|
||||
'/home/expo/.maestro/tests/android-maestro-runner-attempt-0/junit-report.xml',
|
||||
'/home/expo/.maestro/tests/junit-reports/android-maestro-junit-attempt-0.xml'
|
||||
);
|
||||
expect(parser.mergeJUnitReports).not.toHaveBeenCalled();
|
||||
expect(step.getOutputValueByName('final_report_path')).toBe(
|
||||
format === 'html'
|
||||
? '/home/expo/.maestro/tests/android-maestro-runner-attempt-0/report.html'
|
||||
: '/home/expo/.maestro/tests/android-maestro-runner-attempt-0/allure-results'
|
||||
);
|
||||
expect(mockedRunnerHarvest).not.toHaveBeenCalled();
|
||||
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
|
||||
expect(mockUploadArtifact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
artifact: expect.objectContaining({
|
||||
name:
|
||||
format === 'html' ? 'Maestro Runner HTML Report' : 'Maestro Runner Allure Results',
|
||||
paths: [
|
||||
format === 'html'
|
||||
? '/home/expo/.maestro/tests/android-maestro-runner-attempt-0'
|
||||
: '/home/expo/.maestro/tests/android-maestro-runner-attempt-0/allure-results',
|
||||
],
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('does not expose a selected runner report before an attempt starts', async () => {
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
flow_path: [],
|
||||
platform: 'android',
|
||||
backend: 'maestro-runner',
|
||||
output_format: 'html',
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
expect(step.getOutputValueByName('final_report_path')).toBeUndefined();
|
||||
expect(step.getOutputValueByName('junit_report_directory')).toBe(
|
||||
'/home/expo/.maestro/tests/junit-reports'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an unknown output_format with maestro-runner', async () => {
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
backend: 'maestro-runner',
|
||||
output_format: 'csv',
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(
|
||||
'maestro-runner only supports the "junit" output_format'
|
||||
'maestro-runner supports "junit", "html", and "allure" output_format values'
|
||||
);
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uploads the last runner HTML report even when the test fails', async () => {
|
||||
mockedSpawn.mockRejectedValue(rejectExit1());
|
||||
jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
backend: 'maestro-runner',
|
||||
output_format: 'html',
|
||||
retries: 1,
|
||||
retry_failed_only: false,
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
|
||||
expect(mockUploadArtifact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
artifact: expect.objectContaining({
|
||||
name: 'Maestro Runner HTML Report',
|
||||
paths: ['/home/expo/.maestro/tests/android-maestro-runner-attempt-1'],
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(step.getOutputValueByName('final_report_path')).toBe(
|
||||
'/home/expo/.maestro/tests/android-maestro-runner-attempt-1/report.html'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not fail a passing test when the runner report upload fails', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
mockUploadArtifact.mockRejectedValue(new Error('upload failed'));
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
backend: 'maestro-runner',
|
||||
output_format: 'allure',
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('logs that maestro-runner does not support direct DADB', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
@@ -966,7 +1069,7 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('uses $HOME/.maestro/tests output path for non-junit formats (e.g. html)', async () => {
|
||||
it('uses $HOME/.maestro/tests output path and uploads the Maestro CLI HTML report', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({
|
||||
@@ -977,11 +1080,118 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
await step.executeAsync();
|
||||
|
||||
const args = mockedSpawn.mock.calls[0][1] as string[];
|
||||
// Non-JUnit uses a fixed path inside $HOME/.maestro/tests so the
|
||||
// whole-directory upload picks it up.
|
||||
// Non-JUnit uses a fixed path inside $HOME/.maestro/tests.
|
||||
const outputArg = args.find(a => a.startsWith('--output='));
|
||||
expect(outputArg).toMatch(/\.maestro\/tests\/android-maestro-html\.html$/);
|
||||
expect(outputArg).not.toMatch(/junit-reports/);
|
||||
expect(step.getOutputValueByName('final_report_path')).toBe(
|
||||
'/home/expo/.maestro/tests/android-maestro-html.html'
|
||||
);
|
||||
expect(mockUploadArtifact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
artifact: expect.objectContaining({
|
||||
name: 'Maestro HTML Report',
|
||||
paths: ['/home/expo/.maestro/tests/android-maestro-html.html'],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads the Maestro CLI HTML report even when tests fail', async () => {
|
||||
mockedSpawn.mockRejectedValue(rejectExit1());
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'html',
|
||||
platform: 'android',
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
|
||||
expect(mockUploadArtifact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
artifact: expect.objectContaining({
|
||||
name: 'Maestro HTML Report',
|
||||
paths: ['/home/expo/.maestro/tests/android-maestro-html.html'],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('warns that Maestro CLI does not support Allure reports', async () => {
|
||||
mockedSpawn.mockRejectedValue(rejectExit1());
|
||||
const logger = createMockLogger();
|
||||
jest.mocked(logger.child).mockReturnValue(logger);
|
||||
const step = createStep(
|
||||
{
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'allure',
|
||||
platform: 'android',
|
||||
},
|
||||
{ logger }
|
||||
);
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Maestro CLI does not support Allure reports; no Allure artifact was uploaded.'
|
||||
);
|
||||
expect(mockUploadArtifact).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears the Maestro CLI HTML report before every retry attempt', async () => {
|
||||
const removeSpy = jest.spyOn(fs, 'rm').mockResolvedValue();
|
||||
mockedSpawn.mockRejectedValue(rejectExit1());
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'html',
|
||||
platform: 'android',
|
||||
retries: 1,
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
|
||||
expect(
|
||||
removeSpy.mock.calls.filter(
|
||||
([target]) => target === '/home/expo/.maestro/tests/android-maestro-html.html'
|
||||
)
|
||||
).toHaveLength(2);
|
||||
expect(removeSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/home/expo/.maestro/tests/android-maestro-html.html',
|
||||
{
|
||||
force: true,
|
||||
}
|
||||
);
|
||||
expect(removeSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/home/expo/.maestro/tests/android-maestro-html.html',
|
||||
{
|
||||
force: true,
|
||||
}
|
||||
);
|
||||
expect(removeSpy.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockedSpawn.mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(removeSpy.mock.invocationCallOrder[1]).toBeLessThan(
|
||||
mockedSpawn.mock.invocationCallOrder[1]
|
||||
);
|
||||
});
|
||||
|
||||
it('does not run Maestro CLI or upload old HTML if the report cannot be cleared', async () => {
|
||||
jest.spyOn(fs, 'rm').mockImplementation(async target => {
|
||||
if (target === '/home/expo/.maestro/tests/android-maestro-html.html') {
|
||||
throw new Error('permission denied');
|
||||
}
|
||||
});
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'html',
|
||||
platform: 'android',
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(SystemError);
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
expect(mockUploadArtifact).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses lowercase extension for non-junit formats regardless of input casing', async () => {
|
||||
@@ -1208,7 +1418,6 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
expect(mockUploadArtifact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
artifact: expect.objectContaining({
|
||||
type: GenericArtifactType.OTHER,
|
||||
name: shot.displayName,
|
||||
metadata: shot.metadata,
|
||||
}),
|
||||
@@ -1216,28 +1425,31 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('discovers maestro-runner flow results from the attempt report directory', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const shot = makeShot(0);
|
||||
jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
mockedRunnerHarvest.mockResolvedValue([shot]);
|
||||
const parseFlowResultsSpy = jest.spyOn(parser, 'parseMaestroRunnerReport').mockResolvedValue({
|
||||
flows: [{ name: 'Login', sourceFile: 'a.yaml', status: 'failed' }],
|
||||
});
|
||||
it.each(['junit', undefined])(
|
||||
'uploads runner screenshots with output_format=%s',
|
||||
async format => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const shot = makeShot(0);
|
||||
jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
mockedRunnerHarvest.mockResolvedValue([shot]);
|
||||
const parseFlowResultsSpy = jest.spyOn(parser, 'parseMaestroRunnerReport').mockResolvedValue({
|
||||
flows: [{ name: 'Login', sourceFile: 'a.yaml', status: 'failed' }],
|
||||
});
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['a.yaml'],
|
||||
platform: 'ios',
|
||||
output_format: 'junit',
|
||||
backend: 'maestro-runner',
|
||||
});
|
||||
await step.executeAsync();
|
||||
const step = createStep({
|
||||
flow_path: ['a.yaml'],
|
||||
platform: 'ios',
|
||||
...(format === undefined ? {} : { output_format: format }),
|
||||
backend: 'maestro-runner',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(parseFlowResultsSpy).toHaveBeenCalledWith(
|
||||
'/home/expo/.maestro/tests/ios-maestro-runner-attempt-0'
|
||||
);
|
||||
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(parseFlowResultsSpy).toHaveBeenCalledWith(
|
||||
'/home/expo/.maestro/tests/ios-maestro-runner-attempt-0'
|
||||
);
|
||||
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('uploads screenshots even when all attempts fail, before throwing ERR_MAESTRO_TESTS_FAILED', async () => {
|
||||
mockedSpawn.mockRejectedValue(rejectExit1());
|
||||
@@ -1301,14 +1513,15 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
expect(mockUploadArtifact).toHaveBeenCalledTimes(30);
|
||||
});
|
||||
|
||||
it('does not harvest or upload screenshots when output_format is not junit', async () => {
|
||||
it('does not harvest or upload screenshots for Maestro CLI HTML', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'html' });
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedHarvest).not.toHaveBeenCalled();
|
||||
expect(mockUploadArtifact).not.toHaveBeenCalled();
|
||||
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
|
||||
expect(mockUploadArtifact.mock.calls[0][0].artifact.name).toBe('Maestro HTML Report');
|
||||
});
|
||||
|
||||
it('uploads only the final attempt for a pure-failure flow, but every attempt for a flaky flow', async () => {
|
||||
|
||||
+57
-15
@@ -7,6 +7,7 @@ import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { isProcessDescendantOfAsync } from '../../../utils/processes';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import {
|
||||
monitorLocalEgressAsync,
|
||||
readLocalEgressHandoffAsync,
|
||||
@@ -14,11 +15,16 @@ import {
|
||||
stopLocalEgressResourcesAsync,
|
||||
} from '../../utils/localEgress';
|
||||
import {
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
reportLocalEgressGuardCoverageAsync,
|
||||
resolveLocalEgressServeSimBootEnvironmentAsync,
|
||||
startLocalEgressGuardRelayAsync,
|
||||
verifyLocalEgressGuardAsync,
|
||||
} from '../../utils/localEgressGuard';
|
||||
import {
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartAgentDeviceRemoteSessionBuildFunction } from '../startAgentDeviceRemoteSession';
|
||||
import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceSession';
|
||||
import { createStartAppiumRemoteSessionBuildFunction } from '../startAppiumRemoteSession';
|
||||
import { createStartArgentRemoteSessionBuildFunction } from '../startArgentRemoteSession';
|
||||
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
|
||||
@@ -27,7 +33,14 @@ jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../../utils/IosSimulatorUtils');
|
||||
jest.mock('../../../utils/processes');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/agentDeviceArtifacts');
|
||||
jest.mock('../startIosSimulator', () => ({
|
||||
...jest.requireActual('../startIosSimulator'),
|
||||
bootIosSimulatorAsync: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ deviceIdentifier: 'sim', udid: 'sim', displayName: 'iPhone' }),
|
||||
}));
|
||||
jest.mock('../../utils/argentArtifacts');
|
||||
jest.mock('../../utils/agentDeviceEvents', () => ({
|
||||
startAgentDeviceEventCollectionAsync: async () => ({ stopAsync: jest.fn() }),
|
||||
@@ -44,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',
|
||||
@@ -58,12 +78,15 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
subdomainId: 'controller-id',
|
||||
stopAsync: jest.fn(),
|
||||
}),
|
||||
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
child: jest.fn().mockReturnThis(),
|
||||
} as unknown as bunyan;
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const env = { DEVICE_RUN_SESSION_ID: 'session-id' };
|
||||
const handoff = { url: 'https://egress.test', token: 'secret', fingerprint: 'key=', port: 8899 };
|
||||
@@ -74,7 +97,7 @@ const fields = {
|
||||
egressPort: handoff.port,
|
||||
};
|
||||
const controllers = [
|
||||
['agent-device', createStartAgentDeviceRemoteSessionBuildFunction, 'agentDeviceRemoteSessionUrl'],
|
||||
['agent-device', createStartAgentDeviceSessionBuildFunction, 'agentDeviceRemoteSessionUrl'],
|
||||
['Appium', createStartAppiumRemoteSessionBuildFunction, 'appiumUrl'],
|
||||
['Argent', createStartArgentRemoteSessionBuildFunction, 'toolsUrl'],
|
||||
['web preview', createStartWebPreviewRemoteSessionBuildFunction, 'previewUrl'],
|
||||
@@ -82,16 +105,17 @@ const controllers = [
|
||||
|
||||
describe.each(controllers)('%s local egress', (_name, createFunction, controllerField) => {
|
||||
const stopPreview = jest.fn();
|
||||
const openPreview = jest.fn();
|
||||
const stopEgress = jest.fn();
|
||||
let lifetime: AbortSignal;
|
||||
async function run(signal?: AbortSignal): Promise<void> {
|
||||
await createFunction(ctx).fn!(
|
||||
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
// 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 },
|
||||
}),
|
||||
outputs: {},
|
||||
env,
|
||||
signal,
|
||||
@@ -115,15 +139,31 @@ 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);
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
openPreview.mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://preview.test',
|
||||
previewToken: 'preview-secret',
|
||||
stopAsync: stopPreview,
|
||||
closeAsync: jest.fn(),
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: openPreview,
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: stopPreview,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => {
|
||||
@@ -169,12 +209,14 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
|
||||
expect(stopEgress).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(['startup', 'report', 'wait', 'teardown'])(
|
||||
it.each(['startup', 'preview', 'report', 'wait', 'teardown'])(
|
||||
'releases egress after %s fails',
|
||||
async phase => {
|
||||
const error = new Error(`${phase} failed`);
|
||||
if (phase === 'startup') {
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockRejectedValue(error);
|
||||
jest.mocked(startDeviceSessionHostAsync).mockRejectedValue(error);
|
||||
} else if (phase === 'preview') {
|
||||
openPreview.mockRejectedValue(error);
|
||||
} else if (phase === 'report') {
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValue(error);
|
||||
} else if (phase === 'wait') {
|
||||
@@ -186,7 +228,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
expect(lifetime.aborted).toBe(true);
|
||||
expect(stopEgress).toHaveBeenCalledTimes(1);
|
||||
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(
|
||||
phase === 'startup' || phase === 'report' ? 0 : 1
|
||||
phase === 'startup' || phase === 'preview' || phase === 'report' ? 0 : 1
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import turtleSpawn from '@expo/turtle-spawn';
|
||||
import { vol } from 'memfs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { EventEmitter, once } from 'node:events';
|
||||
import path from 'node:path';
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import * as agentRun from '../../utils/agentRun';
|
||||
import { leaseAgentRunProviderCredentialsAsync } from '../../utils/agentRunLease';
|
||||
import { createRunAgentBuildFunction } from '../runAgent';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.mock('node:child_process', () => ({
|
||||
...jest.requireActual('node:child_process'),
|
||||
spawn: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/agentRunLease');
|
||||
|
||||
const installMock = jest.mocked(turtleSpawn);
|
||||
const spawnMock = jest.mocked(spawn);
|
||||
const leaseMock = jest.mocked(leaseAgentRunProviderCredentialsAsync);
|
||||
|
||||
const ctx = {
|
||||
expoApiV2BaseUrl: 'https://staging-api.expo.test/v2/',
|
||||
mcpServerUrl: 'wss://staging-mcp.expo.test',
|
||||
runtimeApi: { addBuildLogMask: jest.fn() },
|
||||
} as any;
|
||||
const inputs = {
|
||||
agent_kind: { value: 'claude-code' },
|
||||
agent_run_id: { value: '0199b0a0-1111-7222-8333-444455556666' },
|
||||
max_duration_seconds: { value: 1800 },
|
||||
prompt: { value: 'Fix the failing test.' },
|
||||
};
|
||||
const env = {
|
||||
PATH: '/usr/local/bin:/usr/bin',
|
||||
HOME: '/home/expo',
|
||||
EXPO_TOKEN: 'expo-token',
|
||||
__API_SERVER_URL: 'https://staging-api.expo.test',
|
||||
EAS_BUILD_ID: 'build-id',
|
||||
NODE_OPTIONS: '--max-old-space-size=3072',
|
||||
USER_DEFINED_SECRET: 'secret',
|
||||
};
|
||||
|
||||
async function runAsync(
|
||||
overrides: {
|
||||
ctx?: any;
|
||||
inputs?: Record<string, { value: unknown }>;
|
||||
env?: Record<string, string>;
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
const fn = createRunAgentBuildFunction(overrides.ctx ?? ctx);
|
||||
await fn.fn!({ logger: createMockLogger() } as any, {
|
||||
inputs: overrides.inputs ?? inputs,
|
||||
outputs: {},
|
||||
env: overrides.env ?? env,
|
||||
});
|
||||
}
|
||||
|
||||
function mockSuccessfulRun(): { mcpConfig?: any } {
|
||||
const captured: { mcpConfig?: any } = {};
|
||||
leaseMock.mockResolvedValue({ provider: 'anthropic', accessToken: 'anthropic-access-token' });
|
||||
installMock.mockResolvedValue({} as any);
|
||||
spawnMock.mockImplementation(((_command: string, _args: string[], spawnOptions: any) => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
stdin: { on: jest.fn(), end: jest.fn() },
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
});
|
||||
captured.mcpConfig = JSON.parse(
|
||||
vol.readFileSync(path.join(spawnOptions.env.HOME, 'mcp.json'), 'utf8') as string
|
||||
);
|
||||
process.nextTick(() => {
|
||||
child.emit('exit', 0, null);
|
||||
child.stdout.end();
|
||||
child.stderr.end();
|
||||
void Promise.all([once(child.stdout, 'end'), once(child.stderr, 'end')]).then(() =>
|
||||
child.emit('close')
|
||||
);
|
||||
});
|
||||
return child;
|
||||
}) as any);
|
||||
return captured;
|
||||
}
|
||||
|
||||
describe(createRunAgentBuildFunction, () => {
|
||||
it('is the eas/run_agent function', () => {
|
||||
expect(createRunAgentBuildFunction(ctx).getFullId()).toBe('eas/run_agent');
|
||||
});
|
||||
|
||||
it('passes none of the job environment to npm or the agent', async () => {
|
||||
mockSuccessfulRun();
|
||||
|
||||
await runAsync();
|
||||
|
||||
expect(leaseMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
expoApiV2BaseUrl: 'https://staging-api.expo.test/v2/',
|
||||
expoToken: 'expo-token',
|
||||
agentRunId: '0199b0a0-1111-7222-8333-444455556666',
|
||||
})
|
||||
);
|
||||
const npmEnv = installMock.mock.calls[0][2]!.env!;
|
||||
const agentEnv = spawnMock.mock.calls[0][2].env!;
|
||||
expect(Object.keys(npmEnv).sort()).toEqual(['HOME', 'LANG', 'PATH', 'TMPDIR']);
|
||||
expect(Object.keys(agentEnv).sort()).toEqual([
|
||||
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
|
||||
'CLAUDE_CODE_OAUTH_TOKEN',
|
||||
'HOME',
|
||||
'LANG',
|
||||
'PATH',
|
||||
'TMPDIR',
|
||||
]);
|
||||
expect(agentEnv.PATH).toBe('/usr/local/bin:/usr/bin');
|
||||
expect(agentEnv.HOME).not.toBe('/home/expo');
|
||||
});
|
||||
|
||||
it('hands the build log secrets filter to the agent run', async () => {
|
||||
const runAgentSpy = jest.spyOn(agentRun, 'runAgentAsync').mockResolvedValue();
|
||||
try {
|
||||
await runAsync();
|
||||
|
||||
expect(runAgentSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ addBuildLogMask: ctx.runtimeApi.addBuildLogMask })
|
||||
);
|
||||
} finally {
|
||||
runAgentSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['wss://staging-mcp.expo.test', 'https://staging-mcp.expo.test/mcp'],
|
||||
['ws://localhost:8787', 'http://localhost:8787/mcp'],
|
||||
])('points the agent at the HTTP endpoint of %s', async (mcpServerUrl, mcpUrl) => {
|
||||
const captured = mockSuccessfulRun();
|
||||
|
||||
await runAsync({ ctx: { ...ctx, mcpServerUrl } });
|
||||
|
||||
expect(captured.mcpConfig.mcpServers.expo.url).toBe(mcpUrl);
|
||||
});
|
||||
|
||||
it('stops the run at max_duration_seconds and reports a timeout', async () => {
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
|
||||
const runAgentSpy = jest.spyOn(agentRun, 'runAgentAsync').mockImplementation(
|
||||
({ signal }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
signal!.addEventListener('abort', () => reject(signal!.reason));
|
||||
})
|
||||
);
|
||||
try {
|
||||
const run = runAsync({ inputs: { ...inputs, max_duration_seconds: { value: 60 } } });
|
||||
const rejection = expect(run).rejects.toMatchObject({
|
||||
errorCode: 'EAS_RUN_AGENT_TIMEOUT',
|
||||
message: expect.stringContaining('60 seconds'),
|
||||
});
|
||||
await jest.advanceTimersByTimeAsync(59_000);
|
||||
expect(runAgentSpy.mock.calls[0][0].signal!.aborted).toBe(false);
|
||||
await jest.advanceTimersByTimeAsync(1_000);
|
||||
await rejection;
|
||||
} finally {
|
||||
runAgentSpy.mockRestore();
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an unknown agent_kind', { inputs: { ...inputs, agent_kind: { value: 'gemini' } } }],
|
||||
['a malformed agent_run_id', { inputs: { ...inputs, agent_run_id: { value: 'not-a-uuid' } } }],
|
||||
['an empty agent_run_id', { inputs: { ...inputs, agent_run_id: { value: '' } } }],
|
||||
['a zero duration', { inputs: { ...inputs, max_duration_seconds: { value: 0 } } }],
|
||||
['a negative duration', { inputs: { ...inputs, max_duration_seconds: { value: -300 } } }],
|
||||
['a fractional duration', { inputs: { ...inputs, max_duration_seconds: { value: 12.5 } } }],
|
||||
['an empty prompt', { inputs: { ...inputs, prompt: { value: '' } } }],
|
||||
['no EXPO_TOKEN', { env: { ...env, EXPO_TOKEN: '' } }],
|
||||
['no PATH', { env: { ...env, PATH: '' } }],
|
||||
['no Expo API URL', { ctx: { ...ctx, expoApiV2BaseUrl: undefined } }],
|
||||
['no MCP server URL', { ctx: { ...ctx, mcpServerUrl: undefined } }],
|
||||
['no build log secrets filter', { ctx: { ...ctx, runtimeApi: {} } }],
|
||||
])('throws a system error for %s', async (_name, overrides) => {
|
||||
await expect(runAsync(overrides)).rejects.toBeInstanceOf(SystemError);
|
||||
|
||||
expect(leaseMock).not.toHaveBeenCalled();
|
||||
expect(installMock).not.toHaveBeenCalled();
|
||||
expect(spawnMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports every invalid input at once', async () => {
|
||||
await expect(
|
||||
runAsync({
|
||||
inputs: {
|
||||
...inputs,
|
||||
agent_run_id: { value: 'not-a-uuid' },
|
||||
max_duration_seconds: { value: 0 },
|
||||
},
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'The "agent_run_id" input must be the UUID of an agent run, but received "not-a-uuid". ' +
|
||||
'The "max_duration_seconds" input must be a positive whole number of seconds, but received "0".'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type BuildStepContext, type BuildStepEnv } from '@expo/steps';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { getDeviceRunSessionIdOrThrow } from '../../utils/remoteDeviceRunSession';
|
||||
import { uploadServeSimLogsFileAsync } from '../../utils/serveSimLogsArtifacts';
|
||||
import { ServeSimLogsRecorder } from '../../utils/serveSimLogsRecorder';
|
||||
import { createCollectServeSimLogsBuildFunction } from '../collectServeSimLogs';
|
||||
import { createStartServeSimLogsBuildFunction } from '../startServeSimLogs';
|
||||
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../utils/serveSimLogsRecorder');
|
||||
jest.mock('../../utils/serveSimLogsArtifacts');
|
||||
jest.mock('../../utils/remoteDeviceRunSession');
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const step = { logger } as BuildStepContext;
|
||||
const args = { inputs: {}, outputs: {}, env: {} as BuildStepEnv };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(getDeviceRunSessionIdOrThrow).mockReturnValue('session-id');
|
||||
jest.mocked(ServeSimLogsRecorder.finishAsync).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('starts collection and tolerates startup failures', async () => {
|
||||
jest.mocked(ServeSimLogsRecorder.startAsync).mockRejectedValueOnce(new Error('disk failure'));
|
||||
await expect(createStartServeSimLogsBuildFunction().fn?.(step, args)).resolves.toBeUndefined();
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Could not start serve-sim simulator logs',
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads each device with its own bounded signal', async () => {
|
||||
jest.mocked(ServeSimLogsRecorder.finishAsync).mockResolvedValue([
|
||||
{ udid: 'A', filePath: '/tmp/A.ndjson' },
|
||||
{ udid: 'B', filePath: '/tmp/B.ndjson' },
|
||||
]);
|
||||
await createCollectServeSimLogsBuildFunction(ctx).fn?.(step, args);
|
||||
expect(uploadServeSimLogsFileAsync).toHaveBeenCalledTimes(2);
|
||||
expect(uploadServeSimLogsFileAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
deviceRunSessionId: 'session-id',
|
||||
udid: 'A',
|
||||
filePath: '/tmp/A.ndjson',
|
||||
signal: expect.any(AbortSignal),
|
||||
})
|
||||
);
|
||||
expect(jest.mocked(uploadServeSimLogsFileAsync).mock.calls[0][1].signal).not.toBe(
|
||||
jest.mocked(uploadServeSimLogsFileAsync).mock.calls[1][1].signal
|
||||
);
|
||||
});
|
||||
|
||||
it('skips empty collection and warns on finalization failure', async () => {
|
||||
await createCollectServeSimLogsBuildFunction(ctx).fn?.(step, args);
|
||||
expect(uploadServeSimLogsFileAsync).not.toHaveBeenCalled();
|
||||
jest.mocked(ServeSimLogsRecorder.finishAsync).mockRejectedValueOnce(new Error('failure'));
|
||||
await expect(
|
||||
createCollectServeSimLogsBuildFunction(ctx).fn?.(step, args)
|
||||
).resolves.toBeUndefined();
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Could not finalize serve-sim simulator logs',
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
+566
-57
@@ -1,26 +1,27 @@
|
||||
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { pollAgentDeviceArtifactsForUploadAsync } from '../../utils/agentDeviceArtifacts';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../../utils/agentDeviceEvents';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
waitForFileAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartAgentDeviceRemoteSessionBuildFunction } from '../startAgentDeviceRemoteSession';
|
||||
import { createProcessOutput } from '../../utils/processOutput';
|
||||
import { type StartupTasks, createStartupTasks } from '../../utils/startupTasks';
|
||||
import { runAgentDeviceRemoteSessionAsync } from '../startAgentDeviceRemoteSession';
|
||||
|
||||
// The daemon entry path and the state directory are resolved from the home directory when
|
||||
// the module loads, so point it at a temp home we can populate.
|
||||
@@ -40,6 +41,7 @@ jest.mock('../../utils/agentDeviceArtifacts', () => ({
|
||||
jest.mock('../../utils/agentDeviceEvents', () => ({
|
||||
startAgentDeviceEventCollectionAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
getDeviceRunSessionIdOrThrow: jest.fn(),
|
||||
@@ -47,7 +49,6 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
getNgrokTunnelDomainOrThrow: jest.fn(),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
spawnDetached: jest.fn(),
|
||||
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
|
||||
startNgrokTunnelAsync: jest.fn(),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
@@ -66,28 +67,42 @@ const mockTunnelStopAsync = jest.fn();
|
||||
const mockDaemonStopAsync = jest.fn();
|
||||
const mockEventCollectionStopAsync = jest.fn();
|
||||
|
||||
type CaptureInputs = Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]['capture'];
|
||||
const NO_CAPTURE: CaptureInputs = { networkCapture: false, networkCaptureFields: [] };
|
||||
|
||||
/** Runs an Android session that is ready, or an iOS session with no application preparation. */
|
||||
async function runAsync(
|
||||
logger: { info: jest.Mock; warn: jest.Mock },
|
||||
logger: { info: jest.Mock; warn: jest.Mock; child: jest.Mock },
|
||||
runtimePlatform: BuildRuntimePlatform,
|
||||
launchInputs: Record<string, { value: unknown }> = {}
|
||||
capture: CaptureInputs = NO_CAPTURE,
|
||||
disableShare = false,
|
||||
privatePreviewUrl?: string,
|
||||
maxIdleTimeMinutes?: number
|
||||
): Promise<void> {
|
||||
const buildFunction = createStartAgentDeviceRemoteSessionBuildFunction(ctx);
|
||||
await buildFunction.fn!(
|
||||
{ logger, global: { runtimePlatform } } as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
max_duration_seconds: { value: undefined },
|
||||
...launchInputs,
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
await runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env: {},
|
||||
logger: logger as never,
|
||||
runtimePlatform,
|
||||
sessionEnv: {
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
ngrokTunnelDomain: 'tunnel.example.com',
|
||||
ngrokAuthtoken: 'ngrok-token',
|
||||
},
|
||||
packageVersion: undefined,
|
||||
maxIdleTimeMinutes,
|
||||
maxDurationSeconds: undefined,
|
||||
capture,
|
||||
disableShare,
|
||||
privatePreviewUrl,
|
||||
tasks: createStartupTasks(logger as never),
|
||||
device:
|
||||
runtimePlatform === BuildRuntimePlatform.DARWIN
|
||||
? { iosSimulatorUdid: 'selected-udid', application: Promise.resolve() }
|
||||
: { booted: Promise.resolve(), ready: Promise.resolve() },
|
||||
});
|
||||
}
|
||||
|
||||
describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () => {
|
||||
describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
@@ -104,6 +119,7 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 4242,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: mockDaemonStopAsync,
|
||||
});
|
||||
jest.mocked(waitForFileAsync).mockResolvedValue({ port: 5678, token: 'daemon-token' });
|
||||
@@ -112,10 +128,24 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
|
||||
subdomainId: 'agent-device-abc',
|
||||
stopAsync: mockTunnelStopAsync,
|
||||
});
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.tunnel.example.com',
|
||||
stopAsync: mockPreviewStopAsync,
|
||||
const host = {
|
||||
openPreviewAsync: jest.fn().mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.tunnel.example.com',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: mockPreviewStopAsync,
|
||||
};
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementation(async (_ctx, options) => {
|
||||
try {
|
||||
await options.application;
|
||||
options.signal?.throwIfAborted();
|
||||
} catch (error) {
|
||||
await mockPreviewStopAsync();
|
||||
throw error;
|
||||
}
|
||||
return host;
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
@@ -129,14 +159,13 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
|
||||
});
|
||||
|
||||
it('reports the preview URL and tears every resource down', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
|
||||
await runAsync(logger, BuildRuntimePlatform.LINUX);
|
||||
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
|
||||
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX, timeoutMs: 60_000 })
|
||||
);
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -154,47 +183,527 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('hands the launch inputs to serve-sim and announces them on an iOS session', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
it('redacts learned daemon credentials in arbitrary output and retained diagnostics', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const token = 'daemon secret/+';
|
||||
jest.mocked(waitForFileAsync).mockResolvedValue({ port: 5678, token });
|
||||
jest.mocked(startNgrokTunnelAsync).mockImplementation(async () => {
|
||||
const options = jest.mocked(spawnDetached).mock.calls[0][0];
|
||||
expect(options.secrets).toContain(token);
|
||||
const output = createProcessOutput(options.logger, options.secrets);
|
||||
output.stdout.append('unrecognized value: daemon sec');
|
||||
output.stdout.append('ret/+\n');
|
||||
output.stderr.append(`escaped value: ${encodeURIComponent(token)}\n`);
|
||||
expect(output.getOutput()).toBe(
|
||||
'unrecognized value: [REDACTED]\nescaped value: [REDACTED]\n'
|
||||
);
|
||||
return { url: 'https://daemon.test', subdomainId: 'daemon', stopAsync: mockTunnelStopAsync };
|
||||
});
|
||||
await runAsync(logger, BuildRuntimePlatform.DARWIN);
|
||||
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(token);
|
||||
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(encodeURIComponent(token));
|
||||
});
|
||||
|
||||
it('resets the idle timer on preview input from the session host', async () => {
|
||||
const previewInputAt = new Date('2026-10-19T12:00:00Z');
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(ctx, {
|
||||
application: Promise.resolve(),
|
||||
} as never);
|
||||
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(previewInputAt);
|
||||
|
||||
await runAsync(logger, BuildRuntimePlatform.LINUX, NO_CAPTURE, false, undefined, 5);
|
||||
|
||||
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
|
||||
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
|
||||
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(previewInputAt);
|
||||
});
|
||||
|
||||
it('hands network capture to serve-sim', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
|
||||
await runAsync(logger, BuildRuntimePlatform.DARWIN, {
|
||||
launch_app_identifier: { value: 'host.exp.Exponent' },
|
||||
launch_args: { value: ['-EXDevMenuIsOnboardingFinished', '1'] },
|
||||
open_url: { value: 'exp://127.0.0.1:8081' },
|
||||
networkCapture: true,
|
||||
networkCaptureFields: ['header'],
|
||||
});
|
||||
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ networkCapture: true, networkCaptureFields: ['header'] })
|
||||
);
|
||||
});
|
||||
|
||||
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({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
|
||||
openUrl: 'exp://127.0.0.1:8081',
|
||||
disableShare: true,
|
||||
privatePreviewUrl: 'https://expo.dev/private-preview',
|
||||
})
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'serve-sim will launch host.exp.Exponent with arguments ' +
|
||||
'["-EXDevMenuIsOnboardingFinished","1"], then open exp://127.0.0.1:8081.'
|
||||
);
|
||||
});
|
||||
|
||||
it('fails before starting the daemon when a launch is asked for on Android', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
it('starts the session host without waiting for the agent-device daemon', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const sessionHost = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
|
||||
ctx,
|
||||
{} as never
|
||||
);
|
||||
let markHostStarted!: () => void;
|
||||
const hostStarted = new Promise<void>(resolve => {
|
||||
markHostStarted = resolve;
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementation(async () => {
|
||||
markHostStarted();
|
||||
return sessionHost;
|
||||
});
|
||||
// The daemon credentials only appear after the session host started. A sequential
|
||||
// startup would wait here forever.
|
||||
jest.mocked(waitForFileAsync).mockImplementation(async () => {
|
||||
await hostStarted;
|
||||
return { port: 5678, token: 'daemon-token' };
|
||||
});
|
||||
|
||||
await expect(
|
||||
runAsync(logger, BuildRuntimePlatform.LINUX, {
|
||||
launch_app_identifier: { value: 'host.exp.Exponent' },
|
||||
await runAsync(logger, BuildRuntimePlatform.DARWIN);
|
||||
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: expect.objectContaining({
|
||||
agentDeviceRemoteSessionUrl: 'https://agent-device-abc.tunnel.example.com',
|
||||
agentDeviceRemoteSessionToken: 'daemon-token',
|
||||
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
}),
|
||||
})
|
||||
).rejects.toThrow('runs on linux');
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
);
|
||||
});
|
||||
|
||||
it('declares the launch inputs', () => {
|
||||
const buildFunction = createStartAgentDeviceRemoteSessionBuildFunction(ctx);
|
||||
const globalCtx = createGlobalContextMock();
|
||||
it('stops the daemon and its tunnel when the session host fails to start', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
jest
|
||||
.mocked(startDeviceSessionHostAsync)
|
||||
.mockRejectedValue(new Error('serve-sim did not start'));
|
||||
|
||||
expect(
|
||||
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
|
||||
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
|
||||
await expect(runAsync(logger, BuildRuntimePlatform.DARWIN)).rejects.toThrow(
|
||||
'serve-sim did not start'
|
||||
);
|
||||
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
// The failure aborts the daemon task, which then may not start its daemon or tunnel.
|
||||
// Whatever started is stopped.
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(jest.mocked(spawnDetached).mock.calls.length);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(
|
||||
jest.mocked(startNgrokTunnelAsync).mock.calls.length
|
||||
);
|
||||
});
|
||||
|
||||
it('stops the session host and the daemon when the daemon credentials never appear', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
jest.mocked(waitForFileAsync).mockRejectedValue(new Error('no daemon credentials'));
|
||||
|
||||
await expect(runAsync(logger, BuildRuntimePlatform.DARWIN)).rejects.toThrow(
|
||||
'no daemon credentials'
|
||||
);
|
||||
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('runAgentDeviceRemoteSessionAsync with a device that is still starting', () => {
|
||||
function deferred<T = void>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (e: Error) => void;
|
||||
} {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (e: Error) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
promise.catch(() => {});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
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, signal);
|
||||
const sessionDevice = typeof device === 'function' ? device(tasks) : device;
|
||||
return runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env: {},
|
||||
logger,
|
||||
signal,
|
||||
runtimePlatform:
|
||||
'iosSimulatorUdid' in sessionDevice
|
||||
? BuildRuntimePlatform.DARWIN
|
||||
: BuildRuntimePlatform.LINUX,
|
||||
sessionEnv: {
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
ngrokTunnelDomain: 'tunnel.example.com',
|
||||
ngrokAuthtoken: 'ngrok-token',
|
||||
},
|
||||
packageVersion: undefined,
|
||||
maxIdleTimeMinutes: undefined,
|
||||
maxDurationSeconds: undefined,
|
||||
capture: NO_CAPTURE,
|
||||
tasks,
|
||||
device: sessionDevice,
|
||||
});
|
||||
}
|
||||
|
||||
async function flushAsync(): Promise<void> {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
expect(spawnDetached).toHaveBeenCalledTimes(1);
|
||||
expect(startNgrokTunnelAsync).toHaveBeenCalledTimes(1);
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
|
||||
booted.resolve();
|
||||
await session;
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
timeoutMs: 60_000,
|
||||
iosSimulatorUdid: undefined,
|
||||
})
|
||||
);
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('preserves a failed download when aborting daemon installation rejects first', async () => {
|
||||
const failure = new Error('download failed: build not found');
|
||||
const installStarted = deferred();
|
||||
jest.mocked(spawn).mockImplementation((async (_command, _args, options) => {
|
||||
return await new Promise((_resolve, reject) => {
|
||||
options!.signal!.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
const error = new Error('The operation was aborted');
|
||||
error.name = 'AbortError';
|
||||
reject(error);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
installStarted.resolve();
|
||||
});
|
||||
}) as typeof spawn);
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementation(async (_ctx, { signal }) => {
|
||||
return await new Promise((_resolve, reject) => {
|
||||
signal!.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
setTimeout(() => reject(signal!.reason), 20);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
const applicationReady = Promise.resolve();
|
||||
const session = startSession(tasks => {
|
||||
void tasks.run('build download', async () => {
|
||||
await installStarted.promise;
|
||||
throw failure;
|
||||
});
|
||||
return { iosSimulatorUdid: 'selected-udid', application: applicationReady };
|
||||
});
|
||||
await expect(session).rejects.toBe(failure);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('boots the host during download and releases startup only after preparation', async () => {
|
||||
const hostReady = deferred();
|
||||
const application = deferred<{
|
||||
installAppPath: string;
|
||||
launchAppIdentifier: string;
|
||||
launchArgs: string[];
|
||||
openUrl: string;
|
||||
}>();
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
|
||||
ctx,
|
||||
{} as never
|
||||
);
|
||||
const release = jest.fn();
|
||||
jest
|
||||
.mocked(startDeviceSessionHostAsync)
|
||||
.mockClear()
|
||||
.mockImplementation(async (_ctx, options) => {
|
||||
release(await options.application);
|
||||
await hostReady.promise;
|
||||
return host;
|
||||
});
|
||||
const applicationReady = application.promise;
|
||||
const session = startSession({
|
||||
iosSimulatorUdid: 'selected-udid',
|
||||
application: applicationReady,
|
||||
});
|
||||
await flushAsync();
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
|
||||
expect(release).not.toHaveBeenCalled();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
const startup = {
|
||||
installAppPath: '/tmp/App.app',
|
||||
launchAppIdentifier: 'dev.example.app',
|
||||
launchArgs: ['--flag'],
|
||||
openUrl: 'example://screen',
|
||||
};
|
||||
application.resolve(startup);
|
||||
await flushAsync();
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
application: applicationReady,
|
||||
iosSimulatorUdid: 'selected-udid',
|
||||
timeoutMs: 60_000,
|
||||
signal: expect.any(AbortSignal),
|
||||
})
|
||||
);
|
||||
expect(release).toHaveBeenCalledWith(startup);
|
||||
expect(host.openPreviewAsync).not.toHaveBeenCalled();
|
||||
hostReady.resolve();
|
||||
await session;
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('passes the guard boot environment with the startup installation and launch', async () => {
|
||||
const startup = {
|
||||
installAppPath: '/tmp/App.app',
|
||||
launchAppIdentifier: 'dev.example.app',
|
||||
launchArgs: ['--flag'],
|
||||
openUrl: 'example://screen',
|
||||
};
|
||||
const bootEnv = {
|
||||
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
|
||||
SIMCTL_CHILD_EAS_EGRESS_GUARD_MODE: 'block',
|
||||
SIMCTL_CHILD_EAS_EGRESS_GUARD_LOG: '/tmp/egress-guard.log',
|
||||
SIMCTL_CHILD_HTTP_PROXY: 'http://127.0.0.1:8899',
|
||||
};
|
||||
await startSession({
|
||||
iosSimulatorUdid: 'guarded-udid',
|
||||
bootEnv,
|
||||
application: Promise.resolve(startup),
|
||||
});
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
application: expect.any(Promise),
|
||||
bootEnv,
|
||||
iosSimulatorUdid: 'guarded-udid',
|
||||
})
|
||||
);
|
||||
const hostOptions = jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1];
|
||||
expect(hostOptions.bootEnv).toBe(bootEnv);
|
||||
expect(hostOptions.bootEnv).not.toHaveProperty('DYLD_INSERT_LIBRARIES');
|
||||
expect(hostOptions.bootEnv).not.toHaveProperty('SIMCTL_CHILD_DYLD_INSERT_LIBRARIES');
|
||||
});
|
||||
|
||||
it('cleans up a host returned after cancellation without configuring the device', async () => {
|
||||
const hostReady = deferred();
|
||||
const controller = new AbortController();
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
|
||||
ctx,
|
||||
{} as never
|
||||
);
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementation(async () => {
|
||||
await hostReady.promise;
|
||||
return host;
|
||||
});
|
||||
const applicationReady = Promise.resolve();
|
||||
const session = startSession(
|
||||
{ iosSimulatorUdid: 'selected-udid', application: applicationReady },
|
||||
controller.signal
|
||||
);
|
||||
const failure = expect(session).rejects.toThrow('cancelled');
|
||||
await flushAsync();
|
||||
controller.abort(new Error('cancelled'));
|
||||
hostReady.resolve();
|
||||
|
||||
await failure;
|
||||
expect(host.openPreviewAsync).not.toHaveBeenCalled();
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drains in-flight application preparation and stops the waiting host after cancellation', async () => {
|
||||
const controller = new AbortController();
|
||||
const appReady = deferred();
|
||||
const session = startSession(
|
||||
{ iosSimulatorUdid: 'selected-udid', application: appReady.promise },
|
||||
controller.signal
|
||||
);
|
||||
const failure = expect(session).rejects.toThrow('cancelled');
|
||||
await flushAsync();
|
||||
controller.abort(new Error('cancelled'));
|
||||
await flushAsync();
|
||||
expect(mockPreviewStopAsync).not.toHaveBeenCalled();
|
||||
|
||||
appReady.resolve();
|
||||
await failure;
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not report readiness when cancellation arrives during preview startup', async () => {
|
||||
const controller = new AbortController();
|
||||
const previewReady = deferred();
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
|
||||
ctx,
|
||||
{} as never
|
||||
);
|
||||
const preview = await host.openPreviewAsync({
|
||||
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();
|
||||
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
|
||||
ready.resolve();
|
||||
await session;
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
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>(() => {});
|
||||
|
||||
// In the combined step, `ready` stops waiting for the boot on an abort, like this.
|
||||
await expect(
|
||||
startSession(tasks => ({ booted: neverBooted, ready: tasks.untilAborted(neverBooted) }))
|
||||
).rejects.toThrow('no daemon credentials');
|
||||
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancels daemon installation and stops the Android host when the download fails', async () => {
|
||||
jest.mocked(spawn).mockImplementation(((
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: { signal?: AbortSignal }
|
||||
) =>
|
||||
command === 'bun' && args[0] === 'add'
|
||||
? new Promise((_resolve, reject) => {
|
||||
options!.signal!.addEventListener('abort', () => reject(options!.signal!.reason));
|
||||
})
|
||||
: Promise.resolve(undefined)) as never);
|
||||
const ready = deferred();
|
||||
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
|
||||
await flushAsync();
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
|
||||
|
||||
ready.reject(new Error('download failed'));
|
||||
|
||||
await expect(session).rejects.toThrow('download failed');
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops the Android host, daemon and tunnel when the download fails', async () => {
|
||||
const ready = deferred();
|
||||
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
|
||||
await flushAsync();
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
|
||||
ready.reject(new Error('download failed'));
|
||||
|
||||
await expect(session).rejects.toThrow('download failed');
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
+88
-2
@@ -75,6 +75,7 @@ describe(startAgentDeviceDaemonAsync, () => {
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 1234,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync,
|
||||
});
|
||||
jest.mocked(spawn).mockImplementation((async (
|
||||
@@ -92,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',
|
||||
@@ -107,14 +130,35 @@ 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' }),
|
||||
env: expect.objectContaining({
|
||||
AGENT_DEVICE_DAEMON_SERVER_MODE: 'http',
|
||||
AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0',
|
||||
AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS: '0',
|
||||
AGENT_DEVICE_SESSION_IDLE_TIMEOUT_MS: '0',
|
||||
}),
|
||||
});
|
||||
|
||||
await handle.stopAsync();
|
||||
await expect(fs.promises.access(addCwd)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('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,
|
||||
@@ -129,6 +173,41 @@ 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();
|
||||
const failure = new Error('app install failed');
|
||||
|
||||
const daemon = startAgentDeviceDaemonAsync({
|
||||
packageVersion: '1.2.3',
|
||||
env: {},
|
||||
logger,
|
||||
signal: controller.signal,
|
||||
});
|
||||
await started;
|
||||
controller.abort(failure);
|
||||
|
||||
await expect(daemon).rejects.toBe(failure);
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'bun',
|
||||
['add', '[email protected]'],
|
||||
expect.objectContaining({ signal: controller.signal })
|
||||
);
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to git clone when the published daemon is missing', async () => {
|
||||
jest.mocked(spawn).mockImplementation((async (command: string, args: string[]) => {
|
||||
if (command === 'bun' && args[0] === '--version') {
|
||||
@@ -163,9 +242,16 @@ describe(startAgentDeviceDaemonAsync, () => {
|
||||
);
|
||||
expect(spawnDetached).toHaveBeenCalledWith({
|
||||
command: 'bun',
|
||||
logger,
|
||||
secrets: undefined,
|
||||
args: ['run', 'src/daemon.ts'],
|
||||
cwd: '/tmp/agent-device-src',
|
||||
env: expect.objectContaining({ AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' }),
|
||||
env: expect.objectContaining({
|
||||
AGENT_DEVICE_DAEMON_SERVER_MODE: 'http',
|
||||
AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0',
|
||||
AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS: '0',
|
||||
AGENT_DEVICE_SESSION_IDLE_TIMEOUT_MS: '0',
|
||||
}),
|
||||
});
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Failed to start agent-device daemon from the configured package manager; falling back to git clone',
|
||||
|
||||
@@ -0,0 +1,731 @@
|
||||
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,
|
||||
runAgentDeviceRemoteSessionAsync,
|
||||
} from '../startAgentDeviceRemoteSession';
|
||||
import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceSession';
|
||||
import { startAndroidEmulatorAsync } from '../startAndroidEmulator';
|
||||
import { bootIosSimulatorAsync } from '../startIosSimulator';
|
||||
|
||||
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', () => ({
|
||||
...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(),
|
||||
}));
|
||||
jest.mock('../startAgentDeviceRemoteSession', () => ({
|
||||
getAgentDeviceRemoteSessionEnvOrThrow: jest.fn(),
|
||||
runAgentDeviceRemoteSessionAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const graphqlClient = { query: jest.fn() };
|
||||
const ctx = { graphqlClient } as unknown as CustomBuildContext;
|
||||
const sessionEnv = {
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
ngrokTunnelDomain: 'tunnel.example.com',
|
||||
ngrokAuthtoken: 'ngrok-token',
|
||||
};
|
||||
|
||||
type Device = 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>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (err: Error) => void;
|
||||
} {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (err: Error) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
promise.catch(() => {});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function flushAsync(): Promise<void> {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
function runStep(
|
||||
runtimePlatform: BuildRuntimePlatform,
|
||||
inputValues: Record<string, unknown> = {},
|
||||
signal?: AbortSignal,
|
||||
stepCtx: CustomBuildContext = ctx
|
||||
): Promise<void> {
|
||||
const buildFunction = createStartAgentDeviceSessionBuildFunction(stepCtx);
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const inputs = Object.fromEntries(
|
||||
[
|
||||
'device_identifier',
|
||||
'system_image_package',
|
||||
'lcd_width',
|
||||
'lcd_height',
|
||||
'lcd_density',
|
||||
'build_id',
|
||||
'application_archive_url',
|
||||
'launch_args',
|
||||
'open_url',
|
||||
'network_capture',
|
||||
'network_capture_fields',
|
||||
'public_preview',
|
||||
'private_preview_url',
|
||||
'frame_ancestors',
|
||||
'cors_origins',
|
||||
'package_version',
|
||||
'max_idle_time_minutes',
|
||||
'max_duration_seconds',
|
||||
].map(id => [id, { value: inputValues[id] }])
|
||||
);
|
||||
return buildFunction.fn!(
|
||||
{
|
||||
logger,
|
||||
global: {
|
||||
runtimePlatform,
|
||||
staticContext: { job: { secrets: { robotAccessToken: 'robot-token' } } },
|
||||
},
|
||||
} as unknown as BuildStepContext,
|
||||
{ inputs, outputs: {}, env: {}, signal } as never
|
||||
) as Promise<void>;
|
||||
}
|
||||
|
||||
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, tasks }) => {
|
||||
await tasks.untilAborted(runDevicePreparationAsync(device));
|
||||
});
|
||||
jest.mocked(bootIosSimulatorAsync).mockResolvedValue({
|
||||
deviceIdentifier: 'iPhone 17' as never,
|
||||
udid: 'udid' as never,
|
||||
displayName: 'iPhone 17',
|
||||
});
|
||||
jest.mocked(startAndroidEmulatorAsync).mockResolvedValue({
|
||||
serialId: 'emulator-5554' as never,
|
||||
emulatorPromise: Promise.resolve(),
|
||||
shouldAdjustAnimationScale: true,
|
||||
});
|
||||
jest.mocked(downloadBuildAsync).mockResolvedValue({ artifactPath: '/tmp/App.app' });
|
||||
jest
|
||||
.mocked(installBuildAsync)
|
||||
.mockResolvedValue({ applicationIdentifier: 'dev.example.app', activityName: '.Main' });
|
||||
jest.mocked(launchApplicationAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(readIosApplicationIdentifierAsync).mockResolvedValue('dev.example.app');
|
||||
});
|
||||
|
||||
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',
|
||||
});
|
||||
expect(
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).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',
|
||||
extensions: ['app'],
|
||||
graphqlClient,
|
||||
robotAccessToken: 'robot-token',
|
||||
})
|
||||
);
|
||||
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledTimes(1);
|
||||
const device = sessionDevice();
|
||||
expect(device).toEqual({
|
||||
iosSimulatorUdid: 'SELECTED-UDID',
|
||||
bootEnv,
|
||||
application: expect.any(Promise),
|
||||
});
|
||||
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',
|
||||
});
|
||||
});
|
||||
|
||||
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('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);
|
||||
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, {
|
||||
application_archive_url: 'https://example.test/app.tar.gz',
|
||||
});
|
||||
await flushAsync();
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
|
||||
download.resolve({ artifactPath: '/tmp/App.app' });
|
||||
await step;
|
||||
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledTimes(1);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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();
|
||||
expect(sessionDevice()).toEqual({
|
||||
iosSimulatorUdid: 'SELECTED-UDID',
|
||||
bootEnv: undefined,
|
||||
application: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('boots the Android Emulator with the device inputs', async () => {
|
||||
await runStep(BuildRuntimePlatform.LINUX, {
|
||||
device_identifier: 'pixel_7',
|
||||
system_image_package: 'system-images;android-35-ext15;google_apis_playstore;x86_64',
|
||||
lcd_width: 720,
|
||||
lcd_height: 1600,
|
||||
lcd_density: 300,
|
||||
build_id: 'build-id',
|
||||
});
|
||||
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
expect(startAndroidEmulatorAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
deviceIdentifier: 'pixel_7',
|
||||
systemImagePackage: 'system-images;android-35-ext15;google_apis_playstore;x86_64',
|
||||
lcdWidth: 720,
|
||||
lcdHeight: 1600,
|
||||
lcdDensity: 300,
|
||||
})
|
||||
);
|
||||
expect(downloadBuildAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ extensions: ['apk'] })
|
||||
);
|
||||
expect(launchApplicationAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ applicationIdentifier: 'dev.example.app', activityName: '.Main' })
|
||||
);
|
||||
});
|
||||
|
||||
it('fails the session when the download fails, without an unhandled rejection', async () => {
|
||||
jest.mocked(downloadBuildAsync).mockRejectedValue(new Error('download failed'));
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'download failed'
|
||||
);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/** A download that runs until its abort signal fires, like a stalled one. */
|
||||
function mockStalledDownload(): { aborted: () => boolean; settled: () => boolean } {
|
||||
let aborted = false;
|
||||
let settled = false;
|
||||
jest.mocked(downloadBuildAsync).mockImplementation(
|
||||
({ signal }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
signal!.addEventListener('abort', () => {
|
||||
aborted = true;
|
||||
// Settles a bit later, like a request that winds down.
|
||||
setImmediate(() => {
|
||||
settled = true;
|
||||
reject(new Error('The user aborted a request.'));
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
return { aborted: () => aborted, settled: () => settled };
|
||||
}
|
||||
|
||||
/** Runs the session like the real one: fails with the daemon, then waits for every part. */
|
||||
function mockSessionWithFailingDaemon(beforeFailure?: () => Promise<void>): void {
|
||||
jest
|
||||
.mocked(runAgentDeviceRemoteSessionAsync)
|
||||
.mockImplementation(async (_ctx, { tasks, device }) => {
|
||||
const ready = runDevicePreparationAsync(device);
|
||||
await beforeFailure?.();
|
||||
const daemon = tasks.run('agent-device daemon', async () => {
|
||||
throw new Error('daemon failed');
|
||||
});
|
||||
try {
|
||||
await Promise.all([daemon, ready]);
|
||||
} finally {
|
||||
await Promise.allSettled([daemon, ready]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it('stops the download and waits for it when Android boot fails', async () => {
|
||||
const download = mockStalledDownload();
|
||||
jest.mocked(startAndroidEmulatorAsync).mockRejectedValue(new Error('boot failed'));
|
||||
|
||||
await expect(runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'boot failed'
|
||||
);
|
||||
|
||||
expect(download.aborted()).toBe(true);
|
||||
// The step returned only after the download stopped.
|
||||
expect(download.settled()).toBe(true);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops a stalled download and does not install when the daemon fails', async () => {
|
||||
const download = mockStalledDownload();
|
||||
mockSessionWithFailingDaemon();
|
||||
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'daemon failed'
|
||||
);
|
||||
|
||||
expect(download.aborted()).toBe(true);
|
||||
expect(download.settled()).toBe(true);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('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 metadataStarted.promise;
|
||||
setImmediate(() => metadata.resolve('dev.example.app'));
|
||||
});
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'daemon failed'
|
||||
);
|
||||
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 () => {
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, {
|
||||
build_id: 'build-id',
|
||||
application_archive_url: 'https://example.test/app.tar.gz',
|
||||
})
|
||||
).rejects.toThrow('Pass only one of build_id or application_archive_url.');
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, { open_url: 'exp://example.test' })
|
||||
).rejects.toThrow('launch_args and open_url need an application');
|
||||
|
||||
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('declares the step inputs', () => {
|
||||
const buildFunction = createStartAgentDeviceSessionBuildFunction(ctx);
|
||||
const globalCtx = createGlobalContextMock();
|
||||
|
||||
expect(
|
||||
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
|
||||
).toEqual([
|
||||
'device_identifier',
|
||||
'system_image_package',
|
||||
'lcd_width',
|
||||
'lcd_height',
|
||||
'lcd_density',
|
||||
'build_id',
|
||||
'application_archive_url',
|
||||
'launch_args',
|
||||
'open_url',
|
||||
'network_capture',
|
||||
'network_capture_fields',
|
||||
'public_preview',
|
||||
'private_preview_url',
|
||||
'frame_ancestors',
|
||||
'cors_origins',
|
||||
'package_version',
|
||||
'max_idle_time_minutes',
|
||||
'max_duration_seconds',
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([undefined, true, false])(
|
||||
'passes public_preview=%s to the iOS session host',
|
||||
async publicPreview => {
|
||||
await runStep(BuildRuntimePlatform.DARWIN, {
|
||||
public_preview: publicPreview,
|
||||
private_preview_url: 'https://expo.dev/private-preview',
|
||||
});
|
||||
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
disableShare: publicPreview === false,
|
||||
privatePreviewUrl: 'https://expo.dev/private-preview',
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('passes frame_ancestors to the session host', async () => {
|
||||
await runStep(BuildRuntimePlatform.DARWIN, {
|
||||
frame_ancestors: ['https://embed.example.test'],
|
||||
});
|
||||
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ frameAncestors: ['https://embed.example.test'] })
|
||||
);
|
||||
});
|
||||
|
||||
it('passes cors_origins to the session host', async () => {
|
||||
await runStep(BuildRuntimePlatform.DARWIN, {
|
||||
cors_origins: ['https://app.example.test'],
|
||||
});
|
||||
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ corsOrigins: ['https://app.example.test'] })
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['argument count', { launch_args: Array(257).fill('a') }],
|
||||
['argument length', { launch_args: ['a'.repeat(9000)] }],
|
||||
['NUL argument', { launch_args: ['a\u0000b'] }],
|
||||
['total UTF-8 bytes', { launch_args: Array(8).fill('😃'.repeat(4096)) }],
|
||||
['NUL URL', { open_url: 'example:\u0000screen' }],
|
||||
['URL length', { open_url: `example://${'a'.repeat(8192)}` }],
|
||||
])('rejects iOS launch %s before startup work', async (_name, inputs) => {
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id', ...inputs });
|
||||
await expect(step).rejects.toBeInstanceOf(UserError);
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
expect(IosSimulatorUtils.resolveUdidAsync).not.toHaveBeenCalled();
|
||||
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['NUL argument', { launch_args: ['a\u0000b'] }],
|
||||
['command length', { launch_args: ['--es', 'quotes', "'".repeat(20_000)] }],
|
||||
['NUL URL', { open_url: 'example:\u0000screen' }],
|
||||
])('rejects Android launch %s before startup work', async (_name, inputs) => {
|
||||
const step = runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id', ...inputs });
|
||||
await expect(step).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
|
||||
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps Android launch inputs on its existing direct path', async () => {
|
||||
const launchArgs = ['a'.repeat(9000)];
|
||||
await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id', launch_args: launchArgs });
|
||||
expect(launchApplicationAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX, launchArgs })
|
||||
);
|
||||
});
|
||||
|
||||
it('passes network capture to the session', async () => {
|
||||
await runStep(BuildRuntimePlatform.DARWIN, {
|
||||
network_capture: true,
|
||||
network_capture_fields: ['header', 'response-body'],
|
||||
});
|
||||
|
||||
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
capture: { networkCapture: true, networkCaptureFields: ['header', 'response-body'] },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects network capture on Android before it boots anything', async () => {
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.LINUX, { network_capture: true, system_image_package: 'x' })
|
||||
).rejects.toThrow('records traffic through serve-sim on an iOS simulator');
|
||||
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Loaded 100 of 570 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user