Compare commits

...
42 Commits
Author SHA1 Message Date
Expo CI c8aa5eb05d v25.1.0 2026-10-16 19:51:06 +00:00
Krystof WoldrichandClaude Fable 5.1 68596f8c0c [eas-cli] add --cors-origin to simulator:start (#4658)
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-16 21:23:09 +02:00
Krystof WoldrichandClaude Fable 5.1 1e38ada929 [eas-cli] add --frame-ancestor to simulator:start (#4656)
* [eas-cli] add --frame-ancestor to simulator:start and print the embed URL

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [eas-cli] cover the embed output fallbacks

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [eas-cli] format the embed output tests

Co-Authored-By: Claude Fable 5.1 <[email protected]>

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-16 21:23:09 +02:00
Krystof WoldrichandClaude Fable 5.1 d52629e9f3 [build-tools] pass cors_origins session input to the preview hosts (#4657)
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-16 20:40:20 +02:00
Krystof WoldrichandClaude Fable 5.1 bed6354ef5 [build-tools] pass frame_ancestors session input to the preview hosts (#4655)
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-16 20:05:51 +02:00
Stanisław Chmiela 9aac1a0dff [build-tools] Retry commits while Google Play processes bundles (#4651)
# Why

https://sentry.io/organizations/expoio/issues/7792509578/events/f6692b78705a40ab9380fd177c265b35/

# How

Supposedly Fastlane retries committing.

# Test Plan

CI should pass. Land and see that the error no longer occurs.
2026-10-16 15:26:32 +02:00
Stanisław Chmiela c1c2b748b4 [build-tools] Report Google Play error responses to Sentry (#4652)
# Why

Google Play submissions sometimes fail with generic errors like `Precondition check failed.` (HTTP 400, `FAILED_PRECONDITION`). We currently drop everything except `error.message` and `errors[].reason`, so neither the user nor we can tell what Google actually objected to.

This replaces #4650, which also put Google's details into user-facing messages and structured logs, and added a redacting sanitizer. That made the code a lot more complex. This PR keeps build logs unchanged and only makes the raw response available in Sentry. Once real payloads show up there, we can add targeted classifiers to `mapGooglePlayError` with clear user-facing messages.

# How

- `GooglePlayApiError` now keeps:
  - the JSON error response body, as a string capped at 8 KB
  - the request URL path, without the query (the upload session query contains a secret)

  Non-JSON bodies are still dropped, as before.
- `GooglePlayApiError.getErrorMetadata()` finds the API error in the cause chain, through retry `AggregateError`s and commit-outcome-unknown wrappers. It returns `googlePlayRequestPath` and `googlePlayErrorResponse`.
- `mapGooglePlayError` and `GooglePlayCommitOutcomeUnknownError` attach that as `ExpoError.metadata`. The worker already sends it to Sentry as `extras.errorMetadata`, and it is not part of the external build error.
- The body is stored as a string because `@sentry/node` 7 normalizes extras to depth 3, so nested `details` would otherwise show up as `[Object]`.

User-facing messages are unchanged.

# Test Plan

- New tests in `GooglePlaySubmission.test.ts`:
  - a `FAILED_PRECONDITION` commit with `details` ends up in metadata but not in the message
  - metadata survives retries when the commit outcome is unknown
  - the upload-session query and non-JSON bodies are not stored
- `yarn test src/steps/utils/android src/steps/functions/__tests__/submitToGooglePlay.test.ts` (153 passed), plus `yarn typecheck`, `yarn lint`, `yarn fmt:check`

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-16 15:17:05 +02:00
Gabe Debes 7b39b92df8 [build-tools] Experiment with 4 GiB RAM for Android device sessions (#4640) 2026-10-16 12:57:51 +02:00
Stanisław Chmiela 929905ba7e [build-tools] Retry transient Google Play API requests (#4629)
# Why

While testing submissions on staging I found Google APIs flake sometimes.

# How

Wrapped requests with auto-retry when we can be sure we can retry.

# Test Plan

CI should pass.
2026-10-16 12:01:48 +02:00
Philippe Loulidi ef7fceba86 [eas-cli] ENG-27937 Show EAS Simulator minutes in eas account:usage (#4583)
* [eas-cli] Show EAS Simulator minutes in eas account:usage

* [eas-cli] report unavailable Simulator usage estimates

* [eas-cli] Replace Simulator usage changelog entry with changeset
2026-10-15 17:21:22 -06:00
Expo CI c2dc766c38 bump STAGING and PRODUCTION to 25.0.0 in cli-versions.json 2026-10-15 23:01:41 +00:00
Expo CI 785cfe2d53 v25.0.0 2026-10-15 22:42:24 +00:00
Gabe DebesandCodex 18a008a39a [eas-cli] Default simulator previews to authenticated links and add --public-link option (#4619)
* [eas-cli] default simulator previews to authenticated links

Co-authored-by: Codex <[email protected]>

* [eas-cli] Persist simulator preview sharing preference

* [eas-cli] omit direct preview API addresses from private JSON

* [eas-cli] use saved simulator sharing preference in get

Co-authored-by: Codex <[email protected]>

* [eas-cli] warn before printing public simulator links

Edited-by: Codex

---------

Co-authored-by: Codex <[email protected]>
2026-10-15 15:34:10 -07:00
Will Schurman 🦦 f5be6b8d88 [eas-cli] Use effectivePermissions for logic about capabilities and account:view display (#4637) 2026-10-15 15:28:36 -07:00
Gabe DebesandCodex c4064ea9ba [build-tools] Use private links for private simulator sessions (#4620)
* [build-tools] Respect simulator preview sharing preferences

* [build-tools] Add PR attribution to changelog entry

* [build-tools] clarify preview Share fallback and verify probe cleanup

* [build-tools] pass preview sharing flags directly

Co-authored-by: Codex <[email protected]>

* [build-tools] clarify public preview logging coverage

Co-authored-by: Codex <[email protected]>

* [build-tools] redact preview tokens from host diagnostics

Edited-by: Codex

---------

Co-authored-by: Codex <[email protected]>
2026-10-15 14:25:11 -07:00
Will Schurman 🦦 f1dda5df94 [eas-cli] Update error message for scoped VC in EAS CLI now that scoped can be used outside build (#4636)
<!-- Add a changeset with `yarn changeset`, or skip the check by labeling the PR with "no changelog". -->

# Why

Now that we're testing out permission scoping for tokens, EAS CLI needs to handle these cases more gracefully. First step is this error message, which previously assumed scoped tokens would only be used within build jobs.

# How

Update it to have a separate error message for scoped tokens outside of a build job. Also correct "user"/"account" terminology discrepancy.

# Test Plan

`yarn start`
`export EXPO_STAGING=1`
`neas login`, select viewer scope
run `neas update`
see
```
You don't have the required permissions to perform this operation.

This can sometimes happen if you are logged in as incorrect user or if the permissions granted during authentication are insufficient to perform the operation.
Run eas whoami to check the user you are logged in as and your permission grant.
Run eas login to change the user or permission grant.

Original error message: Entity Not Authorized: Entity not authorized: AppFingerprintEntity[00000000-0000-0000-0000-000000000000] (viewer = ScopedAccountActorViewerContext[RegularUserViewerContext[b99c08be-7584-4358-b8e4-aef050972708], scopes=expo_account:c344bcd9-c79d-4c0b-96fb-940d947cac42(RUN,VIEW);expo_account:55ebf7e2-f986-4b54-950d-55d38f355e29(RUN,VIEW)], action = CREATE, ruleIndex = -1)
Request ID: 410d2f30-c4fa-4575-b21a-6012f6c1e93f
```
2026-10-15 13:21:59 -07:00
Szymon Dziedzic 4ec2a5fe9e [build-tools] Call the device run session worker API with the session token (#4615)
* [build-tools] Call the device run session worker API with the session token

When a device run session job has the EAS_DEVICE_RUN_SESSION_TOKEN environment
secret, send every session-related www call to /v2/device-run-session-worker/*
with the expo-device-run-session-token header instead of GraphQL or the
account-wide REST routes. Without the token, keep the current calls.

The worker also reports Datadog metrics for these jobs through
/v2/device-run-session-worker/metrics.

* [build-tools] Download session builds without the robot token

In a device run session job with a session token, the build archive URL comes
from the device run session worker API and points to an artifact route that
needs no auth. After www stops minting EXPO_TOKEN for session jobs,
robotAccessToken is the session token, and www rejects it as a bearer
credential on every route. Send no Authorization header in that case.

* [build-tools] Match the device run session worker API response contract

www returns entity values from the worker API: session statuses are
lowercase and hyphenated, and GET /application returns
{ platform: 'ios' | 'android', build: { id, gitCommitHash,
applicationArchiveUrl } | null } without app slugs. Map the statuses to the
GraphQL values that callers compare, and name screenshots without an app
slug in worker API mode.

The worker now resolves duplicate EAS_DEVICE_RUN_SESSION_TOKEN secrets like
the build env does (the last one wins), so metrics and steps use the same
token. Add tests for the token propagation from job secrets to the worker API
client and for the download_build and start_agent_device_session call sites.

* [build-tools] Take the device run session platform from the worker runtime

* [build-tools] Report device run session build lookups as system errors and time out hung session requests
2026-10-15 19:24:06 +02:00
Szymon Dziedzic 1068fab6ac [build-tools] Keep NGROK_AUTHTOKEN out of device run session child processes (#4614) 2026-10-15 19:11:11 +02:00
Gabe DebesandGPT-6.1-Sol 3e00e86e5a [build-tools] Let serve-sim start the iOS Simulator for web preview (#4601)
* [build-tools] Let serve-sim start the iOS Simulator for web preview

Co-Authored-By: GPT-6.1-Sol <[email protected]>

* [build-tools] Keep web-preview startup inside the session host

* [build-tools] Mock app metadata at its own boundary

* [build-tools] Stop startup when preview readiness fails

---------

Co-authored-by: GPT-6.1-Sol <[email protected]>
2026-10-15 09:28:11 -07:00
Gabe DebesandGPT-6.1-Sol 69a4a06320 [build-tools] Add serve-sim Simulator startup helpers (#4605)
* [build-tools] Add serve-sim Simulator startup helpers

Co-Authored-By: GPT-6.1-Sol <[email protected]>

* [build-tools] Keep generic boot and install steps independent

* [build-tools] Keep iOS app metadata independent of serve-sim

* [build-tools] Cover serve-sim startup validation and failures

---------

Co-authored-by: GPT-6.1-Sol <[email protected]>
2026-10-15 09:28:10 -07:00
Gabe DebesandGPT-6.1 Sol 219df86f88 [build-tools] Separate Argent and preview logs (#4587)
Co-authored-by: GPT-6.1 Sol <[email protected]>
2026-10-15 09:28:10 -07:00
Gabe DebesandGPT-6.1 Sol 50e1f2aea5 [build-tools] Separate Appium and preview logs (#4586)
Co-authored-by: GPT-6.1 Sol <[email protected]>
2026-10-15 09:28:09 -07:00
Krystof WoldrichandClaude Opus 5.5 4400b93ed3 [build-tools] Start expo-device-hub with a session token and the website origins (#4578)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 15:37:44 +02:00
Ash Wu 52c1a4ce54 [build-tools][worker] Mask the credentials eas/run_agent leases with the build log secrets filter (#4624)
The worker's secrets filter takes secrets added while the job runs through
addBuildLogMask, which reaches steps via the build context's runtimeApi.
eas/run_agent passes each leased token to it and drops its own masking.

Masking marks every occurrence of every secret on the original message and
masks once, so a secret that is a substring of another no longer breaks the
longer match. The log buffer that feeds error detection masks with the same
patterns.
2026-10-15 20:47:34 +08:00
Stanisław Chmiela f97e6ecfa8 [build-tools] Polish some Google Play submission messages (#4625)
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

I want to polish Google Play submission messages a bit.

# How

Tested the code with a few more edge cases and handled them a bit better.

# Test Plan

CI should pass.
2026-10-15 10:36:30 +02:00
Gabe DebesandGPT-6.1 Sol 6d8c29cc34 [build-tools] Split agent-device and simulator preview logs (#4585)
* [build-tools] Separate agent-device and preview logs

* [build-tools] Link agent-device changelog entry

Co-Authored-By: GPT-6.1 Sol <[email protected]>

* [build-tools] Drain preview encoder setup before ending logs

* [build-tools] Move preview encoder scripts into test fixtures

* [build-tools] Keep encoder shutdown tests focused

---------

Co-authored-by: GPT-6.1 Sol <[email protected]>
2026-10-14 22:09:29 -07:00
Gabe Debes 2630625440 [build-tools] Stream preview output into simulator session step (#4525) 2026-10-14 20:34:18 -07:00
Gabe Debes 7ebd9f1a97 [build-tools] Avoid cold runtime scans with preinstalled WDA (#4618)
* [build-tools] Launch preinstalled WDA in iOS remote sessions

Edited-by: Codex

* [build-tools] Add preinstalled WDA changelog entry

Edited-by: Codex

* [build-tools] Bound the WDA downloader process tree

* [build-tools] Move WDA startup release note to a changeset
2026-10-14 15:30:48 -07:00
Douglas LowderandClaude Opus 5.5 8545feb433 [ci] Version packages and generate changelogs with changesets (#4580)
* [ci] Version packages and generate changelogs with changesets

PRs now add a changeset in .changeset/ instead of editing CHANGELOG.md.
The manual release workflow runs scripts/src/versionPackages.ts, which
runs `changeset version` for one fixed group of all packages, writes the
root CHANGELOG.md section with the existing emoji categories, and then
commits, tags, and pushes vX.Y.Z. The tag-driven publish is unchanged,
except that it reads the changelog section from the tagged commit.

The PR check validates changesets with @changesets/parse and rejects
direct CHANGELOG.md edits. The "no changelog" label still skips it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Correct "no changelog" documentation in .changeset/README.md

* [ci] Fail on CHANGELOG.md edits even with the "no changelog" label

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* [ci] Skip the changeset check for Dependabot PRs

Dependabot PRs only update dependencies, so they need no changeset, and
their read-only token cannot post the PR comment. The job skips both for
them. The CHANGELOG.md check still runs. The workflow now sets explicit
token permissions.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Remove the "## main" block from CHANGELOG.md

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 14:57:15 -07:00
Expo CI 14e14e3017 bump STAGING and PRODUCTION to 24.14.0 in cli-versions.json 2026-10-14 21:18:01 +00:00
Expo CI 338d367282 update CHANGELOG.md for v24.14.0 2026-10-14 21:10:57 +00:00
Expo CI 102f06cc50 v24.14.0 2026-10-14 21:08:28 +00:00
Gabe Debes f96c266645 [build-tools] Allow iOS recordings to finish before shutdown (#4626) 2026-10-14 13:19:11 -07:00
Tomasz Mazur 5739556d67 [build-tools] download the upterm binary through build cache (#4442) 2026-10-14 14:20:52 +00:00
Ash Wu 821dcd1a96 [eas-cli] add a platform filter and per-platform totals to workflow:insights:maestro (#4613)
* [eas-cli] regenerate GraphQL types

* [eas-cli] add a platform filter and per-platform totals to workflow:insights:maestro
2026-10-14 16:36:04 +08:00
Byron Karlen 3b59a744da [docs] Add protected resources page (#4616) 2026-10-13 15:56:16 -07:00
Szymon DziedzicandClaude Opus 5.5 73edec4d68 [build-tools] Disable Argent video watermark in remote sessions (#4574)
* [build-tools] Disable Argent video watermark in remote sessions

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* [build-tools] Add changelog entry for Argent watermark change

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* [build-tools] Continue the Argent session when disabling the video watermark fails

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 16:05:03 +02:00
Stanisław Chmiela 13fe586f3f [build-tools] Add native Google Play submission function (#4512)
# Why

To remove submission service altogether we also need to support Google Play uploads here.

# How

Adds `eas/submit_to_google_play`: load the service account, create an edit, upload the APK/AAB, update the track, and commit. Returns `version_code` after the commit succeeds.

The binary, its type, and package name come from the caller. We'll use `eas/download_build` and `eas/read_android_app_info` for that.

# Test Plan

- CI should pass.
- Full live submission passed on October 8 with current source `5915d062d`: uploaded an AAB for `com.exposervices.workflowstesting` to `internal` as draft version `146`. The default review setting worked without a retry. A fresh edit confirmed the draft and release notes. Temporary credentials and binaries were removed.
2026-10-13 15:40:19 +02:00
Stanisław Chmiela c3413315d1 [build-tools] Add resumable Google Play uploads (#4516)
# Why

When uploading files to Google Play it's best we do this thing called "resumable uploads".

# How

Added more utils and a separate namespace that handles:
- creating the upload session
- uploading to the upload session in chunks.

It tries to be smart about errors and retries. Mostly AI-driven code.

# Test Plan

CI should pass. Also tested live with staging key.
2026-10-13 12:23:56 +02:00
Kudo Chien 6c256fd9d2 [build-tools] Open Android URLs in the app package instead of the launch activity (#4559) 2026-10-13 13:10:46 +08:00
Expo CI 6683492955 bump STAGING and PRODUCTION to 24.13.0 in cli-versions.json 2026-10-12 17:49:43 +00:00
Expo CI 964b4ed93f update CHANGELOG.md for v24.13.0 2026-10-12 17:47:09 +00:00
238 changed files with 15460 additions and 9546 deletions

No files matched your search

+39
View File
@@ -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.
+11
View File
@@ -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;
+50
View File
@@ -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;
+32
View File
@@ -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"
}
+1
View File
@@ -0,0 +1 @@
export declare function formatEntry(summary: string, attribution: string): string;
+23
View File
@@ -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');
}
+10 -7
View File
@@ -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
+1 -1
View File
@@ -24,7 +24,7 @@ re-review the code yourself. Your job is to consolidate and decide.
WIP, or slated for removal. Hold the house anchors firm: a logged/printed/persisted
secret or command injection is `critical`; an exit-code regression, a
`--json`/`--non-interactive` contract violation, a `SystemError`/`UserError`
mis-billing, or a missing/malformed 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
+2 -2
View File
@@ -81,7 +81,7 @@ House-calibrated anchors for this repo (use these to place borderline cases):
- **warning** — an exit-code regression (a failure that doesn't propagate, so CI/scripts
see success); a `--json`/`--non-interactive` contract violation (breaks scripting);
a `SystemError`/`UserError` misclassification (mis-bills the customer); a
missing/malformed 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 -1
View File
@@ -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
+42 -18
View File
@@ -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.
+6 -38
View File
@@ -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
+5
View File
@@ -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
-2
View File
@@ -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
+3 -13
View File
@@ -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
+62 -4
View File
@@ -2,10 +2,70 @@
This is the log of notable changes to EAS CLI and related packages.
## main
## [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))
@@ -26,8 +86,6 @@ This is the log of notable changes to EAS CLI and related packages.
- [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))
### 🧹 Chores
## [24.12.1](https://github.com/expo/eas-cli/releases/tag/v24.12.1) - 2026-10-08
### 🐛 Bug fixes
@@ -48,6 +106,7 @@ This is the log of notable changes to EAS CLI and related packages.
- [build-tools] Improve the embedded bundle upload output in build logs. ([#4557](https://github.com/expo/eas-cli/pull/4557) by [@gwdp](https://github.com/gwdp))
- [build-tools] Allow the unnotarized mitmproxy cask to run in iOS network capture sessions. ([#4556](https://github.com/expo/eas-cli/pull/4556) by [@gwdp](https://github.com/gwdp))
- [build-tools] Encode macOS simulator session previews with cwebp, because Homebrew's FFmpeg cannot encode WebP. ([#4549](https://github.com/expo/eas-cli/pull/4549) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Open Android URLs with a VIEW intent instead of pinning them to the launch activity. ([#4559](https://github.com/expo/eas-cli/pull/4559) by [@kudo](https://github.com/kudo))
### 🧹 Chores
@@ -255,7 +314,6 @@ This is the log of notable changes to EAS CLI and related packages.
### 🐛 Bug fixes
- [build-tools] Report a system error when project source refresh fails for a remote build. ([#4359](https://github.com/expo/eas-cli/pull/4359) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Remove the obsolete `appVersion` runtime policy warning that referenced SDK 46 and expo-updates 0.14.4. ([#4375](https://github.com/expo/eas-cli/pull/4375) by [@brentvatne](https://github.com/brentvatne))
## [24.0.0](https://github.com/expo/eas-cli/releases/tag/v24.0.0) - 2026-09-09
+7 -6
View File
@@ -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
View File
@@ -48,7 +48,7 @@ yarn start-allow-unused
## Format
- Be sure to update the [`CHANGELOG.md`](./CHANGELOG.md) with changes for every PR. There is a changelog bot that can generate the proper entry for you. The instructions on how to use it are in the PR description placeholder.
- Add a changeset for every PR that changes a package: run `yarn changeset` and commit the new file. Do not edit `CHANGELOG.md`; the release generates it. See [`.changeset/README.md`](./.changeset/README.md).
- End `async` functions with `Async` like `runAsync`. This is just how we format functions at Expo.
- Utilize the unified `Log` module instead of `console.log`.
+26 -7
View File
@@ -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
View File
@@ -1,4 +1,4 @@
{
"STAGING": "24.12.1",
"PRODUCTION": "24.12.1"
"STAGING": "25.0.0",
"PRODUCTION": "25.0.0"
}
+2 -2
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "24.13.0",
"version": "25.1.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
}
+4 -2
View File
@@ -15,12 +15,14 @@
"lint": "oxlint --type-aware --import-plugin --max-warnings=0 --tsconfig ./tsconfig.oxlint.json .",
"fmt": "oxfmt .",
"fmt:check": "oxfmt --check .",
"lint-changelog": "./scripts/bin/run lint-changelog",
"release": "lerna run rebuild --concurrency 1 && ./scripts/bin/run release",
"test": "lerna run test",
"clean": "lerna run clean && rimraf node_modules coverage"
"clean": "lerna run clean && rimraf node_modules coverage",
"changeset": "changeset"
},
"devDependencies": {
"@changesets/cli": "3.0.3",
"@changesets/get-github-info": "1.0.1",
"@types/jest": "29.5.11",
"@types/node": "22.20.5",
"eslint-plugin-async-protect": "3.1.0",
+33
View File
@@ -0,0 +1,33 @@
# @expo/build-tools
## 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))
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "24.13.0",
"version": "25.1.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -41,17 +41,17 @@
"dependencies": {
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "24.13.0",
"@expo/eas-build-job": "24.13.0",
"@expo/downloader": "25.1.0",
"@expo/eas-build-job": "25.1.0",
"@expo/env": "^0.4.0",
"@expo/logger": "24.13.0",
"@expo/logger": "25.1.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.13.0",
"@expo/template-file": "24.13.0",
"@expo/turtle-spawn": "24.13.0",
"@expo/steps": "25.1.0",
"@expo/template-file": "25.1.0",
"@expo/turtle-spawn": "25.1.0",
"@expo/xcpretty": "^4.3.1",
"@ngrok/ngrok": "1.7.0",
"@sentry/node": "7.77.0",
@@ -71,6 +71,17 @@ by a coverage report from `lsof`, listing any process without the library. Both
`packages/worker/package.sh` for the iOS worker tarball and by the
`test-egress-guard` EAS workflow, not committed.
For serve-sim-owned iOS session startup, build-tools passes the guard through
`SERVE_SIM_ADDITIONAL_DYLIBS` and its configuration and proxy variables through
inherited `SIMCTL_CHILD_` variables. It shuts down an already booted selected
device so every process inherits the guard. Serve-sim boots that device while
build-tools prepares the app. After serve-sim's control endpoint is available,
build-tools runs the packaged guard self-check before requesting installation
and launch through the authenticated host API. Preview readiness and application
startup both complete before reporting the remote session live. Build-tools keeps
the event relay and egress cleanup without rewriting launchd's dylib list after
serve-sim adds its capability loader.
Failure semantics: a missing library, a failed `launchctl setenv`, or a failed
self-check fails the session, since a `--egress local` session without the
guard would silently leak. Only an unwritable event log is a warning, because
@@ -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(
+3
View File
@@ -69,6 +69,7 @@ export interface BuildContextOptions {
metadata?: Metadata;
expoApiV2BaseUrl?: string;
mcpServerUrl?: string;
addBuildLogMask?: (secret: string) => void;
}
export class SkipNativeBuildError extends Error {}
@@ -86,6 +87,7 @@ export class BuildContext<TJob extends Job = Job> {
public readonly skipNativeBuild?: boolean;
public readonly expoApiV2BaseUrl?: string;
public readonly mcpServerUrl?: string;
public readonly addBuildLogMask?: (secret: string) => void;
public artifacts: Artifacts = {};
private readonly _isLocal: boolean;
@@ -115,6 +117,7 @@ export class BuildContext<TJob extends Job = Job> {
this.skipNativeBuild = options.skipNativeBuild;
this.expoApiV2BaseUrl = options.expoApiV2BaseUrl;
this.mcpServerUrl = options.mcpServerUrl;
this.addBuildLogMask = options.addBuildLogMask;
const environmentSecrets = this.getEnvironmentSecrets(job);
this._env = {
@@ -32,6 +32,7 @@ export interface BuilderRuntimeApi {
uploadArtifact: (spec: { artifact: ArtifactToUpload; logger: bunyan }) => Promise<{
artifactId: string | null;
}>;
addBuildLogMask?: (secret: string) => void;
}
export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuildContextProvider {
@@ -84,6 +85,7 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
this.buildLogsDirectory = path.join(buildCtx.workingdir, 'logs');
this.runtimeApi = {
uploadArtifact: (...args) => buildCtx['uploadArtifact'](...args),
addBuildLogMask: buildCtx.addBuildLogMask,
};
this.mcpServerUrl = buildCtx.mcpServerUrl;
this.expoApiV2BaseUrl = buildCtx.expoApiV2BaseUrl;
+42 -15
View File
@@ -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;
+1
View File
@@ -33,5 +33,6 @@ export type { GradleProfileTask } from './android/gradleProfile';
export * from './generic';
export { Datadog } from './datadog';
export { getDeviceRunSessionTokenFromEnvironmentSecrets } from './steps/utils/deviceRunSessionWorkerApi';
export { Sentry } from './sentry';
export * from './runtimeSettings';
@@ -11,7 +11,7 @@ import { getEasFunctions } from '../easFunctions';
jest.mock('fs');
describe('hook anchor self-declarations', () => {
it('exactly the four v0 functions declare anchors, with these bindings', () => {
it('declares the expected hook anchors', () => {
const declarations: Record<string, HookAnchorId> = {};
for (const buildFunction of getEasFunctions(createCustomBuildContextMock())) {
if (buildFunction.__hookId !== undefined) {
@@ -24,6 +24,7 @@ describe('hook anchor self-declarations', () => {
'eas/checkout': 'checkout',
'eas/install_node_modules': 'install_node_modules',
'eas/upload_to_asc': 'submit',
'eas/submit_to_google_play': 'submit',
'eas/maestro_tests': 'maestro_tests',
});
});
@@ -87,6 +88,17 @@ describe('hook anchor self-declarations', () => {
bundle_short_version: '1.0.0',
},
],
[
'eas/submit_to_google_play',
'before_submit',
'after_submit',
{
artifact_path: 'app.aab',
artifact_type: 'aab',
package_name: 'com.example.app',
service_account_key_path: 'key.json',
},
],
[
'eas/maestro_tests',
'before_maestro_tests',
@@ -29,6 +29,7 @@ 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';
@@ -141,6 +142,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
calculateEASUpdateRuntimeVersionFunction(),
createSubmissionEntityFunction(),
createSubmitToGooglePlayBuildFunction(),
createUploadToAscBuildFunction(),
createUpdateTestFlightMetadataBuildFunction(),
@@ -108,7 +108,11 @@ describe.each([
['Appium', createStartAppiumRemoteSessionBuildFunction],
['Agent Device', createStartAgentDeviceSessionBuildFunction],
] as const)('%s host ownership', (name, createFunction) => {
async function runAsync() {
async function runAsync(
publicPreview?: boolean,
frameAncestors?: string[],
corsOrigins?: string[]
) {
const fn = createFunction({} as CustomBuildContext);
await fn.fn!(
{
@@ -117,9 +121,16 @@ describe.each([
} as unknown as BuildStepContext,
{
// Inputs a step does not get default to undefined, like in a real step call.
inputs: new Proxy({} as Record<string, { value: unknown }>, {
get: (target, id: string) => target[id] ?? { value: undefined },
}),
inputs: new Proxy(
{
public_preview: { value: publicPreview },
frame_ancestors: { value: frameAncestors },
cors_origins: { value: corsOrigins },
} as Record<string, { value: unknown }>,
{
get: (target, id: string) => target[id] ?? { value: undefined },
}
),
outputs: {},
env: {
DEVICE_RUN_SESSION_ID: 'session-id',
@@ -132,7 +143,7 @@ describe.each([
it('holds the host until session completion', async () => {
await runAsync();
expect(openPreview).toHaveBeenCalledWith({ baseDomain: 'example.test' });
expect(openPreview).toHaveBeenCalledWith({ baseDomain: 'example.test', authtoken: 'token' });
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: expect.objectContaining({
@@ -151,6 +162,59 @@ describe.each([
expect(stopTunnel).toHaveBeenCalledTimes(1);
});
it('opens tunnels with the ngrok token but keeps it out of spawned processes', async () => {
await runAsync();
expect(startNgrokTunnelAsync).toHaveBeenCalledWith(
expect.objectContaining({ authtoken: 'token' })
);
const spawnedEnvs = [
...jest.mocked(spawn).mock.calls.map(([, , options]) => options?.env),
...jest.mocked(spawnDetached).mock.calls.map(([options]) => options.env),
...jest.mocked(startDeviceSessionHostAsync).mock.calls.map(([, options]) => options.env),
];
expect(spawnedEnvs.length).toBeGreaterThan(0);
for (const env of spawnedEnvs) {
expect(env).toEqual(expect.objectContaining({ DEVICE_RUN_SESSION_ID: 'session-id' }));
expect(env).not.toHaveProperty('NGROK_AUTHTOKEN');
}
});
it.each([undefined, true, false])('maps public_preview=%s to the host', async publicPreview => {
await runAsync(publicPreview);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ disableShare: publicPreview === false })
);
});
it('maps frame_ancestors to the host and defaults to none', async () => {
await runAsync(undefined, ['https://embed.example.test']);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ frameAncestors: ['https://embed.example.test'] })
);
jest.mocked(startDeviceSessionHostAsync).mockClear();
await runAsync();
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ frameAncestors: [] })
);
});
it('maps cors_origins to the host and defaults to none', async () => {
await runAsync(undefined, undefined, ['https://app.example.test']);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ corsOrigins: ['https://app.example.test'] })
);
jest.mocked(startDeviceSessionHostAsync).mockClear();
await runAsync();
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ corsOrigins: [] })
);
});
it('stops automation while recording finalization or upload is pending', async () => {
let release!: () => void;
let stopped!: () => void;
@@ -591,6 +591,100 @@ describe('createDownloadBuildFunction', () => {
expect(graphqlClient.query).not.toHaveBeenCalled();
});
describe('with a device run session token in the job env', () => {
const { Response: ActualResponse } = jest.requireActual(
'node-fetch'
) as typeof import('node-fetch');
const workerApiUrl = 'https://api.expo.test/v2/device-run-session-worker/application';
function createSessionCtx(graphqlClient: Client): any {
return {
graphqlClient,
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'session-token' },
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
};
}
function createStep(ctx: any, callInputs: Record<string, unknown>) {
return createDownloadBuildFunction(ctx).createBuildStepFromFunctionCall(
createGlobalContextMock({
logger: createMockLogger(),
staticContextContent: { job: { secrets: { robotAccessToken: 'session-token' } } },
}),
{ callInputs }
);
}
it('looks up build_id through the worker API and downloads without an Authorization header', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({});
jest.mocked(fetch).mockImplementation(async url => {
if (url === workerApiUrl) {
return new ActualResponse(
JSON.stringify({
data: {
build: {
id: buildId,
gitCommitHash: null,
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
},
},
}),
{ status: 200 }
);
}
return {
ok: false,
text: () => Promise.resolve('Internal Server Error'),
status: 500,
} as unknown as Response;
});
await expect(
createStep(createSessionCtx(graphqlClient), {
build_id: buildId,
extensions: ['app'],
}).executeAsync()
).rejects.toThrow('Internal Server Error');
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
workerApiUrl,
expect.objectContaining({
method: 'GET',
headers: { 'expo-device-run-session-token': 'session-token' },
})
);
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
APPLICATION_ARCHIVE_URL,
expect.objectContaining({ headers: undefined })
);
expect(graphqlClient.query).not.toHaveBeenCalled();
});
it('does not call the worker API for an application_archive_url input', async () => {
const graphqlClient = createMockGraphqlClient({});
jest.mocked(fetch).mockResolvedValue({
ok: false,
text: () => Promise.resolve('Internal Server Error'),
status: 500,
} as unknown as Response);
await expect(
createStep(createSessionCtx(graphqlClient), {
application_archive_url: APPLICATION_ARCHIVE_URL,
extensions: ['app'],
}).executeAsync()
).rejects.toThrow('Internal Server Error');
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(1);
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
APPLICATION_ARCHIVE_URL,
expect.objectContaining({ headers: undefined })
);
expect(graphqlClient.query).not.toHaveBeenCalled();
});
});
it('should download a build', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
@@ -179,17 +179,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(
@@ -242,17 +232,7 @@ describe(launchApplicationAsync, () => {
);
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
[
'shell',
'am',
'start',
'-a',
'android.intent.action.VIEW',
'-d',
'exp://example.test',
'-n',
'com.example.app/com.example.app.MainActivity',
],
['shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', 'exp://example.test'],
expect.any(Object)
);
});
@@ -29,8 +29,8 @@ jest.mock('../../../utils/processes');
jest.mock('../../../utils/turtleFetch');
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/agentDeviceArtifacts');
// The agent-device session step boots the Simulator itself; this test covers local egress.
jest.mock('../startIosSimulator', () => ({
...jest.requireActual('../startIosSimulator'),
bootIosSimulatorAsync: jest
.fn()
.mockResolvedValue({ deviceIdentifier: 'sim', udid: 'sim', displayName: 'iPhone' }),
@@ -25,6 +25,7 @@ const leaseMock = jest.mocked(leaseAgentRunProviderCredentialsAsync);
const ctx = {
expoApiV2BaseUrl: 'https://staging-api.expo.test/v2/',
mcpServerUrl: 'wss://staging-mcp.expo.test',
runtimeApi: { addBuildLogMask: jest.fn() },
} as any;
const inputs = {
agent_kind: { value: 'claude-code' },
@@ -115,6 +116,19 @@ describe(createRunAgentBuildFunction, () => {
expect(agentEnv.HOME).not.toBe('/home/expo');
});
it('hands the build log secrets filter to the agent run', async () => {
const runAgentSpy = jest.spyOn(agentRun, 'runAgentAsync').mockResolvedValue();
try {
await runAsync();
expect(runAgentSpy).toHaveBeenCalledWith(
expect.objectContaining({ addBuildLogMask: ctx.runtimeApi.addBuildLogMask })
);
} finally {
runAgentSpy.mockRestore();
}
});
it.each([
['wss://staging-mcp.expo.test', 'https://staging-mcp.expo.test/mcp'],
['ws://localhost:8787', 'http://localhost:8787/mcp'],
@@ -162,6 +176,7 @@ describe(createRunAgentBuildFunction, () => {
['no PATH', { env: { ...env, PATH: '' } }],
['no Expo API URL', { ctx: { ...ctx, expoApiV2BaseUrl: undefined } }],
['no MCP server URL', { ctx: { ...ctx, mcpServerUrl: undefined } }],
['no build log secrets filter', { ctx: { ...ctx, runtimeApi: {} } }],
])('throws a system error for %s', async (_name, overrides) => {
await expect(runAsync(overrides)).rejects.toBeInstanceOf(SystemError);
@@ -19,6 +19,7 @@ import {
waitForDeviceRunSessionStoppedAsync,
waitForFileAsync,
} from '../../utils/remoteDeviceRunSession';
import { createProcessOutput } from '../../utils/processOutput';
import { type StartupTasks, createStartupTasks } from '../../utils/startupTasks';
import { runAgentDeviceRemoteSessionAsync } from '../startAgentDeviceRemoteSession';
@@ -73,7 +74,9 @@ const NO_CAPTURE: CaptureInputs = { networkCapture: false, networkCaptureFields:
async function runAsync(
logger: { info: jest.Mock; warn: jest.Mock; child: jest.Mock },
runtimePlatform: BuildRuntimePlatform,
capture: CaptureInputs = NO_CAPTURE
capture: CaptureInputs = NO_CAPTURE,
disableShare = false,
privatePreviewUrl?: string
): Promise<void> {
await runAgentDeviceRemoteSessionAsync(ctx, {
env: {},
@@ -88,6 +91,8 @@ async function runAsync(
maxIdleTimeMinutes: undefined,
maxDurationSeconds: undefined,
capture,
disableShare,
privatePreviewUrl,
tasks: createStartupTasks(logger as never),
device: { booted: Promise.resolve(), ready: Promise.resolve() },
});
@@ -163,6 +168,27 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
});
it('redacts learned daemon credentials in arbitrary output and retained diagnostics', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const token = 'daemon secret/+';
jest.mocked(waitForFileAsync).mockResolvedValue({ port: 5678, token });
jest.mocked(startNgrokTunnelAsync).mockImplementation(async () => {
const options = jest.mocked(spawnDetached).mock.calls[0][0];
expect(options.secrets).toContain(token);
const output = createProcessOutput(options.logger, options.secrets);
output.stdout.append('unrecognized value: daemon sec');
output.stdout.append('ret/+\n');
output.stderr.append(`escaped value: ${encodeURIComponent(token)}\n`);
expect(output.getOutput()).toBe(
'unrecognized value: [REDACTED]\nescaped value: [REDACTED]\n'
);
return { url: 'https://daemon.test', subdomainId: 'daemon', stopAsync: mockTunnelStopAsync };
});
await runAsync(logger, BuildRuntimePlatform.DARWIN);
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(token);
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(encodeURIComponent(token));
});
it('hands network capture to serve-sim', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
@@ -177,6 +203,24 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
);
});
it('hands a private preview choice to the session host', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
await runAsync(
logger,
BuildRuntimePlatform.DARWIN,
NO_CAPTURE,
true,
'https://expo.dev/private-preview'
);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
disableShare: true,
privatePreviewUrl: 'https://expo.dev/private-preview',
})
);
});
it('starts the session host without waiting for the agent-device daemon', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const sessionHost = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
@@ -93,6 +93,28 @@ describe(startAgentDeviceDaemonAsync, () => {
}) as never);
});
it.each([false, true])(
'shares the daemon secret list through git fallback=%s',
async fallback => {
if (fallback) {
jest.mocked(spawn).mockResolvedValue({ stdout: '' } as never);
}
const secrets: string[] = [];
const handle = await startAgentDeviceDaemonAsync({
packageVersion: '1.2.3',
env: {},
logger,
secrets,
});
const options = jest.mocked(spawnDetached).mock.calls[0][0];
expect(options.command).toBe(fallback ? 'bun' : 'node');
expect(options.secrets).toBe(secrets);
secrets.push('learned-daemon-token');
expect(options.secrets).toContain('learned-daemon-token');
await handle.stopAsync();
}
);
it('installs with bun add by default and launches the published daemon', async () => {
const handle = await startAgentDeviceDaemonAsync({
packageVersion: '1.2.3',
@@ -108,6 +130,8 @@ describe(startAgentDeviceDaemonAsync, () => {
const addCwd = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
expect(spawnDetached).toHaveBeenCalledWith({
command: 'node',
logger,
secrets: undefined,
args: [path.join(addCwd, 'node_modules/agent-device/dist/src/internal/daemon.js')],
env: expect.objectContaining({
AGENT_DEVICE_DAEMON_SERVER_MODE: 'http',
@@ -121,6 +145,20 @@ describe(startAgentDeviceDaemonAsync, () => {
await expect(fs.promises.access(addCwd)).rejects.toThrow();
});
it('removes the install directory when the daemon cannot be stopped', async () => {
const stopError = new Error('drain timed out');
stopAsync.mockRejectedValueOnce(stopError);
const handle = await startAgentDeviceDaemonAsync({
packageVersion: '1.2.3',
env: {},
logger,
});
const addCwd = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
await expect(handle.stopAsync()).rejects.toBe(stopError);
await expect(fs.promises.access(addCwd)).rejects.toThrow();
});
it('installs with npm when EAS_OVERRIDE_PACKAGE_MANAGER is npm', async () => {
await startAgentDeviceDaemonAsync({
packageVersion: undefined,
@@ -136,10 +174,15 @@ describe(startAgentDeviceDaemonAsync, () => {
});
it('kills the install and does not fall back to git when aborted', async () => {
let installStarted!: () => void;
const started = new Promise<void>(resolve => {
installStarted = resolve;
});
jest.mocked(spawn).mockImplementation(
((_command: string, _args: string[], options?: { signal?: AbortSignal }) =>
new Promise((_resolve, reject) => {
options!.signal!.addEventListener('abort', () => reject(options!.signal!.reason));
installStarted();
})) as never
);
const controller = new AbortController();
@@ -151,7 +194,7 @@ describe(startAgentDeviceDaemonAsync, () => {
logger,
signal: controller.signal,
});
await new Promise(resolve => setImmediate(resolve));
await started;
controller.abort(failure);
await expect(daemon).rejects.toBe(failure);
@@ -199,6 +242,8 @@ describe(startAgentDeviceDaemonAsync, () => {
);
expect(spawnDetached).toHaveBeenCalledWith({
command: 'bun',
logger,
secrets: undefined,
args: ['run', 'src/daemon.ts'],
cwd: '/tmp/agent-device-src',
env: expect.objectContaining({
@@ -2,6 +2,7 @@ import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { DeviceRunSessionWorkerApi } from '../../utils/deviceRunSessionWorkerApi';
import { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession';
import { downloadBuildAsync } from '../downloadBuild';
import { installBuildAsync } from '../installBuild';
@@ -67,9 +68,10 @@ async function flushAsync(): Promise<void> {
function runStep(
runtimePlatform: BuildRuntimePlatform,
inputValues: Record<string, unknown> = {}
inputValues: Record<string, unknown> = {},
stepCtx: CustomBuildContext = ctx
): Promise<void> {
const buildFunction = createStartAgentDeviceSessionBuildFunction(ctx);
const buildFunction = createStartAgentDeviceSessionBuildFunction(stepCtx);
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const inputs = Object.fromEntries(
[
@@ -84,6 +86,10 @@ function runStep(
'open_url',
'network_capture',
'network_capture_fields',
'public_preview',
'private_preview_url',
'frame_ancestors',
'cors_origins',
'package_version',
'max_idle_time_minutes',
'max_duration_seconds',
@@ -178,6 +184,47 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
);
});
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' }, 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' },
sessionCtx
);
expect(downloadBuildAsync).toHaveBeenLastCalledWith(
expect.objectContaining({
applicationArchiveUrl: 'https://example.test/app.tar.gz',
deviceRunSessionWorkerApi: null,
})
);
});
it('waits for the download when the Simulator boots first', async () => {
const download = deferred<{ artifactPath: string }>();
jest.mocked(downloadBuildAsync).mockReturnValue(download.promise);
@@ -370,12 +417,53 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
'open_url',
'network_capture',
'network_capture_fields',
'public_preview',
'private_preview_url',
'frame_ancestors',
'cors_origins',
'package_version',
'max_idle_time_minutes',
'max_duration_seconds',
]);
});
it.each([undefined, true, false])(
'passes public_preview=%s to the iOS session host',
async publicPreview => {
await runStep(BuildRuntimePlatform.DARWIN, {
public_preview: publicPreview,
private_preview_url: 'https://expo.dev/private-preview',
});
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
disableShare: publicPreview === false,
privatePreviewUrl: 'https://expo.dev/private-preview',
})
);
}
);
it('passes frame_ancestors to the session host', async () => {
await runStep(BuildRuntimePlatform.DARWIN, {
frame_ancestors: ['https://embed.example.test'],
});
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ frameAncestors: ['https://embed.example.test'] })
);
});
it('passes cors_origins to the session host', async () => {
await runStep(BuildRuntimePlatform.DARWIN, {
cors_origins: ['https://app.example.test'],
});
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ corsOrigins: ['https://app.example.test'] })
);
});
it('passes network capture to the session', async () => {
await runStep(BuildRuntimePlatform.DARWIN, {
network_capture: true,
@@ -1,4 +1,4 @@
import { BuildRuntimePlatform } from '@expo/steps';
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
@@ -6,7 +6,15 @@ import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession';
import { sleepAsync } from '../../../utils/retry';
import { turtleFetch } from '../../../utils/turtleFetch';
import { startAppiumEventCollectionAsync } from '../../utils/appiumEvents';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import {
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startNgrokTunnelAsync,
} from '../../utils/remoteDeviceRunSession';
import {
createStartAppiumRemoteSessionBuildFunction,
@@ -24,8 +32,26 @@ jest.mock('../../../utils/IosSimulatorUtils', () => ({
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
selectXcodeDeveloperDirectoryAsync: jest.fn(),
spawnDetached: jest.fn(),
startNgrokTunnelAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
}));
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('../../../sentry');
jest.mock('../../../utils/retry', () => ({
...jest.requireActual('../../../utils/retry'),
sleepAsync: jest.fn(),
}));
jest.mock('../../../utils/turtleFetch', () => ({
...jest.requireActual('../../../utils/turtleFetch'),
turtleFetch: jest.fn(),
}));
jest.mock('../../utils/appiumEvents', () => ({ startAppiumEventCollectionAsync: jest.fn() }));
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/localEgressSession', () => ({
...jest.requireActual('../../utils/localEgressSession'),
uploadRemoteSessionConfigWithLocalEgressAsync: jest.fn(),
}));
const logger = { info: jest.fn(), warn: jest.fn() } as never;
@@ -51,9 +77,12 @@ describe(resolveAppiumDeviceAsync, () => {
});
it('returns XCUITest capabilities for the booted iOS simulator', async () => {
jest
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
.mockResolvedValue([{ udid: 'ios-simulator-id' } as never]);
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockResolvedValue([
{
udid: 'ios-simulator-id',
runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5',
} as never,
]);
await expect(
resolveAppiumDeviceAsync({
@@ -66,6 +95,7 @@ describe(resolveAppiumDeviceAsync, () => {
automationName: 'XCUITest',
driverName: 'xcuitest',
udid: 'ios-simulator-id',
runtimeIdentifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5',
});
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledWith({ env: {}, logger });
expect(IosSimulatorUtils.getAvailableDevicesAsync).toHaveBeenCalledWith({
@@ -164,6 +194,126 @@ describe('createStartAppiumRemoteSessionBuildFunction', () => {
expect(
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
).toEqual(
expect.arrayContaining([
'launch_app_identifier',
'launch_args',
'open_url',
'public_preview',
'private_preview_url',
])
);
});
});
describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () => {
const stopError = new Error('Process output drain timed out after 5000ms.');
const stopAppium = jest.fn();
const stopEventCollection = jest.fn();
beforeEach(() => {
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => ({
stdout: args.includes('--json') ? '{}' : '',
})) as never);
jest
.mocked(AndroidEmulatorUtils.getAttachedDevicesAsync)
.mockResolvedValue([{ serialId: 'emulator-5554', state: 'device' } as never]);
stopAppium.mockRejectedValue(stopError);
jest.mocked(spawnDetached).mockReturnValue({
pid: 4242,
getOutput: () => '',
getExitError: () => undefined,
stopAsync: stopAppium,
});
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as never);
stopEventCollection.mockResolvedValue(undefined);
jest.mocked(startAppiumEventCollectionAsync).mockResolvedValue({
stopAsync: stopEventCollection,
getLastEventObservedAt: () => undefined,
});
jest.mocked(startNgrokTunnelAsync).mockResolvedValue({
url: 'https://appium-abc.tunnel.example.com',
subdomainId: 'appium-abc',
stopAsync: jest.fn(),
});
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: jest.fn().mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.tunnel.example.com',
closeAsync: jest.fn(),
}),
finishAsync: jest.fn(),
});
});
afterEach(() => {
jest.restoreAllMocks();
});
async function runSessionAsync(logger: { info: jest.Mock; warn: jest.Mock }): Promise<void> {
await createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
{
logger,
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {
DEVICE_RUN_SESSION_ID: 'device-run-session-id',
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'tunnel.example.com',
NGROK_AUTHTOKEN: 'ngrok-token',
},
} as never
);
}
function expectAppiumStoppedAndHomeRemoved(logger: { warn: jest.Mock }): void {
expect(stopAppium).toHaveBeenCalledTimes(1);
expect(fs.existsSync(jest.mocked(spawnDetached).mock.calls[0][0].env.APPIUM_HOME!)).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(
{ err: stopError },
'Could not stop the Appium server during remote session teardown.'
);
}
it('keeps the readiness error when Appium cannot be stopped', async () => {
let now = Date.now();
jest.spyOn(Date, 'now').mockImplementation(() => now);
jest.mocked(sleepAsync).mockImplementation(async ms => {
now += ms;
});
jest.mocked(turtleFetch).mockRejectedValue(new Error('connect ECONNREFUSED'));
const logger = { info: jest.fn(), warn: jest.fn() };
await expect(runSessionAsync(logger)).rejects.toThrow(
'Timed out waiting for Appium to become ready.'
);
expectAppiumStoppedAndHomeRemoved(logger);
expect(startAppiumEventCollectionAsync).not.toHaveBeenCalled();
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
});
it('keeps the event collection error when Appium cannot be stopped', async () => {
const collectionError = new Error('event collection failed');
jest.mocked(startAppiumEventCollectionAsync).mockRejectedValueOnce(collectionError);
const logger = { info: jest.fn(), warn: jest.fn() };
await expect(runSessionAsync(logger)).rejects.toBe(collectionError);
expectAppiumStoppedAndHomeRemoved(logger);
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
});
it('fails a finished session when Appium cannot be stopped', async () => {
const logger = { info: jest.fn(), warn: jest.fn() };
await expect(runSessionAsync(logger)).rejects.toBe(stopError);
expectAppiumStoppedAndHomeRemoved(logger);
expect(stopEventCollection.mock.invocationCallOrder[0]).toBeLessThan(
stopAppium.mock.invocationCallOrder[0]
);
});
});
@@ -6,10 +6,12 @@ import path from 'node:path';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import { isProcessDescendantOfAsync } from '../../../utils/processes';
import { pollArgentArtifactsForUploadAsync } from '../../utils/argentArtifacts';
import { startArgentEventCollectionAsync } from '../../utils/argentEvents';
import { createProcessOutput } from '../../utils/processOutput';
import {
ensureFfmpegInstalledOnceAsync,
getDeviceRunSessionIdOrThrow,
@@ -119,18 +121,21 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
await fs.promises.rm(TEST_HOME, { recursive: true, force: true });
});
it('reports an early exit with output before opening tunnels or publishing readiness', async () => {
it('reports an early exit with output and stops Argent before opening tunnels', async () => {
const stopError = new Error('drain timed out');
const stopServer = jest.fn().mockRejectedValue(stopError);
jest.mocked(spawnDetached).mockReturnValue({
pid: 4242,
getOutput: () => 'could not bind server port',
getExitError: () => new Error('process exited with code 1'),
stopAsync: jest.fn(),
stopAsync: stopServer,
});
const logger = { info: jest.fn(), warn: jest.fn() };
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
await expect(
buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
logger,
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
@@ -145,11 +150,66 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
).rejects.toThrow(
'Argent exited before becoming ready: process exited with code 1\nArgent tool-server output:\ncould not bind server port'
);
expect(stopServer).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
{ err: stopError },
'Could not stop the Argent tool-server during remote session teardown.'
);
expect(pollArgentArtifactsForUploadAsync).not.toHaveBeenCalled();
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(startArgentEventCollectionAsync).not.toHaveBeenCalled();
});
it('drains artifact polling and stops the tool-server when event collection cannot start', async () => {
const collectionError = new Error('event collection failed');
const stopError = new Error('drain timed out');
let pollingFinished = false;
jest
.mocked(pollArgentArtifactsForUploadAsync)
.mockImplementationOnce(async (_ctx, { signal }) => {
await new Promise(resolve => signal.addEventListener('abort', resolve, { once: true }));
pollingFinished = true;
});
jest.mocked(startArgentEventCollectionAsync).mockRejectedValueOnce(collectionError);
const stopServer = jest.fn(async () => {
expect(pollingFinished).toBe(true);
throw stopError;
});
jest.mocked(spawnDetached).mockReturnValueOnce({
pid: 4242,
getOutput: () => '',
getExitError: () => undefined,
stopAsync: stopServer,
});
const logger = { info: jest.fn(), warn: jest.fn() };
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
await expect(
buildFunction.fn!(
{
logger,
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
)
).rejects.toBe(collectionError);
expect(pollingFinished).toBe(true);
expect(stopServer).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
{ err: stopError },
'Could not stop the Argent tool-server during remote session teardown.'
);
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
});
it.each(['preview', 'config', 'wait'])(
'finishes the host and tools after %s fails',
async phase => {
@@ -188,7 +248,60 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
}
);
it('enables the event log flag, shares one path, and starts/stops the collector', async () => {
it.each([
['succeeded', false],
['failed', true],
])(
'fails after stopping the host and tools when the tools tunnel cannot close and the session %s',
async (_description, sessionFails) => {
const closeError = new Error('ngrok close failed');
const sessionError = new Error('session failed');
mockTunnelStopAsync.mockRejectedValueOnce(closeError);
if (sessionFails) {
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValueOnce(sessionError);
}
const logger = { info: jest.fn(), warn: jest.fn() };
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
// The tunnel may still be serving, so its failure fails a session that otherwise succeeded.
await expect(
buildFunction.fn!(
{
logger,
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
)
).rejects.toBe(sessionFails ? sessionError : closeError);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(mockStopAsync).toHaveBeenCalledTimes(1);
expect(jest.mocked(spawnDetached).mock.results[0].value.stopAsync).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
{ err: closeError },
'Could not stop the Argent tunnel during remote session teardown.'
);
expect(jest.mocked(Sentry.capture).mock.calls).toEqual(
sessionFails
? [
[
'Could not stop the Argent tunnel after the remote session failed',
closeError,
{ level: 'warning' },
],
]
: []
);
}
);
it('enables the event log flag, disables the video watermark, shares one path, and starts/stops the collector', async () => {
const ctx = {} as unknown as CustomBuildContext;
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
@@ -231,6 +344,20 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
jest.mocked(spawnDetached).mock.invocationCallOrder[0]
);
// (2b) The video watermark flag is disabled, before the tool-server is launched.
const disableWatermarkIndex = spawnCalls.findIndex(
([command, args]) =>
command === 'bun' &&
Array.isArray(args) &&
args[0] === 'x' &&
args.includes('disable') &&
args.includes('video-watermark')
);
expect(disableWatermarkIndex).toBeGreaterThanOrEqual(0);
expect(jest.mocked(spawn).mock.invocationCallOrder[disableWatermarkIndex]).toBeLessThan(
jest.mocked(spawnDetached).mock.invocationCallOrder[0]
);
// (3) The tool-server and the collector are pinned to the exact same event log path.
const serverEnv = jest.mocked(spawnDetached).mock.calls[0][0].env;
expect(serverEnv.ARGENT_EVENT_LOG).toBe(EXPECTED_EVENT_LOG_PATH);
@@ -254,7 +381,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX, disableShare: false })
);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
@@ -267,6 +394,165 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
});
it('passes public_preview=false to the session host', async () => {
const ctx = {} as CustomBuildContext;
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
await buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
public_preview: { value: false },
private_preview_url: { value: 'https://expo.dev/private-preview' },
},
outputs: {},
env: {},
} as never
);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
disableShare: true,
privatePreviewUrl: 'https://expo.dev/private-preview',
})
);
});
it('passes frame_ancestors to the session host', async () => {
const ctx = {} as CustomBuildContext;
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
await buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
frame_ancestors: { value: ['https://embed.example.test'] },
},
outputs: {},
env: {},
} as never
);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ frameAncestors: ['https://embed.example.test'] })
);
});
it('passes cors_origins to the session host', async () => {
const ctx = {} as CustomBuildContext;
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
await buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
cors_origins: { value: ['https://app.example.test'] },
},
outputs: {},
env: {},
} as never
);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ corsOrigins: ['https://app.example.test'] })
);
});
it('continues the session when disabling the video watermark fails', async () => {
const error = new Error('bun x failed');
jest
.mocked(spawn)
.mockImplementation(
(_command, args) =>
(args.includes('video-watermark')
? Promise.reject(error)
: Promise.resolve(undefined)) as never
);
const logger = { info: jest.fn(), warn: jest.fn() };
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
await buildFunction.fn!(
{
logger,
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
);
expect(logger.warn).toHaveBeenCalledWith(
{ err: error },
expect.stringContaining('Could not disable the Argent video watermark')
);
expect(spawnDetached).toHaveBeenCalledTimes(1);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledTimes(1);
});
it('redacts startup credentials and registers the state token for later output', async () => {
const token = 'tool/server "secret"';
await fs.promises.writeFile(
path.join(ARGENT_STATE_DIR, 'tool-server-orchestration.json'),
JSON.stringify({ port: 5678, pid: 9999, token })
);
const logger = { info: jest.fn(), warn: jest.fn() };
jest.mocked(spawnDetached).mockImplementationOnce(options => {
const output = createProcessOutput(options.logger, options.secrets);
output.stdout.append(`argent link argent://${encodeURIComponent(token)}@127.0.0.1:5678\n`);
return {
pid: 4242,
getOutput: output.getOutput,
getExitError: () => undefined,
stopAsync: async () => {
output.stderr.append(`opaque ${token}\nencoded ${encodeURIComponent(token)}\n`);
output.stderr.append(`escaped ${JSON.stringify(token).slice(1, -1)}\n`);
output.finish();
},
};
});
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
await buildFunction.fn!(
{
logger,
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
);
expect(logger.info).toHaveBeenCalledWith(
{ source: 'stdout' },
'argent link argent://[REDACTED]@127.0.0.1:5678'
);
for (const label of ['opaque', 'encoded', 'escaped']) {
expect(logger.info).toHaveBeenCalledWith({ source: 'stderr' }, `${label} [REDACTED]`);
}
expect(JSON.stringify(logger.info.mock.calls)).not.toContain('secret');
});
it('hands the launch inputs to serve-sim and announces them on an iOS session', async () => {
const ctx = {} as unknown as CustomBuildContext;
const logger = { info: jest.fn(), warn: jest.fn() };
@@ -339,7 +625,15 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
expect(
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
).toEqual(
expect.arrayContaining([
'launch_app_identifier',
'launch_args',
'open_url',
'public_preview',
'private_preview_url',
])
);
});
it('stops automation without waiting for recording upload', async () => {
@@ -32,6 +32,7 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
startNgrokTunnelAsync: jest.fn(),
getNgrokAuthtokenOrThrow: () => 'ngrok-token',
getNgrokTunnelDomainOrThrow: () => 'example.com',
omitNgrokAuthtoken: jest.requireActual('../../utils/remoteDeviceRunSession').omitNgrokAuthtoken,
}));
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
@@ -3,6 +3,7 @@ import { BuildRuntimePlatform, type BuildStepContext, type BuildStepEnv } from '
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import {
getDeviceRunSessionIdOrThrow,
@@ -14,6 +15,22 @@ import {
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../../utils/IosSimulatorUtils', () => ({
IosSimulatorUtils: {
resolveUdidAsync: jest.fn().mockResolvedValue('selected-ios-udid'),
getAvailableDevicesAsync: jest
.fn()
.mockResolvedValue([{ name: 'iPhone 17', udid: 'selected-ios-udid' }]),
},
}));
jest.mock('../../utils/iosAppArtifact', () => ({
...jest.requireActual('../../utils/iosAppArtifact'),
readIosApplicationIdentifierAsync: jest.fn().mockResolvedValue('dev.example.app'),
}));
jest.mock('../../utils/localEgressGuard', () => ({
resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn().mockResolvedValue(null),
}));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
getDeviceRunSessionIdOrThrow: jest.fn(),
@@ -24,7 +41,8 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
}));
const ctx = {} as CustomBuildContext;
const env = {} as BuildStepEnv;
const env = { NGROK_AUTHTOKEN: 'ngrok-token', PATH: '/usr/bin' } as BuildStepEnv;
const childEnv = { PATH: '/usr/bin' } as BuildStepEnv;
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const stopAsync = jest.fn();
@@ -59,6 +77,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: jest.fn().mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
sharePageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.example.test',
closeAsync: jest.fn(),
}),
@@ -73,6 +92,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: jest.fn().mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
sharePageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.example.test',
previewToken: 'tok-1',
closeAsync: jest.fn(),
@@ -95,6 +115,54 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
);
});
it('logs the authenticated link while publishing the public frame URL', async () => {
const privatePreviewUrl = 'https://expo.dev/private-preview';
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
openPreviewAsync: jest.fn().mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
sharePageUrl: privatePreviewUrl,
apiUrl: 'https://web-preview.example.test',
closeAsync: jest.fn(),
}),
finishAsync: stopAsync,
});
await runAsync(BuildRuntimePlatform.DARWIN, {
public_preview: { value: false },
private_preview_url: { value: privatePreviewUrl },
});
expect(logger.info).toHaveBeenCalledWith(
`Preview URL: ${privatePreviewUrl} (server: https://web-preview.example.test).`
);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: expect.objectContaining({
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
}),
})
);
});
it('does not log the public link when a private job lacks its authenticated URL', async () => {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
openPreviewAsync: jest.fn().mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.example.test',
closeAsync: jest.fn(),
}),
finishAsync: stopAsync,
});
await runAsync(BuildRuntimePlatform.DARWIN, { public_preview: { value: false } });
expect(logger.info).toHaveBeenCalledWith('Private preview URL unavailable for this session.');
expect(jest.mocked(logger.info).mock.calls).not.toEqual(
expect.arrayContaining([
expect.arrayContaining([expect.stringContaining('/simulator-preview/preview-id')]),
])
);
});
it.each(['preview', 'config', 'wait'])('finishes the session after %s fails', async phase => {
const error = new Error(`${phase} failed`);
if (phase === 'preview') {
@@ -115,21 +183,46 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
[BuildRuntimePlatform.DARWIN, true],
[BuildRuntimePlatform.LINUX, false],
])('starts the web preview for %s', async (runtimePlatform, selectsXcode) => {
await runAsync(runtimePlatform);
await runAsync(runtimePlatform, selectsXcode ? { boot_simulator: { value: true } } : {});
expect(
jest
.mocked(logger.info)
.mock.calls.filter(
([message]) =>
message ===
'Preview URL: https://expo.dev/simulator-preview/preview-id (server: https://web-preview.example.test).'
)
).toHaveLength(1);
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledTimes(selectsXcode ? 1 : 0);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(ctx, {
runtimePlatform,
env,
env: childEnv,
logger,
timeoutMs: 60_000,
signal: undefined,
...(selectsXcode
? {
iosSimulatorUdid: 'SELECTED-IOS-UDID',
installAppPath: undefined,
bootEnv: undefined,
}
: {}),
packageVersion: '1.2.3',
disableShare: false,
frameAncestors: [],
corsOrigins: [],
launchAppIdentifier: undefined,
launchArgs: [],
openUrl: undefined,
networkCapture: false,
networkCaptureFields: [],
});
const host = await jest.mocked(startDeviceSessionHostAsync).mock.results[0].value;
expect(host.openPreviewAsync).toHaveBeenCalledWith({
baseDomain: 'tunnel.example.com',
authtoken: 'ngrok-token',
});
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
ctx,
deviceRunSessionId: 'device-run-session-id',
@@ -160,8 +253,15 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
'launch_app_identifier',
'launch_args',
'open_url',
'boot_simulator',
'device_identifier',
'install_app_path',
'network_capture',
'network_capture_fields',
'public_preview',
'private_preview_url',
'frame_ancestors',
'cors_origins',
'package_version',
'max_duration_seconds',
]);
@@ -188,6 +288,64 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
);
});
it('hands the downloaded app and selected device to serve-sim with initial launch options', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, {
device_identifier: { value: 'chosen-device' },
install_app_path: { value: '/tmp/App.app' },
launch_args: { value: ['--literal', 'value with spaces'] },
open_url: { value: 'example://home' },
});
expect(
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
).toBeLessThan(jest.mocked(IosSimulatorUtils.resolveUdidAsync).mock.invocationCallOrder[0]);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
iosSimulatorUdid: 'SELECTED-IOS-UDID',
installAppPath: '/tmp/App.app',
launchAppIdentifier: 'dev.example.app',
launchArgs: ['--literal', 'value with spaces'],
openUrl: 'example://home',
})
);
});
it.each([true, false])(
'maps public_preview=%s to the native Share option',
async publicPreview => {
await runAsync(BuildRuntimePlatform.DARWIN, {
public_preview: { value: publicPreview },
private_preview_url: { value: 'https://expo.dev/private-preview' },
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
disableShare: !publicPreview,
privatePreviewUrl: 'https://expo.dev/private-preview',
})
);
}
);
it('hands frame_ancestors to the session host', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, {
frame_ancestors: { value: ['https://embed.example.test'] },
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ frameAncestors: ['https://embed.example.test'] })
);
});
it('hands cors_origins to the session host', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, {
cors_origins: { value: ['https://app.example.test'] },
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ corsOrigins: ['https://app.example.test'] })
);
});
it('hands network capture to the session host', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, {
network_capture: { value: true },
@@ -0,0 +1,257 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import nock from 'nock';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import {
api,
editPath,
mockToken,
packageName,
serviceAccount,
} from '../../utils/android/__tests__/googlePlayTestUtils';
import { createSubmitToGooglePlayBuildFunction } from '../submitToGooglePlay';
jest.unmock('node-fetch');
jest.mock('promise-retry', () => {
const actual = jest.requireActual('promise-retry');
return (fn: Parameters<typeof actual>[0], options: object) =>
actual(fn, { ...options, minTimeout: 1, maxTimeout: 1, randomize: false });
});
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
let directory: string;
let keyPath: string;
let artifactPath: string;
const session = '/upload/test-session';
const createPath = `/androidpublisher/v3/applications/${packageName}/edits`;
beforeEach(async () => {
nock.disableNetConnect();
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'play-submit-test-'));
keyPath = path.join(directory, 'key.json');
artifactPath = path.join(directory, 'app.aab');
await fs.writeFile(artifactPath, 'binary');
await fs.writeFile(
keyPath,
JSON.stringify({
...serviceAccount,
type: 'service_account',
private_key: serviceAccount.private_key.export({ format: 'pem', type: 'pkcs8' }),
})
);
});
afterEach(async () => {
const pending = nock.pendingMocks();
nock.cleanAll();
nock.enableNetConnect();
await fs.rm(directory, { recursive: true, force: true });
expect(pending).toEqual([]);
});
function submission(extra: Record<string, unknown> = {}, signal?: AbortSignal) {
const fn = createSubmitToGooglePlayBuildFunction();
const step = fn.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs: {
artifact_path: artifactPath,
artifact_type: 'aab',
package_name: packageName,
service_account_key_path: keyPath,
...extra,
},
});
const setters = step.outputs.map(output => jest.spyOn(output, 'set'));
return {
setters,
step,
run: async () =>
await fn.fn!(step.ctx, {
inputs: Object.fromEntries(
step.inputs!.map(input => [input.id, { value: input.rawValue }])
),
outputs: Object.fromEntries(step.outputs.map(output => [output.id, output])),
env: {},
signal,
}),
};
}
function upload() {
mockToken();
api().post(createPath).reply(200, { id: 'edit' });
api()
.post(`/upload${editPath}/bundles`)
.query({ uploadType: 'resumable' })
.reply(200, '', { Location: `https://androidpublisher.googleapis.com${session}` });
nock('https://androidpublisher.googleapis.com').put(session).reply(200, { versionCode: 145 });
}
it.each([
['completed', undefined, { status: 'completed' }],
['completed', 0, 'invalid'],
['completed', 0.25, { status: 'inProgress', userFraction: 0.25 }],
['completed', 1, { status: 'completed' }],
['draft', undefined, { status: 'draft' }],
['draft', 0, 'invalid'],
['draft', 0.25, 'invalid'],
['draft', 1, 'invalid'],
['halted', undefined, 'invalid'],
['halted', 0, 'invalid'],
['halted', 0.25, 'invalid'],
['halted', 1, 'invalid'],
['inProgress', undefined, 'invalid'],
['inProgress', 0, 'invalid'],
['inProgress', 0.25, { status: 'inProgress', userFraction: 0.25 }],
['inProgress', 1, { status: 'completed' }],
] as const)('validates and submits %s with rollout %s', async (status, rollout, expected) => {
const task = submission({
release_status: status,
...(rollout !== undefined ? { rollout } : {}),
});
if (expected === 'invalid') {
await expect(task.run()).rejects.toThrow(status === 'halted' ? /release_status/ : /rollout/);
expect(task.setters.every(set => set.mock.calls.length === 0)).toBe(true);
return;
}
upload();
api()
.put(`${editPath}/tracks/internal`, {
track: 'internal',
releases: [{ ...expected, versionCodes: ['145'] }],
})
.reply(200, { track: 'internal' });
api()
.post(`${editPath}:commit`)
.query({})
.reply(() => {
expect(task.setters.every(set => set.mock.calls.length === 0)).toBe(true);
return [200, { id: 'edit' }];
});
await task.run();
expect(task.setters.map(set => set.mock.calls)).toEqual([[['145']]]);
});
it('runs the step with a string rollout and validates its required output', async () => {
upload();
api()
.put(`${editPath}/tracks/internal`, {
track: 'internal',
releases: [{ status: 'inProgress', userFraction: 0.25, versionCodes: ['145'] }],
})
.reply(200, { track: 'internal' });
api().post(`${editPath}:commit`).query({}).reply(200, { id: 'edit' });
const { step } = submission({ rollout: '0.25' });
expect(step.__hookId).toBe('submit');
await step.executeAsync();
expect(step.outputs[0].value).toBe('145');
});
it.each(['upload', 'track', 'abort'] as const)(
'deletes the edit after %s failure and leaves outputs unset',
async failure => {
const controller = new AbortController();
const task = submission({}, controller.signal);
mockToken();
api().post(createPath).reply(200, { id: 'edit' });
api()
.post(`/upload${editPath}/bundles`)
.query({ uploadType: 'resumable' })
.reply(() => {
if (failure === 'abort') {
controller.abort(new Error('step timed out'));
}
return failure === 'upload'
? [400, { error: { message: 'rejected binary' } }]
: [200, '', { Location: `https://androidpublisher.googleapis.com${session}` }];
});
if (failure === 'track') {
nock('https://androidpublisher.googleapis.com').put(session).reply(200, { versionCode: 145 });
api()
.put(`${editPath}/tracks/internal`)
.reply(400, { error: { message: 'unknown track' } });
}
api().delete(editPath).reply(204);
await expect(task.run()).rejects.toThrow();
expect(task.setters.every(set => set.mock.calls.length === 0)).toBe(true);
}
);
it('preserves the original failure when cleanup fails', async () => {
upload();
api()
.put(`${editPath}/tracks/internal`)
.reply(400, { error: { message: 'unknown track' } });
api().delete(editPath).times(6).reply(500);
await expect(submission().run()).rejects.toMatchObject({
cause: expect.objectContaining({ apiMessage: 'unknown track' }),
});
});
it('does not delete the edit or publish outputs when commit outcome is unknown', async () => {
upload();
api().put(`${editPath}/tracks/internal`).reply(200, { track: 'internal' });
api().post(`${editPath}:commit`).query(true).reply(503);
api().post(`${editPath}:commit`).query(true).reply(404);
let deleted = false;
api()
.delete(editPath)
.optionally()
.reply(() => {
deleted = true;
return [204];
});
const task = submission();
await expect(task.run()).rejects.toBeInstanceOf(SystemError);
expect(deleted).toBe(false);
expect(task.setters.every(set => set.mock.calls.length === 0)).toBe(true);
});
it('reports a missing service-account file with its path', async () => {
const missing = path.join(directory, 'missing.json');
await expect(submission({ service_account_key_path: missing }).run()).rejects.toThrow(
`Service-account key file not found at ${missing}`
);
});
it('does not expose invalid credential contents', async () => {
await fs.writeFile(keyPath, 'PRIVATE_SECRET_INVALID_JSON');
const error = await submission()
.run()
.catch(error => error);
expect(error).toBeInstanceOf(UserError);
expect(JSON.stringify(error) + String(error)).not.toContain('PRIVATE_SECRET');
});
it('preserves the cause when the service-account file cannot be read', async () => {
const cause = Object.assign(new Error('permission denied'), { code: 'EACCES' });
const read = jest.spyOn(fs, 'readFile').mockRejectedValueOnce(cause);
try {
await expect(submission().run()).rejects.toMatchObject({ cause });
} finally {
read.mockRestore();
}
});
it('logs a formatted rollout and confirms the track update before committing', async () => {
upload();
const task = submission({ rollout: 0.29 });
const info = jest.spyOn(task.step.ctx.logger, 'info');
const updatedMessage = 'Updated Google Play track "internal" in the edit.';
let loggedBeforeUpdate = false;
let messagesBeforeCommit: unknown[] = [];
api()
.put(`${editPath}/tracks/internal`)
.reply(() => {
loggedBeforeUpdate = info.mock.calls.some(([message]) => message === updatedMessage);
return [200, { track: 'internal' }];
});
api()
.post(`${editPath}:commit`)
.query({})
.reply(() => {
messagesBeforeCommit = info.mock.calls.map(([message]) => message);
return [200, { id: 'edit' }];
});
await task.run();
expect(info).toHaveBeenCalledWith('Release status: inProgress. Rollout: 29%.');
expect(loggedBeforeUpdate).toBe(false);
expect(messagesBeforeCommit.slice(-2)).toEqual([
updatedMessage,
'Committing the Google Play edit...',
]);
});
@@ -28,6 +28,10 @@ import { decompressTarAsync, isFileTarGzAsync } from '../../utils/files';
import { graphqlAbortContext } from '../../utils/graphqlAbort';
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
import { pluralize } from '../../utils/strings';
import {
type DeviceRunSessionWorkerApi,
getDeviceRunSessionWorkerApi,
} from '../utils/deviceRunSessionWorkerApi';
const streamPipeline = promisify(stream.pipeline);
@@ -118,6 +122,7 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
logger,
...source,
graphqlClient: ctx.graphqlClient,
deviceRunSessionWorkerApi: buildId ? getDeviceRunSessionWorkerApi(ctx) : null,
robotAccessToken: stepsCtx.global.staticContext.job.secrets?.robotAccessToken ?? null,
extensions,
signal,
@@ -131,13 +136,22 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
async function fetchApplicationArchiveUrlAsync({
buildId,
graphqlClient,
deviceRunSessionWorkerApi,
signal,
}: {
buildId: string;
graphqlClient: Client;
deviceRunSessionWorkerApi?: DeviceRunSessionWorkerApi | null;
signal?: AbortSignal;
}): Promise<string> {
signal?.throwIfAborted();
if (deviceRunSessionWorkerApi) {
return await fetchDeviceRunSessionApplicationArchiveUrlAsync({
buildId,
deviceRunSessionWorkerApi,
signal,
});
}
const result = await graphqlClient
.query(BUILD_BY_ID_QUERY, { buildId }, graphqlAbortContext(signal))
.toPromise();
@@ -163,17 +177,71 @@ async function fetchApplicationArchiveUrlAsync({
return applicationArchiveUrl;
}
/**
* A device run session token can read only the build of its own session, so the build comes from
* `GET /v2/device-run-session-worker/application` instead of the builds GraphQL query.
*
* Only device run session jobs, whose steps www generates from the session, reach this path, so
* every failure here is a system error: neither the request nor `build_id` comes from the user.
*/
async function fetchDeviceRunSessionApplicationArchiveUrlAsync({
buildId,
deviceRunSessionWorkerApi,
signal,
}: {
buildId: string;
deviceRunSessionWorkerApi: DeviceRunSessionWorkerApi;
signal?: AbortSignal;
}): Promise<string> {
let application;
try {
application = await deviceRunSessionWorkerApi.getApplicationAsync({ signal });
} catch (err) {
signal?.throwIfAborted();
throw new SystemError(
`Could not fetch build ${buildId} for this device run session: ${
err instanceof Error ? err.message : String(err)
}`,
{ cause: err }
);
}
signal?.throwIfAborted();
if (application.build?.id !== buildId) {
throw new SystemError(
`Could not fetch build ${buildId}: this device run session runs ${
application.build ? `build ${application.build.id}` : 'no build'
}, and its session token can read only that build. www generates this step from the ` +
'session, so the two should always match. Start a new session, and contact support if ' +
'it happens again.'
);
}
if (!application.build.applicationArchiveUrl) {
throw new UserError(
'EAS_DOWNLOAD_BUILD_NO_APPLICATION_ARCHIVE',
'Build does not have an application archive url'
);
}
return application.build.applicationArchiveUrl;
}
export async function downloadBuildAsync(
params: DownloadBuildSource & {
logger: bunyan;
graphqlClient: Client;
/**
* Set in a device run session job that has a session token. The build lookup then uses the
* device run session worker API instead of GraphQL.
*/
deviceRunSessionWorkerApi?: DeviceRunSessionWorkerApi | null;
robotAccessToken: string | null;
extensions: string[];
/** Stops the build lookup, the request and the file write when aborted, and skips the rest. */
signal?: AbortSignal;
}
): Promise<{ artifactPath: string }> {
const { logger, graphqlClient, robotAccessToken, extensions, signal } = params;
const { logger, graphqlClient, deviceRunSessionWorkerApi, robotAccessToken, extensions, signal } =
params;
let downloadUrl: string;
let headers: { Authorization: string } | undefined;
@@ -188,8 +256,19 @@ export async function downloadBuildAsync(
headers = undefined;
} else if (params.buildId) {
const buildId = z.string().uuid().parse(params.buildId);
downloadUrl = await fetchApplicationArchiveUrlAsync({ buildId, graphqlClient, signal });
headers = robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined;
downloadUrl = await fetchApplicationArchiveUrlAsync({
buildId,
graphqlClient,
deviceRunSessionWorkerApi,
signal,
});
// The archive URL from the device run session worker API points to an artifact route that
// needs no auth. After www stops minting EXPO_TOKEN for session jobs, robotAccessToken is the
// session token, and www rejects it as a bearer credential on every route.
headers =
robotAccessToken && !deviceRunSessionWorkerApi
? { Authorization: `Bearer ${robotAccessToken}` }
: undefined;
} else {
throw new UserError(
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
@@ -118,17 +118,7 @@ export async function launchApplicationAsync({
logger.info(`Opening ${openUrl} in ${applicationIdentifier}.`);
await spawn(
'adb',
[
'shell',
'am',
'start',
'-a',
'android.intent.action.VIEW',
'-d',
openUrl,
'-n',
`${applicationIdentifier}/${activityName}`,
],
['shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', openUrl],
{ env, logger }
);
}
@@ -96,6 +96,9 @@ export function createRunAgentBuildFunction(ctx: CustomBuildContext): BuildFunct
if (!ctx.mcpServerUrl) {
throw new SystemError('MCP server URL is required to run an agent.');
}
if (!ctx.runtimeApi.addBuildLogMask) {
throw new SystemError('A build log secrets filter is required to run an agent.');
}
// The worker config holds the MCP server's WebSocket URL; its HTTP endpoint is on the same host.
const mcpUrl = new URL('/mcp', ctx.mcpServerUrl);
mcpUrl.protocol = mcpUrl.protocol.replace(/^ws/, 'http');
@@ -120,6 +123,7 @@ export function createRunAgentBuildFunction(ctx: CustomBuildContext): BuildFunct
pathEnv,
npmCacheUrl,
logger: stepCtx.logger,
addBuildLogMask: ctx.runtimeApi.addBuildLogMask,
signal: signal
? AbortSignal.any([signal, timeoutController.signal])
: timeoutController.signal,
@@ -96,6 +96,10 @@ export async function runAgentDeviceRemoteSessionAsync(
maxIdleTimeMinutes,
maxDurationSeconds,
capture,
disableShare = false,
privatePreviewUrl,
frameAncestors,
corsOrigins,
tasks,
device,
}: {
@@ -108,6 +112,10 @@ export async function runAgentDeviceRemoteSessionAsync(
maxIdleTimeMinutes: number | undefined;
maxDurationSeconds: number | undefined;
capture: ReturnType<typeof parseNetworkCaptureInputs>;
disableShare?: boolean;
privatePreviewUrl?: string;
frameAncestors?: string[];
corsOrigins?: string[];
tasks: StartupTasks;
device: { booted: Promise<unknown>; ready: Promise<unknown> };
}
@@ -121,6 +129,7 @@ export async function runAgentDeviceRemoteSessionAsync(
let sessionHost: DeviceSessionHost | undefined;
let eventCollection: Awaited<ReturnType<typeof startAgentDeviceEventCollectionAsync>> | undefined;
let sessionFailed = false;
const daemonSecrets = [ngrokAuthtoken];
// Each task stores what it started, so the teardown below can stop it even when
// another task failed first.
@@ -131,12 +140,14 @@ export async function runAgentDeviceRemoteSessionAsync(
env,
logger: taskLogger,
signal: tasks.signal,
secrets: daemonSecrets,
});
taskLogger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
// The daemon is stored above, so the teardown stops it. The wait itself is bounded
// (STARTUP_TIMEOUT_MS) and only reads a file, so it may finish in the background.
const daemonInfo = await tasks.untilAborted(waitForDaemonInfoAsync({ daemonProcess }));
daemonSecrets.push(daemonInfo.token);
taskLogger.info(`Daemon is listening on port ${daemonInfo.port}; loaded auth token.`);
tasks.signal.throwIfAborted();
@@ -155,17 +166,27 @@ export async function runAgentDeviceRemoteSessionAsync(
await tasks.untilAborted(device.booted);
tasks.signal.throwIfAborted();
sessionHost = await startDeviceSessionHostAsync(ctx, {
separateLogPhase: true,
runtimePlatform,
env,
logger: taskLogger,
timeoutMs: STARTUP_TIMEOUT_MS,
disableShare,
privatePreviewUrl,
frameAncestors,
corsOrigins,
networkCapture: capture.networkCapture,
networkCaptureFields: capture.networkCaptureFields,
});
tasks.signal.throwIfAborted();
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
const webPreview = await sessionHost.openPreviewAsync({
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
});
taskLogger.info(
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
webPreview.sharePageUrl
? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
: 'Private preview URL unavailable for this session.'
);
return webPreview;
});
@@ -267,12 +288,14 @@ export async function startAgentDeviceDaemonAsync({
env,
logger,
signal,
secrets,
}: {
packageVersion: string | undefined;
env: BuildStepEnv;
logger: bunyan;
/** Kills the install and stops before the daemon starts, when aborted. No git fallback then. */
signal?: AbortSignal;
secrets?: string[];
}): Promise<DetachedProcessHandle> {
const packageSpec = createAgentDevicePackageSpec(packageVersion);
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
@@ -298,12 +321,17 @@ export async function startAgentDeviceDaemonAsync({
command: 'node',
args: [daemonPath],
env: { ...env, ...AGENT_DEVICE_DAEMON_ENV },
logger,
secrets,
});
return {
...daemonProcess,
stopAsync: async () => {
await daemonProcess.stopAsync();
await fs.promises.rm(installDir, { recursive: true, force: true });
try {
await daemonProcess.stopAsync();
} finally {
await fs.promises.rm(installDir, { recursive: true, force: true });
}
},
};
} catch (err) {
@@ -334,7 +362,13 @@ export async function startAgentDeviceDaemonAsync({
logger.warn(
`Failed to start daemon from ${packageSpec} via ${packageManager}; falling back to git clone: ${error.message}`
);
return await startAgentDeviceDaemonFromGitAsync({ packageVersion, env, logger, signal });
return await startAgentDeviceDaemonFromGitAsync({
packageVersion,
env,
logger,
signal,
secrets,
});
}
}
@@ -365,11 +399,13 @@ async function startAgentDeviceDaemonFromGitAsync({
env,
logger,
signal,
secrets,
}: {
packageVersion: string | undefined;
env: BuildStepEnv;
logger: bunyan;
signal?: AbortSignal;
secrets?: string[];
}): Promise<DetachedProcessHandle> {
logger.info(
packageVersion
@@ -396,6 +432,8 @@ async function startAgentDeviceDaemonFromGitAsync({
args: ['run', 'src/daemon.ts'],
cwd: SRC_DIR,
env: { ...env, ...AGENT_DEVICE_DAEMON_ENV },
logger,
secrets,
});
}
@@ -16,8 +16,18 @@ import {
AndroidVirtualDeviceName,
} from '../../utils/AndroidEmulatorUtils';
import { IosSimulatorName, IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
import { getDeviceRunSessionWorkerApi } from '../utils/deviceRunSessionWorkerApi';
import { withLocalEgressSession } from '../utils/localEgressSession';
import { selectXcodeDeveloperDirectoryAsync } from '../utils/remoteDeviceRunSession';
import {
createCorsOriginsInputProvider,
createFrameAncestorsInputProvider,
createPrivatePreviewUrlInputProvider,
createPublicPreviewInputProvider,
omitNgrokAuthtoken,
parseCorsOriginsInput,
parseFrameAncestorsInput,
selectXcodeDeveloperDirectoryAsync,
} from '../utils/remoteDeviceRunSession';
import {
createNetworkCaptureInputProviders,
parseNetworkCaptureInputs,
@@ -114,6 +124,10 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
}),
// Session.
...createNetworkCaptureInputProviders(),
createPublicPreviewInputProvider(),
createPrivatePreviewUrlInputProvider(),
createFrameAncestorsInputProvider(),
createCorsOriginsInputProvider(),
BuildStepInput.createProvider({
id: 'package_version',
required: false,
@@ -130,13 +144,14 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
fn: withLocalEgressSession(async (stepsCtx, { inputs, env, signal }) => {
fn: withLocalEgressSession(async (stepsCtx, { inputs, env: stepEnv, signal }) => {
const { logger, global } = stepsCtx;
const { runtimePlatform } = global;
const isIos = runtimePlatform === BuildRuntimePlatform.DARWIN;
// Validate everything before any expensive work starts.
const sessionEnv = getAgentDeviceRemoteSessionEnvOrThrow(env);
const sessionEnv = getAgentDeviceRemoteSessionEnvOrThrow(stepEnv);
const env = omitNgrokAuthtoken(stepEnv);
const buildId = inputs.build_id.value as string | undefined;
const applicationArchiveUrl = inputs.application_archive_url.value as string | undefined;
if (buildId && applicationArchiveUrl) {
@@ -204,6 +219,7 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
logger: taskLogger,
...(buildId ? { buildId } : { applicationArchiveUrl: applicationArchiveUrl! }),
graphqlClient: ctx.graphqlClient,
deviceRunSessionWorkerApi: buildId ? getDeviceRunSessionWorkerApi(ctx) : null,
robotAccessToken: global.staticContext.job.secrets?.robotAccessToken ?? null,
extensions: isIos ? ['app'] : ['apk'],
signal: tasks.signal,
@@ -259,6 +275,10 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
maxIdleTimeMinutes: inputs.max_idle_time_minutes.value as number | undefined,
maxDurationSeconds: inputs.max_duration_seconds.value as number | undefined,
capture,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
tasks,
device: { booted, ready },
});
@@ -35,7 +35,12 @@ import {
import { sleepAsync } from '../../utils/retry';
import { turtleFetch } from '../../utils/turtleFetch';
import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
import { preparePreinstalledWdaAsync } from '../utils/preinstalledWda';
import {
createCorsOriginsInputProvider,
createFrameAncestorsInputProvider,
createPrivatePreviewUrlInputProvider,
createPublicPreviewInputProvider,
createServeSimLaunchInputProviders,
describeServeSimLaunch,
ensureFfmpegInstalledOnceAsync,
@@ -43,6 +48,9 @@ import {
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
omitNgrokAuthtoken,
parseCorsOriginsInput,
parseFrameAncestorsInput,
parseServeSimLaunchInputs,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
@@ -71,6 +79,10 @@ export function createStartAppiumRemoteSessionBuildFunction(
inputProviders: [
...createServeSimLaunchInputProviders(),
...createNetworkCaptureInputProviders(),
createPublicPreviewInputProvider(),
createPrivatePreviewUrlInputProvider(),
createFrameAncestorsInputProvider(),
createCorsOriginsInputProvider(),
BuildStepInput.createProvider({
id: 'package_version',
required: false,
@@ -82,10 +94,11 @@ export function createStartAppiumRemoteSessionBuildFunction(
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env: stepEnv, signal }) => {
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(stepEnv);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(stepEnv);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(stepEnv);
const env = omitNgrokAuthtoken(stepEnv);
const packageVersion = inputs.package_version.value as string | undefined;
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
const { runtimePlatform } = global;
@@ -121,6 +134,13 @@ export function createStartAppiumRemoteSessionBuildFunction(
ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger }),
]);
const { appiumHome, appiumBinPath, appiumEnv } = appiumInstall.enforceValue();
const wdaCapabilities = await preparePreinstalledWdaAsync({
runtimeIdentifier: device.runtimeIdentifier,
appiumHome,
appiumBinPath,
appiumEnv,
logger,
});
const appiumProcess = spawnDetached({
command: appiumBinPath,
@@ -139,28 +159,23 @@ export function createStartAppiumRemoteSessionBuildFunction(
'--allow-insecure',
'*:session_discovery',
'--default-capabilities',
JSON.stringify({ 'appium:eventTimings': true }),
JSON.stringify({ 'appium:eventTimings': true, ...wdaCapabilities }),
],
env: appiumEnv,
});
try {
await waitForAppiumReadyAsync({ appiumProcess, logger });
} catch (error) {
await appiumProcess.stopAsync();
await fs.promises.rm(appiumHome, { recursive: true, force: true });
throw error;
}
const eventCollection = await startAppiumEventCollectionAsync({
ctx,
deviceRunSessionId,
appiumUrl: `http://${APPIUM_HOST}:${APPIUM_PORT}/`,
logger,
});
let eventCollection: Awaited<ReturnType<typeof startAppiumEventCollectionAsync>> | undefined;
let appiumTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
let sessionHost: DeviceSessionHost | undefined;
let sessionFailed = false;
try {
await waitForAppiumReadyAsync({ appiumProcess, logger });
eventCollection = await startAppiumEventCollectionAsync({
ctx,
deviceRunSessionId,
appiumUrl: `http://${APPIUM_HOST}:${APPIUM_PORT}/`,
logger,
});
appiumTunnel = await startNgrokTunnelAsync({
port: APPIUM_PORT,
subdomainPrefix: 'appium',
@@ -176,17 +191,25 @@ export function createStartAppiumRemoteSessionBuildFunction(
logger.info(launchDescription);
}
sessionHost = await startDeviceSessionHostAsync(ctx, {
separateLogPhase: true,
runtimePlatform,
env,
logger,
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
launchAppIdentifier: launch.launchAppIdentifier,
launchArgs: launch.launchArgs,
openUrl: launch.openUrl,
networkCapture,
networkCaptureFields,
});
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
const webPreview = await sessionHost.openPreviewAsync({
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
});
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
@@ -233,10 +256,13 @@ export function createStartAppiumRemoteSessionBuildFunction(
'Appium server',
(async () => {
try {
await eventCollection.stopAsync();
await eventCollection?.stopAsync();
} finally {
await appiumProcess.stopAsync();
await fs.promises.rm(appiumHome, { recursive: true, force: true });
try {
await appiumProcess.stopAsync();
} finally {
await fs.promises.rm(appiumHome, { recursive: true, force: true });
}
}
})(),
],
@@ -264,6 +290,7 @@ export type AppiumDevice = {
automationName: 'XCUITest' | 'UiAutomator2';
driverName: 'xcuitest' | 'uiautomator2';
udid: string;
runtimeIdentifier?: string;
};
export async function resolveAppiumDeviceAsync({
@@ -290,6 +317,7 @@ export async function resolveAppiumDeviceAsync({
automationName: 'XCUITest',
driverName: 'xcuitest',
udid: bootedDevice.udid,
runtimeIdentifier: bootedDevice.runtime,
};
}
case BuildRuntimePlatform.LINUX: {
@@ -34,6 +34,10 @@ import { sleepAsync } from '../../utils/retry';
import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts';
import { ARGENT_EVENT_LOG_FILENAME, startArgentEventCollectionAsync } from '../utils/argentEvents';
import {
createCorsOriginsInputProvider,
createFrameAncestorsInputProvider,
createPrivatePreviewUrlInputProvider,
createPublicPreviewInputProvider,
createServeSimLaunchInputProviders,
describeServeSimLaunch,
ensureFfmpegInstalledOnceAsync,
@@ -41,6 +45,9 @@ import {
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
omitNgrokAuthtoken,
parseCorsOriginsInput,
parseFrameAncestorsInput,
parseServeSimLaunchInputs,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
@@ -55,6 +62,9 @@ export const MIN_ARGENT_REMOTE_SESSION_VERSION = '0.16.0';
const ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG = 'artifacts-list-endpoint';
// Tells the tool-server to write its structured event log so we can collect session events.
const ARGENT_EVENT_LOG_FLAG = 'tool-server-event-log';
// On by default in Argent (since 0.17.0); we turn it off so recorded videos have no Argent
// watermark. Older versions that don't know the flag treat `disable` as a no-op.
const ARGENT_VIDEO_WATERMARK_FLAG = 'video-watermark';
const ARGENT_STATE_DIR = path.join(os.homedir(), '.argent');
// Pin the event log path explicitly and hand the same value to the tool-server (via
// ARGENT_EVENT_LOG) and the collector, so an ambient ARGENT_EVENT_LOG or a future change to
@@ -81,6 +91,10 @@ export function createStartArgentRemoteSessionBuildFunction(
inputProviders: [
...createServeSimLaunchInputProviders(),
...createNetworkCaptureInputProviders(),
createPublicPreviewInputProvider(),
createPrivatePreviewUrlInputProvider(),
createFrameAncestorsInputProvider(),
createCorsOriginsInputProvider(),
BuildStepInput.createProvider({
id: 'package_version',
required: false,
@@ -97,14 +111,15 @@ export function createStartArgentRemoteSessionBuildFunction(
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env: stepEnv, signal }) => {
// Fail fast before any expensive setup if the injected env
// vars are missing: DEVICE_RUN_SESSION_ID (to report the remote config
// back to the API server), EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain
// for our ngrok tunnels), and NGROK_AUTHTOKEN (to authenticate them).
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(stepEnv);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(stepEnv);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(stepEnv);
const env = omitNgrokAuthtoken(stepEnv);
const packageVersion = inputs.package_version.value as string | undefined;
// A missing or non-positive value disables the idle timeout (opt-in feature).
@@ -163,6 +178,22 @@ export function createStartArgentRemoteSessionBuildFunction(
]);
await spawn(enableEventLog.command, enableEventLog.args, { env, logger });
logger.info('Disabling the Argent video watermark flag.');
const disableVideoWatermark = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'disable',
ARGENT_VIDEO_WATERMARK_FLAG,
]);
// The watermark is cosmetic, so a failure here must not fail the session.
try {
await spawn(disableVideoWatermark.command, disableVideoWatermark.args, { env, logger });
} catch (err) {
logger.warn(
{ err },
'Could not disable the Argent video watermark, so recorded videos may include it. Continuing the session.'
);
}
const startServer = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'server',
@@ -178,10 +209,13 @@ export function createStartArgentRemoteSessionBuildFunction(
);
// Keep Argent itself in foreground mode under the detached process. This preserves
// the npx/bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
const secrets: string[] = [];
const argentServer = spawnDetached({
command: startServer.command,
args: startServer.args,
env: { ...env, ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH, ARGENT_EMULATOR_NO_WINDOW: '1' },
logger,
secrets,
});
if (argentServer.pid === undefined) {
throw new SystemError(
@@ -189,50 +223,55 @@ export function createStartArgentRemoteSessionBuildFunction(
);
}
logger.info(`Waiting for argent tool-server state in ${ARGENT_STATE_DIR}.`);
let toolServerPort: number;
let toolServerToken: string | undefined;
try {
const toolServerState = await waitForArgentToolServerStateAsync({
stateDir: ARGENT_STATE_DIR,
ancestorPid: argentServer.pid,
timeoutMs: STARTUP_TIMEOUT_MS,
getExitError: argentServer.getExitError,
});
toolServerPort = toolServerState.port;
toolServerToken = toolServerState.token;
} catch (err) {
const output = argentServer.getOutput();
throw new SystemError(
`${
err instanceof Error ? err.message : `Timed out waiting for argent tool-server state.`
}${output ? `\nArgent tool-server output:\n${output}` : ''}`
);
}
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
const artifactPollAbortController = new AbortController();
const artifactPollSignal = signal
? AbortSignal.any([signal, artifactPollAbortController.signal])
: artifactPollAbortController.signal;
const artifactPollingPromise = pollArgentArtifactsForUploadAsync(ctx, {
deviceRunSessionId,
toolsUrl: `http://127.0.0.1:${toolServerPort}`,
toolsAuthToken: toolServerToken,
logger,
signal: artifactPollSignal,
});
const eventCollection = await startArgentEventCollectionAsync({
ctx,
deviceRunSessionId,
eventLogPath: ARGENT_EVENT_LOG_PATH,
logger,
});
let artifactPollingPromise: Promise<void> | undefined;
let eventCollection: Awaited<ReturnType<typeof startArgentEventCollectionAsync>> | undefined;
let toolsTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
let sessionHost: DeviceSessionHost | undefined;
let sessionFailed = false;
try {
logger.info(`Waiting for argent tool-server state in ${ARGENT_STATE_DIR}.`);
let toolServerPort: number;
let toolServerToken: string | undefined;
try {
const toolServerState = await waitForArgentToolServerStateAsync({
stateDir: ARGENT_STATE_DIR,
ancestorPid: argentServer.pid,
timeoutMs: STARTUP_TIMEOUT_MS,
getExitError: argentServer.getExitError,
});
toolServerPort = toolServerState.port;
toolServerToken = toolServerState.token;
if (toolServerToken) {
secrets.push(toolServerToken);
}
} catch (err) {
const output = argentServer.getOutput();
throw new SystemError(
`${
err instanceof Error ? err.message : `Timed out waiting for argent tool-server state.`
}${output ? `\nArgent tool-server output:\n${output}` : ''}`
);
}
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
const artifactPollSignal = signal
? AbortSignal.any([signal, artifactPollAbortController.signal])
: artifactPollAbortController.signal;
artifactPollingPromise = pollArgentArtifactsForUploadAsync(ctx, {
deviceRunSessionId,
toolsUrl: `http://127.0.0.1:${toolServerPort}`,
toolsAuthToken: toolServerToken,
logger,
signal: artifactPollSignal,
});
eventCollection = await startArgentEventCollectionAsync({
ctx,
deviceRunSessionId,
eventLogPath: ARGENT_EVENT_LOG_PATH,
logger,
});
toolsTunnel = await startNgrokTunnelAsync({
port: toolServerPort,
subdomainPrefix: 'argent',
@@ -249,19 +288,29 @@ export function createStartArgentRemoteSessionBuildFunction(
logger.info(launchDescription);
}
sessionHost = await startDeviceSessionHostAsync(ctx, {
separateLogPhase: true,
runtimePlatform,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
launchAppIdentifier: launch.launchAppIdentifier,
launchArgs: launch.launchArgs,
openUrl: launch.openUrl,
networkCapture,
networkCaptureFields,
});
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
const webPreview = await sessionHost.openPreviewAsync({
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
});
logger.info(
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
webPreview.sharePageUrl
? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
: 'Private preview URL unavailable for this session.'
);
await uploadRemoteSessionConfigWithLocalEgressAsync({
@@ -306,11 +355,13 @@ export function createStartArgentRemoteSessionBuildFunction(
'Argent tool-server',
(async () => {
try {
await stopArgentEventCollectionSafelyAsync({
eventCollection,
deviceRunSessionId,
logger,
});
if (eventCollection) {
await stopArgentEventCollectionSafelyAsync({
eventCollection,
deviceRunSessionId,
logger,
});
}
artifactPollAbortController.abort();
try {
await artifactPollingPromise;
@@ -25,6 +25,7 @@ import {
findAvailablePortAsync,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
omitNgrokAuthtoken,
startNgrokTunnelAsync,
} from '../utils/remoteDeviceRunSession';
@@ -76,11 +77,11 @@ function awaitLocalEgressAcquisitionAsync<T>(
/**
* Points the device host's system HTTP(S) proxy at the EAS CLI's egress client.
* Must run before `eas/start_ios_simulator`: the simulator reads the system proxy
* at boot. Once a simulator boots, `eas/start_ios_simulator` also sets proxy
* environment variables inside it for clients that read them (gRPC, libcurl)
* and installs the local egress guard, which refuses connections that ignore
* both. On Linux it starts the Android emulator relay and fence instead; see
* Must run before iOS simulator startup: the simulator reads the system proxy
* at boot. Startup supplies proxy variables for gRPC/libcurl and the guard that
* refuses connections bypassing the proxy. Serve-sim-owned startup passes this
* boot configuration to serve-sim.
* On Linux it starts the Android emulator relay and fence instead; see
* androidLocalEgress.ts. The shared session cleanup releases the resources
* started here when the session ends, with a job finalizer as a fallback.
*/
@@ -91,9 +92,10 @@ export function createStartLocalEgressBuildFunction(): BuildFunction {
name: 'Start local egress',
__metricsId: 'eas/start_local_egress',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX],
fn: async ({ logger, global }, { env, signal }) => {
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
fn: async ({ logger, global }, { env: stepEnv, signal }) => {
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(stepEnv);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(stepEnv);
const env = omitNgrokAuthtoken(stepEnv);
const isAndroid = global?.runtimePlatform === BuildRuntimePlatform.LINUX;
let workDir: string | undefined;
let server: DetachedProcessHandle | undefined;
@@ -156,6 +158,7 @@ export function createStartLocalEgressBuildFunction(): BuildFunction {
controlPort,
authfilePath,
env,
logger,
signal: startupSignal,
});
server = started.process;
@@ -7,6 +7,7 @@ import {
import { CustomBuildContext } from '../../customBuildContext';
import { startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
import {
createNetworkCaptureInputProviders,
parseNetworkCaptureInputs,
@@ -16,11 +17,19 @@ import {
withLocalEgressSession,
} from '../utils/localEgressSession';
import {
createCorsOriginsInputProvider,
createFrameAncestorsInputProvider,
createIosSessionStartupInputProviders,
createPrivatePreviewUrlInputProvider,
createPublicPreviewInputProvider,
createServeSimLaunchInputProviders,
describeServeSimLaunch,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
parseServeSimLaunchInputs,
omitNgrokAuthtoken,
parseCorsOriginsInput,
parseFrameAncestorsInput,
selectXcodeDeveloperDirectoryAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../utils/remoteDeviceRunSession';
@@ -37,7 +46,12 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
__metricsId: 'eas/start_serve_sim_remote_session',
inputProviders: [
...createServeSimLaunchInputProviders(),
...createIosSessionStartupInputProviders(),
...createNetworkCaptureInputProviders(),
createPublicPreviewInputProvider(),
createPrivatePreviewUrlInputProvider(),
createFrameAncestorsInputProvider(),
createCorsOriginsInputProvider(),
BuildStepInput.createProvider({
id: 'package_version',
required: false,
@@ -49,20 +63,29 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env: stepEnv, signal }) => {
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(stepEnv);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(stepEnv);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(stepEnv);
const env = omitNgrokAuthtoken(stepEnv);
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
const packageVersion = inputs.package_version?.value as string | undefined;
const { runtimePlatform } = global;
const launch = parseServeSimLaunchInputs(
{
launchAppIdentifier: inputs.launch_app_identifier?.value as string | undefined,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value as string | undefined,
},
{ runtimePlatform }
);
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
runtimePlatform,
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
deviceIdentifier: inputs.device_identifier?.value as string | undefined,
installAppPath: inputs.install_app_path?.value as string | undefined,
launchAppIdentifier: inputs.launch_app_identifier?.value,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value,
env,
logger,
signal,
});
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture?.value,
@@ -77,16 +100,18 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
logger.info(launchDescription);
}
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
const sessionHost = await startDeviceSessionHostAsync(ctx, {
...iosStartup,
runtimePlatform,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
signal,
packageVersion,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
launchAppIdentifier: launch.launchAppIdentifier,
launchArgs: launch.launchArgs,
openUrl: launch.openUrl,
@@ -95,8 +120,15 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
});
try {
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
logger.info(`Preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`);
const webPreview = await sessionHost.openPreviewAsync({
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
});
logger.info(
webPreview.sharePageUrl
? `Preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
: 'Private preview URL unavailable for this session.'
);
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
@@ -0,0 +1,264 @@
import { UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import {
BuildFunction,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import path from 'node:path';
import { z } from 'zod';
import { GooglePlayAuthUtils } from '../utils/android/GooglePlayAuthUtils';
import { GooglePlayClient, ReleaseStatus, ReleaseStatusZ } from '../utils/android/GooglePlayClient';
import {
GooglePlayCommitOutcomeUnknownError,
GooglePlayUtils,
} from '../utils/android/GooglePlayUtils';
import { AndroidArtifactType } from '../utils/android/appArtifact';
const InputsZ = z
.object({
artifact_path: z.string().min(1),
artifact_type: z.enum(['apk', 'aab']),
package_name: z.string().regex(/^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+$/),
service_account_key_path: z.string().min(1),
track: z.string().trim().min(1).default('internal'),
release_status: ReleaseStatusZ.exclude(['halted']).default('completed'),
rollout: z.number().finite().gt(0).max(1).optional(),
changelog: z.string().optional(),
changes_not_sent_for_review: z.boolean().default(false),
})
.superRefine(({ release_status, rollout }, ctx) => {
if (release_status === 'draft' && rollout !== undefined) {
ctx.addIssue({
code: 'custom',
path: ['rollout'],
message: 'A draft release cannot have a rollout.',
});
}
if (release_status === 'inProgress' && rollout === undefined) {
ctx.addIssue({
code: 'custom',
path: ['rollout'],
message: 'An inProgress release requires a rollout greater than 0 and at most 1.',
});
}
});
// Does not inspect the binary. The caller must run eas/read_android_app_info and
// compare its package_name with the intended application ID before calling this.
export function createSubmitToGooglePlayBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'submit_to_google_play',
name: 'Submit to Google Play',
__metricsId: 'eas/submit_to_google_play',
__hookId: 'submit',
inputProviders: [
BuildStepInput.createProvider({
id: 'artifact_path',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'artifact_type',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'package_name',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'service_account_key_path',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'track',
required: false,
defaultValue: 'internal',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'release_status',
required: false,
defaultValue: 'completed',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'rollout',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'changelog',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'changes_not_sent_for_review',
required: false,
defaultValue: false,
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
}),
],
outputProviders: [BuildStepOutput.createProvider({ id: 'version_code', required: true })],
fn: async (ctx, { inputs, outputs, signal }) => {
signal?.throwIfAborted();
const parsed = InputsZ.safeParse(
Object.fromEntries(Object.entries(inputs).map(([key, input]) => [key, input.value]))
);
if (!parsed.success) {
throw new UserError(
'EAS_GOOGLE_PLAY_INVALID_INPUT',
'Invalid Google Play submission inputs:\n' + z.prettifyError(parsed.error)
);
}
const submission = parsed.data;
const { release_status: requested, rollout } = submission;
// Match Fastlane: a partial rollout overrides the requested status to inProgress.
const release: {
status: ReleaseStatus;
userFraction?: number;
} =
rollout !== undefined && rollout < 1
? { status: 'inProgress', userFraction: rollout }
: { status: requested === 'inProgress' ? 'completed' : requested };
const credentials = await GooglePlayAuthUtils.loadGoogleServiceAccountAsync({
keyPath: path.resolve(ctx.workingDirectory, submission.service_account_key_path),
});
const artifactPath = path.resolve(ctx.workingDirectory, submission.artifact_path);
const client = new GooglePlayClient(credentials, ctx.logger);
const versionCode = await submitToGooglePlayAsync({
artifactType: submission.artifact_type,
client,
submission: {
package_name: submission.package_name,
track: submission.track,
release_status: submission.release_status,
rollout: submission.rollout,
changelog: submission.changelog,
changes_not_sent_for_review: submission.changes_not_sent_for_review,
artifact_path: artifactPath,
},
release,
logger: ctx.logger,
signal,
});
outputs.version_code.set(String(versionCode));
},
});
}
async function submitToGooglePlayAsync({
artifactType,
client,
submission,
release,
logger,
signal,
}: {
artifactType: AndroidArtifactType;
client: GooglePlayClient;
submission: {
package_name: string;
track: string;
release_status: ReleaseStatus;
rollout?: number;
changelog?: string;
changes_not_sent_for_review: boolean;
artifact_path: string;
};
release: { status: ReleaseStatus; userFraction?: number };
logger: bunyan;
signal?: AbortSignal;
}): Promise<number> {
let editId: string | undefined;
let committed = false;
let commitOutcomeUnknown = false;
try {
const packageName = submission.package_name;
logger.info(
`Submitting ${packageName} (${artifactType}) to track ${JSON.stringify(submission.track)}.`
);
logger.info(
`Release status: ${release.status}. Rollout: ${release.userFraction === undefined ? 'none' : new Intl.NumberFormat('en-US', { style: 'percent', maximumSignificantDigits: 15 }).format(release.userFraction)}.`
);
if (submission.release_status !== release.status) {
logger.info(
release.status === 'completed'
? 'A 100% rollout is submitted as a completed release.'
: 'A partial rollout is submitted as an in-progress release.'
);
}
logger.info(
submission.changes_not_sent_for_review
? 'Save changes without sending them for review.'
: 'Send changes for review.'
);
if (submission.changelog) {
logger.info(`Changelog (en-US): ${JSON.stringify(submission.changelog)}`);
}
editId = (await GooglePlayUtils.createEditAsync(client, { packageName, signal })).id;
let lastPercent: number | undefined;
const { versionCode } = await GooglePlayUtils.uploadApplicationAsync(client, {
packageName,
editId,
artifactPath: submission.artifact_path,
artifactType,
signal,
onProgress: (uploaded, total) => {
const percent = Math.floor((uploaded / total) * 100);
if (percent !== lastPercent) {
logger.info(`Upload: ${percent}% (${uploaded}/${total} bytes).`);
lastPercent = percent;
}
},
});
logger.info(`Uploaded version code: ${versionCode}.`);
await GooglePlayUtils.updateTrackAsync(client, {
packageName,
editId,
track: submission.track,
release,
versionCode,
changelog: submission.changelog ? { locale: 'en-US', text: submission.changelog } : undefined,
signal,
});
logger.info(`Updated Google Play track ${JSON.stringify(submission.track)} in the edit.`);
logger.info('Committing the Google Play edit...');
// Commit also validates the edit.
await GooglePlayUtils.commitEditAsync(client, {
packageName,
editId,
changesNotSentForReview: submission.changes_not_sent_for_review,
logger,
signal,
});
committed = true;
logger.info(
`Submitted ${packageName}, version ${versionCode}, to ${JSON.stringify(submission.track)}.`
);
return versionCode;
} catch (error) {
commitOutcomeUnknown = error instanceof GooglePlayCommitOutcomeUnknownError;
throw GooglePlayUtils.mapGooglePlayError(error, submission.package_name);
} finally {
if (editId && !committed && !commitOutcomeUnknown) {
try {
await GooglePlayUtils.deleteEditAsync(client, {
packageName: submission.package_name,
editId,
signal: AbortSignal.timeout(30_000),
});
} catch {
logger.warn(
'Could not discard the pending Google Play changes. Check the release in Play Console before submitting again.'
);
}
}
}
}
@@ -19,14 +19,22 @@ import { isProcessGroupRunning, killProcessGroup } from '../../utils/processes';
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
const IOS_SIMULATOR_RECORDING_POLL_INTERVAL_MS = 2_000;
// Once the poller stops, each recording stops in parallel within 60 + 130 + 5 + 30 = 225 s.
// Once the poller stops, each recording stops in parallel within 60 + 430 + 5 + 310 = 805 s.
const SERVE_SIM_START_TIMEOUT_MS = 60_000;
/** serve-sim finalizes an active recording within this grace period after SIGTERM. */
export const SERVE_SIM_STOP_GRACE_PERIOD_MS = 90_000;
const SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS = 30_000;
// serve-sim may be stopping at the same time: its grace period, the manifest wait, and 10 s.
// Match record-video's control request, recording lease, and finalization allowances.
const SERVE_SIM_CONTROL_REQUEST_TIMEOUT_MS = 120_000;
const SERVE_SIM_RECORDING_LEASE_MS = 180_000;
const SERVE_SIM_FINALIZATION_TIMEOUT_MS = 120_000;
const SERVE_SIM_SHUTDOWN_MARGIN_MS = 10_000;
/** Give the session host time to finalize its active writer before SIGKILL. */
export const SERVE_SIM_STOP_GRACE_PERIOD_MS =
SERVE_SIM_FINALIZATION_TIMEOUT_MS + SERVE_SIM_SHUTDOWN_MARGIN_MS;
// A recorder can exit before its lease expires and the host writes the manifest.
const SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS =
SERVE_SIM_RECORDING_LEASE_MS + SERVE_SIM_FINALIZATION_TIMEOUT_MS + SERVE_SIM_SHUTDOWN_MARGIN_MS;
// An ambiguous stop can use a full control request before lease/finalization reconciliation.
const SERVE_SIM_FINISH_TIMEOUT_MS =
SERVE_SIM_STOP_GRACE_PERIOD_MS + SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS + 10_000;
SERVE_SIM_CONTROL_REQUEST_TIMEOUT_MS + SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS;
const SERVE_SIM_FORCE_STOP_TIMEOUT_MS = 5_000;
const SERVE_SIM_RECORDING_STARTED = 'serve-sim:recording-started';
// The serve-sim package the session host started; every recorder runs `record-video` from it.
@@ -46,6 +54,7 @@ type IosSimulatorRecording = {
type ActiveIosSimulatorRecording = IosSimulatorRecording & {
recordingProcess: ChildProcess;
completionPromise: Promise<void>;
hasExited: () => boolean;
};
type IosSimulatorRecordingSession = {
@@ -128,6 +137,7 @@ export namespace IosSimulatorRecordingUtils {
logger.info(`Stopping screen recording for ${recording.deviceName}.`);
const startState = await waitForRecorderStartAsync(recording, SERVE_SIM_START_TIMEOUT_MS);
if (startState === 'exited') {
await recording.completionPromise;
return;
}
if (startState === 'timeout') {
@@ -357,6 +367,7 @@ async function startIosSimulatorRecordingAsync(
});
return true;
};
let processExited = false;
const completionPromise = (async () => {
let spawnError: unknown;
try {
@@ -367,6 +378,7 @@ async function startIosSimulatorRecordingAsync(
// A package-manager wrapper can exit while its record-video child still
// runs in the detached group. Keep this device active until the group exits.
await waitForRecordingProcessGroupExitAsync(recordingSpawn.child);
processExited = true;
// Host shutdown can end the recorder's lease after its video has been saved.
if ((hasStarted() || !spawnError) && (await saveFinalizedRecordingAsync())) {
return;
@@ -400,6 +412,7 @@ async function startIosSimulatorRecordingAsync(
outputDirectory,
recordingProcess: recordingSpawn.child,
completionPromise,
hasExited: () => processExited,
startedAt,
getOutput,
hasStarted,
@@ -428,6 +441,9 @@ async function waitForRecorderStartAsync(
): Promise<'started' | 'exited' | 'timeout'> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (recording.hasExited()) {
return 'exited';
}
if (recording.hasStarted()) {
return 'started';
}
@@ -439,7 +455,7 @@ async function waitForRecorderStartAsync(
return 'exited';
}
}
return 'timeout';
return recording.hasExited() ? 'exited' : recording.hasStarted() ? 'started' : 'timeout';
}
function reportLostRecording(logger: bunyan, deviceName: string, recorderOutput: string): void {
@@ -0,0 +1,133 @@
import { type Env } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import spawn from '@expo/turtle-spawn';
import { type ChildProcess } from 'node:child_process';
import { EventEmitter } from 'node:events';
import { writeFile } from 'node:fs/promises';
import { PassThrough } from 'node:stream';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { IosSimulatorRecordingUtils } from '../IosSimulatorRecordingUtils';
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
jest.mock('@expo/turtle-spawn');
jest.mock('../serveSimMetricsRecorder');
jest.mock('../../../sentry');
jest.mock('node:timers/promises', () => ({
setTimeout: (ms: number, value?: unknown, options?: { signal?: AbortSignal }) =>
new Promise((resolve, reject) => {
const abort = (): void => {
clearTimeout(timer);
reject(Object.assign(new Error('Aborted'), { name: 'AbortError' }));
};
const timer = setTimeout(() => {
options?.signal?.removeEventListener('abort', abort);
resolve(value);
}, ms);
if (options?.signal?.aborted) {
abort();
} else {
options?.signal?.addEventListener('abort', abort, { once: true });
}
}),
}));
const logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
} as unknown as bunyan;
const udid = '06B546AC-7B06-4BED-83DB-83603E8EB537';
let exit: () => void;
let child: ChildProcess;
let finishing: Promise<unknown> | undefined;
beforeEach(async () => {
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] });
IosSimulatorRecordingUtils.useServeSimPackage('@expo/serve-sim@next');
jest
.spyOn(IosSimulatorUtils, 'getAvailableDevicesAsync')
.mockResolvedValue([{ udid, name: 'iPhone 17 Pro', runtimeDisplayName: 'iOS 26.4' } as never]);
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
child = Object.assign(new EventEmitter(), {
stdout: new PassThrough(),
stderr: new PassThrough(),
kill: jest.fn(),
}) as unknown as ChildProcess;
const done = new Promise<void>(resolve => {
exit = resolve;
});
jest.mocked(spawn).mockReturnValue(Object.assign(done, { child }) as never);
await IosSimulatorRecordingUtils.startAsync({ env: {} as Env, logger });
for (let attempt = 0; attempt < 100 && jest.mocked(spawn).mock.calls.length === 0; attempt++) {
await new Promise<void>(resolve => setImmediate(resolve));
}
expect(spawn).toHaveBeenCalledTimes(1);
child.stderr?.emit('data', 'serve-sim:recording-started\n');
});
async function saveManifest(): Promise<void> {
const directory = jest.mocked(spawn).mock.calls[0][1][6];
await writeFile(`${directory}/session.json`, JSON.stringify({ recording: 'recording.mp4' }));
}
afterEach(async () => {
if (jest.mocked(spawn).mock.calls.length) {
await saveManifest();
}
exit();
finishing ??= IosSimulatorRecordingUtils.finishAsync({ logger });
await jest.advanceTimersByTimeAsync(1_000);
await finishing;
finishing = undefined;
jest.useRealTimers();
jest.restoreAllMocks();
jest.mocked(spawn).mockReset();
});
test('keeps the recorder alive through a 120-second stop request and 300-second reconciliation', async () => {
finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
await jest.advanceTimersByTimeAsync(0);
expect(child.kill).toHaveBeenCalledWith('SIGINT');
await jest.advanceTimersByTimeAsync(420_000);
expect(child.kill).not.toHaveBeenCalledWith('SIGKILL');
await saveManifest();
exit();
await jest.advanceTimersByTimeAsync(0);
expect(await finishing).toHaveLength(1);
expect(child.kill).not.toHaveBeenCalledWith('SIGKILL');
});
test('force-kills an unresponsive started recorder after the bounded shutdown allowance', async () => {
jest.mocked(child.kill).mockImplementation(signal => {
if (signal === 'SIGKILL') {
exit();
}
return true;
});
finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
await jest.advanceTimersByTimeAsync(429_999);
expect(child.kill).not.toHaveBeenCalledWith('SIGKILL');
await saveManifest();
await jest.advanceTimersByTimeAsync(1);
expect(child.kill).toHaveBeenCalledWith('SIGKILL');
expect(await finishing).toHaveLength(1);
expect(logger.warn).toHaveBeenCalledWith(
expect.anything(),
expect.stringContaining('did not finish within 430 seconds')
);
});
test('retains a manifest saved after a 180-second lease and 120-second finalization', async () => {
exit();
finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
await jest.advanceTimersByTimeAsync(299_999);
await saveManifest();
await jest.advanceTimersByTimeAsync(1_001);
expect(await finishing).toHaveLength(1);
expect(child.kill).not.toHaveBeenCalledWith('SIGKILL');
});
@@ -186,7 +186,7 @@ test('reports a started recording that ends without a manifest to Sentry', async
child.stderr?.emit('data', 'GET http://127.0.0.1:43563/record?token=session-token failed\n');
exitRecorder();
await setTimeout(50);
currentTime += 31_000;
currentTime += 311_000;
await setTimeout(1_100);
expect(Sentry.capture).toHaveBeenCalledWith(
@@ -226,7 +226,7 @@ test('says when a recorder never reported its start before it is stopped', async
await setTimeout(50);
currentTime += 61_000;
await setTimeout(200);
currentTime += 31_000;
currentTime += 311_000;
expect(await finishing).toEqual([]);
expect(child.kill).toHaveBeenCalledWith('SIGINT');
@@ -377,7 +377,7 @@ test('does not signal a recorder that exits before it reports its start', async
recorder.exit();
await setTimeout(50);
// Past the manifest wait, so the recorder's completion settles.
currentTime += 31_000;
currentTime += 311_000;
expect(await finishing).toEqual([]);
expect(recorder.child.kill).not.toHaveBeenCalled();
@@ -435,7 +435,7 @@ test('reports a finalized recording whose manifest is gone at finish', async ()
await rm(manifest);
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
await setTimeout(50);
currentTime += 31_000;
currentTime += 311_000;
expect(await finishing).toEqual([]);
expect(Sentry.capture).toHaveBeenCalledWith(
@@ -493,7 +493,7 @@ test('reports a started recorder that fails without a manifest', async () => {
recorder.child.stderr?.emit('data', 'serve-sim:recording-started\n');
recorder.fail(new Error('record-video exited with code 1'));
await setTimeout(50);
currentTime += 31_000;
currentTime += 311_000;
await setTimeout(1_100);
expect(logger.warn).toHaveBeenCalledWith(
@@ -534,6 +534,6 @@ test('records every booted device with the first serve-sim package', async () =>
recorder.exit();
await setTimeout(50);
currentTime += 31_000;
currentTime += 311_000;
await IosSimulatorRecordingUtils.finishAsync({ logger });
});
@@ -140,6 +140,7 @@ function createOptions(
pathEnv: '/usr/local/bin:/usr/bin',
npmCacheUrl: 'https://npm-cache.expo.test',
logger: createMockLogger(),
addBuildLogMask: jest.fn(),
signal: new AbortController().signal,
...overrides,
};
@@ -220,23 +221,22 @@ describe(runAgentAsync, () => {
expect(agent.stdin).toHaveBeenCalledWith('Fix the failing test.');
});
it('writes the agent output as log lines with the leased credentials masked', async () => {
it('writes the agent output as log lines', async () => {
leaseMock.mockResolvedValue(anthropicLease);
const logger = createMockLogger();
mockSpawn({
onAgent: agent => {
// One JSON event split across two chunks, in the middle of the token.
const toolResultEvent =
'{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"3 tests passed"}]}}\n';
agent.child.stdout.write(toolResultEvent.slice(0, 40));
agent.child.stdout.write(toolResultEvent.slice(40));
agent.child.stdout.write(
'{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"token: anthropic-acc'
);
agent.child.stdout.write('ess-token"}]}}\n');
agent.child.stdout.write(
'{"type":"assistant","message":{"content":[{"type":"text","text":"Done.\\nThe token was anthropic-access-token.\\n"}]}}\n'
'{"type":"assistant","message":{"content":[{"type":"text","text":"Done.\\nThe tests pass.\\n"}]}}\n'
);
agent.child.stdout.write(
'{"type":"result","subtype":"error_during_execution","is_error":true,"errors":["boom"]}\n'
);
agent.child.stderr.write('warning: anthropic-access-token\n');
agent.child.stderr.write('warning: deprecated flag\n');
agent.exit(0);
},
});
@@ -247,11 +247,54 @@ describe(runAgentAsync, () => {
expect(child.mock.calls).toEqual([[{ source: 'stdout' }], [{ source: 'stderr' }]]);
const [stdoutLogger, stderrLogger] = child.mock.results.map(result => result.value);
expect(stdoutLogger.info.mock.calls).toEqual([
['Tool result: token: [redacted]'],
['Agent: Done.\nThe token was [redacted].'],
['Tool result: 3 tests passed'],
['Agent: Done.\nThe tests pass.'],
]);
expect(stdoutLogger.error.mock.calls).toEqual([['Agent failed: boom']]);
expect(stderrLogger.info.mock.calls).toEqual([['warning: [redacted]']]);
expect(stderrLogger.info.mock.calls).toEqual([['warning: deprecated flag']]);
});
it.each([
['claude-code', anthropicLease, ['anthropic-access-token']],
['codex', openaiLease, ['openai-access-token', 'openai-id-token']],
] as const)(
'registers the credentials leased for %s with the build log secrets filter before starting the agent',
async (agentKind, lease, secrets) => {
leaseMock.mockResolvedValue(lease);
const addBuildLogMask = jest.fn();
const invocations = mockSpawn({
onAgent: agent => {
expect(addBuildLogMask.mock.calls).toEqual(secrets.map(secret => [secret]));
agent.exit(0);
},
});
await runAgentAsync(createOptions({ agentKind, addBuildLogMask }));
expect(invocations).toHaveLength(1);
}
);
it('registers renewed credentials before resuming the agent', async () => {
leaseMock
.mockResolvedValueOnce(anthropicLease)
.mockResolvedValueOnce({ ...anthropicLease, accessToken: 'renewed-access-token' });
const addBuildLogMask = jest.fn();
const invocations = mockSpawn({
onAgent: agent => {
if (invocations.length === 2) {
expect(addBuildLogMask.mock.calls).toEqual([
['anthropic-access-token'],
['renewed-access-token'],
]);
}
agent.exit(invocations.length === 1 ? 1 : 0);
},
});
await runAgentAsync(createOptions({ addBuildLogMask }));
expect(invocations).toHaveLength(2);
});
it('reports a line the formatter cannot handle instead of ending the run', async () => {
@@ -427,12 +470,7 @@ describe(runAgentAsync, () => {
leaseMock.mockResolvedValueOnce(anthropicLease).mockResolvedValueOnce(renewedLease);
const logger = createMockLogger();
const invocations = mockSpawn({
onAgent: agent => {
if (invocations.length === 2) {
agent.child.stderr.write('tokens: anthropic-access-token renewed-access-token\n');
}
agent.exit(invocations.length === 1 ? 1 : 0);
},
onAgent: agent => agent.exit(invocations.length === 1 ? 1 : 0),
});
await runAgentAsync(createOptions({ logger }));
@@ -452,8 +490,6 @@ describe(runAgentAsync, () => {
[{ source: 'stdout' }],
[{ source: 'stderr' }],
]);
const [, , , resumedStderrLogger] = child.mock.results.map(result => result.value);
expect(resumedStderrLogger.info.mock.calls).toEqual([['tokens: [redacted] [redacted]']]);
});
it('resumes Codex with new credentials when the lease changed during the run', async () => {
@@ -2,12 +2,14 @@ import { type bunyan } from '@expo/logger';
import { BuildRuntimePlatform } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { Client, fetchExchange } from '@urql/core';
import { getEventListeners } from 'node:events';
import { readFile, readdir, writeFile } from 'node:fs/promises';
import { Readable } from 'node:stream';
import os from 'node:os';
import fetch from 'node-fetch';
import { type CustomBuildContext } from '../../../customBuildContext';
import * as processes from '../../../utils/processes';
import {
captureDeviceRunSessionPreviewAsync,
ensureMacosPreviewEncoderInstalledAsync,
@@ -383,18 +385,106 @@ describe(ensureMacosPreviewEncoderInstalledAsync, () => {
[
'brew',
['install', 'webp'],
{ env: { HOME: '/Users/expo', HOMEBREW_NO_AUTO_UPDATE: '1' }, logger },
{
env: { HOME: '/Users/expo', HOMEBREW_NO_AUTO_UPDATE: '1' },
logger,
detached: true,
},
],
]);
}
}
);
it('rejects when webp cannot be installed', async () => {
it('does not start a command when setup was already canceled', async () => {
jest.mocked(spawn).mockReset();
jest.mocked(spawn).mockRejectedValue(new Error('brew failed'));
await expect(ensureMacosPreviewEncoderInstalledAsync({ env: {}, logger })).rejects.toThrow(
'brew failed'
);
const controller = new AbortController();
const reason = new Error('session ended');
controller.abort(reason);
await expect(
ensureMacosPreviewEncoderInstalledAsync({ env: {}, logger, signal: controller.signal })
).rejects.toBe(reason);
expect(spawn).not.toHaveBeenCalled();
expect(getEventListeners(controller.signal, 'abort')).toEqual([]);
});
it.each(['cwebp', 'brew'])(
'waits for canceled %s to close and never starts another command',
async command => {
jest.mocked(spawn).mockReset();
const controller = new AbortController();
const child = { pid: 12345, kill: jest.fn() };
const killed = jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {});
const closeError = new Error(`${command} closed after cancellation`);
let entered!: () => void;
const commandEntered = new Promise<void>(resolve => {
entered = resolve;
});
let close!: (error: Error) => void;
jest.mocked(spawn).mockImplementation(current => {
if (current !== command) {
return Promise.reject(new Error('cwebp missing')) as ReturnType<typeof spawn>;
}
entered();
return Object.assign(
new Promise((_, reject) => {
close = reject;
}),
{ child }
) as unknown as ReturnType<typeof spawn>;
});
let settled = false;
const result = ensureMacosPreviewEncoderInstalledAsync({
env: {},
logger,
signal: controller.signal,
}).then(
() => {
settled = true;
},
error => {
settled = true;
return error;
}
);
try {
await commandEntered;
controller.abort(new Error('session ended'));
await Promise.resolve();
expect(killed).toHaveBeenCalledWith(child, 'SIGKILL');
expect(settled).toBe(false);
expect(jest.mocked(spawn).mock.calls.at(-1)).toEqual([
command,
command === 'cwebp' ? ['-version'] : ['install', 'webp'],
expect.objectContaining({ detached: true }),
]);
expect(jest.mocked(spawn).mock.calls.every(([, , options]) => !options?.signal)).toBe(true);
} finally {
close(closeError);
await result;
killed.mockRestore();
}
expect(spawn).toHaveBeenCalledTimes(command === 'cwebp' ? 1 : 2);
expect(getEventListeners(controller.signal, 'abort')).toEqual([]);
}
);
it('rejects an installation failure and removes its abort listeners', async () => {
jest.mocked(spawn).mockReset();
const controller = new AbortController();
const failure = new Error('brew failed');
jest.mocked(spawn).mockRejectedValue(failure);
await expect(
ensureMacosPreviewEncoderInstalledAsync({ env: {}, logger, signal: controller.signal })
).rejects.toBe(failure);
expect(getEventListeners(controller.signal, 'abort')).toEqual([]);
});
it('removes its abort listener after a successful probe', async () => {
jest.mocked(spawn).mockReset();
jest.mocked(spawn).mockResolvedValue({ stdout: '', stderr: '' } as never);
const controller = new AbortController();
await ensureMacosPreviewEncoderInstalledAsync({ env: {}, logger, signal: controller.signal });
expect(getEventListeners(controller.signal, 'abort')).toEqual([]);
});
});
@@ -45,3 +45,38 @@ it('accepts a full SHA-256 commit hash', () => {
expect(result.filename).toBe(`my-app-ios-bbbbbbb-${timestamp}.png`);
expect(result.metadata.gitCommitHash).toBe(gitCommitHash);
});
describe('without an app slug (device run session worker API)', () => {
const sessionWithoutSlug = {
platform: 'IOS' as const,
app: null,
build: { id: 'installed-build', gitCommitHash, app: null },
};
it('starts the filename with the platform and omits appSlug from the metadata', () => {
expect(screenshotArtifactDetails(timestamp, sessionWithoutSlug)).toEqual({
name,
filename: `ios-a1b2c3d-${timestamp}.png`,
metadata: {
__eas_type: 'screenshot',
platform: 'ios',
buildId: 'installed-build',
gitCommitHash,
},
});
});
it('omits the commit when the session has no build', () => {
expect(
screenshotArtifactDetails(timestamp, {
...sessionWithoutSlug,
platform: 'ANDROID',
build: null,
})
).toEqual({
name,
filename: `android-${timestamp}.png`,
metadata: { __eas_type: 'screenshot', platform: 'android' },
});
});
});
@@ -0,0 +1,115 @@
import { EnvironmentSecretType, Generic } from '@expo/eas-build-job';
import { BuildStepGlobalContext } from '@expo/steps';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { BuildContext } from '../../../context';
import { CustomBuildContext } from '../../../customBuildContext';
import { turtleFetch } from '../../../utils/turtleFetch';
import {
DEVICE_RUN_SESSION_TOKEN_ENV,
DEVICE_RUN_SESSION_TOKEN_HEADER,
getDeviceRunSessionTokenFromEnvironmentSecrets,
getDeviceRunSessionWorkerApi,
} from '../deviceRunSessionWorkerApi';
jest.mock('../../../utils/turtleFetch');
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
const API_V2_BASE_URL = 'https://api.expo.test/v2/';
function createSessionJob(
environmentSecrets: { name: string; type: EnvironmentSecretType; value: string }[]
): Generic.Job {
return {
triggeredBy: 'GIT_BASED_INTEGRATION',
steps: [],
secrets: { robotAccessToken: 'robot-access-token', environmentSecrets },
builderEnvironment: { env: { DEVICE_RUN_SESSION_ID: 'drs-id' } },
} as unknown as Generic.Job;
}
function createCustomBuildContext(job: Generic.Job): CustomBuildContext<Generic.Job> {
const buildCtx = new BuildContext(job, {
env: { __API_SERVER_URL: 'https://api.expo.test' },
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
uploadArtifact: jest.fn(),
workingdir: '',
expoApiV2BaseUrl: API_V2_BASE_URL,
});
return new CustomBuildContext(buildCtx);
}
async function getSentTokenAsync(ctx: CustomBuildContext): Promise<string | undefined> {
jest.mocked(turtleFetch).mockResolvedValueOnce(
new Response(JSON.stringify({ data: { id: 'drs-id', status: 'in-progress' } }), {
status: 200,
})
);
await getDeviceRunSessionWorkerApi(ctx)!.getSessionAsync();
const [url, , options] = jest.mocked(turtleFetch).mock.calls[0];
expect(url).toBe(`${API_V2_BASE_URL}device-run-session-worker/session`);
return (options.headers as Record<string, string>)[DEVICE_RUN_SESSION_TOKEN_HEADER];
}
describe('device run session token propagation', () => {
beforeEach(() => {
jest.mocked(turtleFetch).mockReset();
});
it('makes the environment secret reach the worker API client and the step env', async () => {
const job = createSessionJob([
{ name: 'NGROK_AUTHTOKEN', type: EnvironmentSecretType.STRING, value: 'ngrok' },
{ name: DEVICE_RUN_SESSION_TOKEN_ENV, type: EnvironmentSecretType.STRING, value: 'token-a' },
]);
const ctx = createCustomBuildContext(job);
expect(new BuildStepGlobalContext(ctx, false).env[DEVICE_RUN_SESSION_TOKEN_ENV]).toBe(
'token-a'
);
await expect(getSentTokenAsync(ctx)).resolves.toBe('token-a');
});
it('resolves duplicate secrets to the same token in the build env and the worker', async () => {
const environmentSecrets = [
{ name: DEVICE_RUN_SESSION_TOKEN_ENV, type: EnvironmentSecretType.STRING, value: 'token-a' },
{ name: DEVICE_RUN_SESSION_TOKEN_ENV, type: EnvironmentSecretType.STRING, value: 'token-b' },
];
const ctx = createCustomBuildContext(createSessionJob(environmentSecrets));
const workerToken = getDeviceRunSessionTokenFromEnvironmentSecrets(environmentSecrets);
expect(workerToken).toBe('token-b');
await expect(getSentTokenAsync(ctx)).resolves.toBe(workerToken);
});
it('returns no client when the job has no session token', () => {
const ctx = createCustomBuildContext(
createSessionJob([
{ name: 'NGROK_AUTHTOKEN', type: EnvironmentSecretType.STRING, value: 'ngrok' },
])
);
expect(getDeviceRunSessionWorkerApi(ctx)).toBeNull();
});
});
describe(getDeviceRunSessionTokenFromEnvironmentSecrets, () => {
it('returns null without secrets or without the token', () => {
expect(getDeviceRunSessionTokenFromEnvironmentSecrets(undefined)).toBeNull();
expect(
getDeviceRunSessionTokenFromEnvironmentSecrets([
{ name: 'OTHER', type: EnvironmentSecretType.STRING, value: 'x' },
])
).toBeNull();
});
it('returns null when the last secret with the name is a file secret', () => {
expect(
getDeviceRunSessionTokenFromEnvironmentSecrets([
{ name: DEVICE_RUN_SESSION_TOKEN_ENV, type: EnvironmentSecretType.STRING, value: 'a' },
{ name: DEVICE_RUN_SESSION_TOKEN_ENV, type: EnvironmentSecretType.FILE, value: 'Yg==' },
])
).toBeNull();
});
});
@@ -0,0 +1,334 @@
import { SystemError } from '@expo/eas-build-job';
import { CustomBuildContext } from '../../../customBuildContext';
import { turtleFetch } from '../../../utils/turtleFetch';
import {
DEVICE_RUN_SESSION_TOKEN_HEADER,
DeviceRunSessionWorkerApi,
DeviceRunSessionWorkerApiError,
getDeviceRunSessionWorkerApi,
} from '../deviceRunSessionWorkerApi';
jest.mock('../../../utils/turtleFetch');
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
type Response = InstanceType<typeof Response>;
const BASE_URL = 'https://api.expo.test/v2/';
const TOKEN = 'device-run-session-token-value';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
function createApi(): DeviceRunSessionWorkerApi {
return new DeviceRunSessionWorkerApi(BASE_URL, TOKEN);
}
describe(getDeviceRunSessionWorkerApi, () => {
it('returns null when the job has no device run session token', () => {
expect(
getDeviceRunSessionWorkerApi({
env: { __API_SERVER_URL: 'https://api.expo.test' },
expoApiV2BaseUrl: BASE_URL,
} as unknown as CustomBuildContext)
).toBeNull();
expect(
getDeviceRunSessionWorkerApi({
env: { EAS_DEVICE_RUN_SESSION_TOKEN: '' },
expoApiV2BaseUrl: BASE_URL,
} as unknown as CustomBuildContext)
).toBeNull();
});
it('uses the API v2 base URL of the context', async () => {
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'in-progress' } }));
const api = getDeviceRunSessionWorkerApi({
env: { EAS_DEVICE_RUN_SESSION_TOKEN: TOKEN, __API_SERVER_URL: 'https://other.expo.test' },
expoApiV2BaseUrl: BASE_URL,
} as unknown as CustomBuildContext);
await api!.getSessionAsync();
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
'https://api.expo.test/v2/device-run-session-worker/session',
'GET',
expect.anything()
);
});
it('derives the API v2 base URL from __API_SERVER_URL when the context has none', async () => {
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'in-progress' } }));
const api = getDeviceRunSessionWorkerApi({
env: { EAS_DEVICE_RUN_SESSION_TOKEN: TOKEN, __API_SERVER_URL: 'https://api.expo.test' },
} as unknown as CustomBuildContext);
await api!.getSessionAsync();
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
'https://api.expo.test/v2/device-run-session-worker/session',
'GET',
expect.anything()
);
});
it('throws a system error when the token is set but no API URL is known', () => {
expect(() =>
getDeviceRunSessionWorkerApi({
env: { EAS_DEVICE_RUN_SESSION_TOKEN: TOKEN },
} as unknown as CustomBuildContext)
).toThrow(SystemError);
});
});
describe(DeviceRunSessionWorkerApi, () => {
beforeEach(() => {
jest.mocked(turtleFetch).mockReset();
});
it.each([
{
name: 'getSessionAsync',
call: (api: DeviceRunSessionWorkerApi) => api.getSessionAsync(),
method: 'GET',
route: 'session',
json: undefined,
data: { id: 'drs-id', status: 'in-progress' },
expected: { id: 'drs-id', status: 'IN_PROGRESS' },
},
{
name: 'startAsync',
call: (api: DeviceRunSessionWorkerApi) =>
api.startAsync({ remoteConfig: { type: 'agent-device', url: 'https://x.test' } }),
method: 'POST',
route: 'start',
json: { remoteConfig: { type: 'agent-device', url: 'https://x.test' } },
data: { id: 'drs-id', status: 'in-progress' },
expected: { id: 'drs-id', status: 'IN_PROGRESS' },
},
{
name: 'stopAsync',
call: (api: DeviceRunSessionWorkerApi) => api.stopAsync(),
method: 'POST',
route: 'stop',
json: undefined,
data: { id: 'drs-id', status: 'stopped' },
expected: { id: 'drs-id', status: 'STOPPED' },
},
{
name: 'createArtifactUploadSessionAsync',
call: (api: DeviceRunSessionWorkerApi) =>
api.createArtifactUploadSessionAsync({
name: 'Screenshot',
filename: 'screenshot.png',
kind: 'screenshot',
size: 12,
metadata: { __eas_type: 'screenshot' },
}),
method: 'POST',
route: 'artifact-upload-sessions',
json: {
name: 'Screenshot',
filename: 'screenshot.png',
kind: 'screenshot',
size: 12,
metadata: { __eas_type: 'screenshot' },
},
data: { uploadSession: { url: 'https://uploads.test/a', headers: { 'X-A': '1' } } },
expected: { url: 'https://uploads.test/a', headers: { 'X-A': '1' } },
},
{
name: 'createEventLogUploadSessionAsync',
call: (api: DeviceRunSessionWorkerApi) => api.createEventLogUploadSessionAsync(),
method: 'POST',
route: 'event-log-upload-session',
json: undefined,
data: { uploadSession: { url: 'https://uploads.test/events', headers: {} } },
expected: { url: 'https://uploads.test/events', headers: {} },
},
{
name: 'createPreviewUploadSessionAsync',
call: (api: DeviceRunSessionWorkerApi) => api.createPreviewUploadSessionAsync(),
method: 'POST',
route: 'preview-upload-session',
json: undefined,
data: { uploadSession: { url: 'https://uploads.test/preview', headers: {} } },
expected: { url: 'https://uploads.test/preview', headers: {} },
},
{
name: 'fetchTurnIceServersAsync',
call: (api: DeviceRunSessionWorkerApi) => api.fetchTurnIceServersAsync(),
method: 'POST',
route: 'turn-ice-servers',
json: undefined,
data: {
iceServers: [
{ urls: ['stun:stun.test:3478'] },
{ urls: ['turn:turn.test:3478'], username: 'user', credential: 'secret' },
],
},
expected: [
{ urls: ['stun:stun.test:3478'] },
{ urls: ['turn:turn.test:3478'], username: 'user', credential: 'secret' },
],
},
{
name: 'getApplicationAsync',
call: (api: DeviceRunSessionWorkerApi) => api.getApplicationAsync(),
method: 'GET',
route: 'application',
json: undefined,
data: {
build: {
id: 'build-id',
gitCommitHash: null,
applicationArchiveUrl: 'https://expo.test/artifacts/eas/a.tar.gz',
},
},
expected: {
build: {
id: 'build-id',
gitCommitHash: null,
applicationArchiveUrl: 'https://expo.test/artifacts/eas/a.tar.gz',
},
},
},
])(
'$name calls $method /v2/device-run-session-worker/$route with the session token header',
async ({ call, method, route, json, data, expected }) => {
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data }));
await expect(call(createApi())).resolves.toEqual(expected);
expect(jest.mocked(turtleFetch)).toHaveBeenCalledTimes(1);
const [url, calledMethod, options] = jest.mocked(turtleFetch).mock.calls[0];
expect(url).toBe(`https://api.expo.test/v2/device-run-session-worker/${route}`);
expect(calledMethod).toBe(method);
expect(options.headers).toEqual({ [DEVICE_RUN_SESSION_TOKEN_HEADER]: TOKEN });
expect(options.json).toEqual(json);
expect(options).toEqual(expect.objectContaining({ retries: 0, shouldThrowOnNotOk: false }));
}
);
it.each([
['new', 'NEW'],
['queued', 'QUEUED'],
['starting', 'STARTING'],
['in-progress', 'IN_PROGRESS'],
['stopped', 'STOPPED'],
['errored', 'ERRORED'],
])('maps the www status %s to the GraphQL status %s', async (wwwStatus, status) => {
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: wwwStatus } }));
await expect(createApi().getSessionAsync()).resolves.toEqual({ id: 'drs-id', status });
});
it('rejects an application response without the build field', async () => {
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { platform: 'ios' } }));
await expect(createApi().getApplicationAsync()).rejects.toThrow(
'GET /v2/device-run-session-worker/application returned an unexpected response'
);
});
it('passes the signal, timeout, and retries to the request', async () => {
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { iceServers: [] } }));
const controller = new AbortController();
await createApi().fetchTurnIceServersAsync({
signal: controller.signal,
timeoutMs: 5000,
retries: 1,
});
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
expect.any(String),
'POST',
expect.objectContaining({ signal: controller.signal, timeout: 5000, retries: 1 })
);
});
it('reports the API v2 error message and status without the token', async () => {
jest.mocked(turtleFetch).mockResolvedValueOnce(
jsonResponse(
{
errors: [
{
code: 'UNAUTHORIZED_ERROR',
message: 'This device run session no longer accepts worker requests.',
},
],
},
403
)
);
const error = await createApi()
.stopAsync()
.catch(err => err);
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
expect(error.status).toBe(403);
expect(error.message).toBe(
'POST /v2/device-run-session-worker/stop failed with status 403: This device run session no longer accepts worker requests.'
);
expect(error.message).not.toContain(TOKEN);
});
it('reports the status text when the error response is not an API v2 error', async () => {
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(
new Response('<html>bad gateway</html>', { status: 502, statusText: 'Bad Gateway' })
);
const error = await createApi()
.getSessionAsync()
.catch(err => err);
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
expect(error.status).toBe(502);
expect(error.message).toBe(
'GET /v2/device-run-session-worker/session failed with status 502: Bad Gateway'
);
});
it('reports a request that failed before www responded', async () => {
jest.mocked(turtleFetch).mockRejectedValueOnce(new Error('socket hang up'));
const error = await createApi()
.getSessionAsync()
.catch(err => err);
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
expect(error.status).toBeUndefined();
expect(error.message).toContain('failed before www responded: socket hang up');
});
it('rejects a response that does not match the route contract', async () => {
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'STOPPED' } }));
await expect(createApi().getSessionAsync()).rejects.toThrow(
'GET /v2/device-run-session-worker/session returned an unexpected response'
);
});
it('rejects a successful response that is not JSON', async () => {
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('ok', { status: 200 }));
await expect(createApi().getSessionAsync()).rejects.toThrow(
'GET /v2/device-run-session-worker/session returned a response that is not JSON.'
);
});
});
@@ -0,0 +1,635 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
import fetch from 'node-fetch';
import { Readable } from 'node:stream';
import { setImmediate } from 'node:timers/promises';
import { type CustomBuildContext } from '../../../customBuildContext';
import RemoteLoggerStream from '../../../logging/RemoteLoggerStream';
import { turtleFetch } from '../../../utils/turtleFetch';
import { downloadBuildAsync } from '../../functions/downloadBuild';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
import { startDeviceRunSessionEventCollectionAsync } from '../deviceRunSessionEvents';
import { startDeviceRunSessionPreview } from '../deviceRunSessionPreview';
import { loadScreenshotSessionAsync } from '../deviceRunSessionScreenshotNames';
import {
DEVICE_RUN_SESSION_TOKEN_HEADER,
DeviceRunSessionWorkerApi,
} from '../deviceRunSessionWorkerApi';
import {
fetchWebPreviewTurnArgsAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../remoteDeviceRunSession';
jest.mock('node-fetch');
jest.mock('@ngrok/ngrok');
jest.mock('@expo/turtle-spawn');
jest.mock('../../../utils/turtleFetch');
jest.mock('../../../sentry');
jest.mock('../../../logging/HttpLogStream', () => ({
__esModule: true,
default: jest.fn(() => ({ write: jest.fn(), cleanUp: jest.fn(async () => undefined) })),
}));
jest.mock('../../../logging/RemoteLoggerStream', () => ({
__esModule: true,
default: jest.fn(() => ({
init: jest.fn(async () => undefined),
write: jest.fn(),
cleanUp: jest.fn(async () => undefined),
})),
}));
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
const TOKEN = 'device-run-session-token-value';
const ROBOT_ACCESS_TOKEN = 'robot-access-token-value';
const API_V2_BASE_URL = 'https://api.expo.test/v2/';
const BUILD_ID = '0a8b5f3c-7c38-4a2f-9c0c-2a1f0e7d8b11';
const APPLICATION_ARCHIVE_URL = 'https://expo.test/artifacts/eas/app.tar.gz';
const logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
child: jest.fn(),
} as unknown as bunyan;
function jsonResponse(body: unknown, status = 200): InstanceType<typeof Response> {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
function createCtx({ withToken }: { withToken: boolean }): CustomBuildContext {
return {
env: {
__API_SERVER_URL: 'https://api.expo.test',
EAS_BUILD_ID: 'job-run-id',
...(withToken ? { EAS_DEVICE_RUN_SESSION_TOKEN: TOKEN } : {}),
},
expoApiV2BaseUrl: API_V2_BASE_URL,
job: { secrets: { robotAccessToken: ROBOT_ACCESS_TOKEN } },
graphqlClient: {
query: jest.fn(),
mutation: jest.fn(),
},
} as unknown as CustomBuildContext;
}
function graphqlResult(data: unknown): { toPromise: () => Promise<{ data: unknown }> } {
return { toPromise: async () => ({ data }) };
}
function expectWorkerApiCall(
route: string,
method: 'GET' | 'POST',
options: Record<string, unknown> = {}
): void {
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
`${API_V2_BASE_URL}device-run-session-worker/${route}`,
method,
expect.objectContaining({
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: TOKEN },
...options,
})
);
}
async function waitForCallAsync(mock: jest.Mock): Promise<void> {
for (let i = 0; i < 100 && mock.mock.calls.length === 0; i++) {
await setImmediate();
}
}
const env = { DEVICE_RUN_SESSION_ID: 'drs-id' } as unknown as BuildStepEnv;
const eventSource = {
producer: 'test-producer',
findEventFilesAsync: async () => [],
sourceKeyForFile: () => 'source',
parseLine: () => ({}),
};
beforeEach(() => {
jest.mocked(turtleFetch).mockReset();
jest.mocked(fetch).mockReset();
});
describe('with a device run session token', () => {
it('polls the session status through the worker API', async () => {
const ctx = createCtx({ withToken: true });
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'stopped' } }));
await waitForDeviceRunSessionStoppedAsync({ ctx, deviceRunSessionId: 'drs-id', logger });
expectWorkerApiCall('session', 'GET', {
retries: 0,
timeout: 30_000,
signal: expect.any(AbortSignal),
});
expect(ctx.graphqlClient.query).not.toHaveBeenCalled();
});
it('stops an idle session through the worker API', async () => {
const ctx = createCtx({ withToken: true });
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'stopped' } }));
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId: 'drs-id',
logger,
idleTimeout: { maxIdleTimeMinutes: 0, getLastEventObservedAt: () => undefined },
});
expectWorkerApiCall('stop', 'POST', { timeout: 30_000 });
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
});
it('starts the session through the worker API', async () => {
const ctx = createCtx({ withToken: true });
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'in-progress' } }));
await uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId: 'drs-id',
remoteConfig: { type: 'agent-device' },
logger,
});
expectWorkerApiCall('start', 'POST', { json: { remoteConfig: { type: 'agent-device' } } });
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
});
it('fails the start with a system error that does not contain the token', async () => {
const ctx = createCtx({ withToken: true });
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(jsonResponse({ errors: [{ message: 'The session finished.' }] }, 403));
const error = await uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId: 'drs-id',
remoteConfig: {},
logger,
}).catch(err => err);
expect(error).toBeInstanceOf(SystemError);
expect(error.message).toBe(
'Failed to start device run session drs-id: POST /v2/device-run-session-worker/start failed with status 403: The session finished.'
);
expect(error.message).not.toContain(TOKEN);
});
it('fetches TURN ICE servers through the worker API', async () => {
const ctx = createCtx({ withToken: true });
jest.mocked(turtleFetch).mockResolvedValueOnce(
jsonResponse({
data: {
iceServers: [
{ urls: ['stun:stun.test:3478'] },
{ urls: ['turn:turn.test:3478'], username: 'user', credential: 'credential' },
],
},
})
);
const args = await fetchWebPreviewTurnArgsAsync(ctx, { env, logger });
expect(args).toEqual([
'--stun-url',
'stun:stun.test:3478',
'--turn-url',
'turn:turn.test:3478',
'--turn-username',
'user',
'--turn-credential',
'credential',
]);
expectWorkerApiCall('turn-ice-servers', 'POST', { timeout: 5000, retries: 1 });
expect(jest.mocked(turtleFetch)).toHaveBeenCalledTimes(1);
});
it('creates artifact upload sessions through the worker API', async () => {
const ctx = createCtx({ withToken: true });
jest.mocked(turtleFetch).mockResolvedValueOnce(
jsonResponse({
data: { uploadSession: { url: 'https://uploads.expo.test/artifact', headers: {} } },
})
);
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 200 }));
await uploadDeviceRunSessionArtifactAsync(ctx, {
deviceRunSessionId: 'drs-id',
artifactId: 'artifact-id',
name: 'Report',
filename: 'report.json',
kind: 'test-report',
metadata: { a: 'b' },
size: 4,
openStream: () => Readable.from(Buffer.from('data')),
});
expectWorkerApiCall('artifact-upload-sessions', 'POST', {
json: {
name: 'Report',
filename: 'report.json',
kind: 'test-report',
metadata: { a: 'b' },
size: 4,
},
signal: expect.any(AbortSignal),
});
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
'https://uploads.expo.test/artifact',
expect.objectContaining({ method: 'PUT' })
);
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
});
it('creates the event log upload session through the worker API', async () => {
const ctx = createCtx({ withToken: true });
jest.mocked(turtleFetch).mockResolvedValueOnce(
jsonResponse({
data: {
uploadSession: { url: 'https://uploads.expo.test/events', headers: { 'X-A': '1' } },
},
})
);
const collection = await startDeviceRunSessionEventCollectionAsync({
ctx,
deviceRunSessionId: 'drs-id',
source: eventSource,
logger,
});
await collection.stopAsync();
expectWorkerApiCall('event-log-upload-session', 'POST');
expect(jest.mocked(RemoteLoggerStream)).toHaveBeenCalledWith(
expect.objectContaining({
uploadMethod: {
signedUrl: { url: 'https://uploads.expo.test/events', headers: { 'X-A': '1' } },
},
})
);
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
});
it('creates the preview upload session through the worker API', async () => {
const ctx = createCtx({ withToken: true });
jest.mocked(turtleFetch).mockResolvedValueOnce(
jsonResponse({
data: { uploadSession: { url: 'https://uploads.expo.test/preview', headers: {} } },
})
);
jest.mocked(fetch).mockResolvedValue(new Response('', { status: 200 }));
const preview = startDeviceRunSessionPreview({
ctx,
deviceRunSessionId: 'drs-id',
captureAsync: async () => Buffer.from('webp'),
logger,
});
await waitForCallAsync(jest.mocked(fetch));
await preview.stopAsync();
expectWorkerApiCall('preview-upload-session', 'POST', { signal: expect.any(AbortSignal) });
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
'https://uploads.expo.test/preview',
expect.objectContaining({ method: 'PUT' })
);
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
});
it.each([
[BuildRuntimePlatform.DARWIN, 'IOS'],
[BuildRuntimePlatform.LINUX, 'ANDROID'],
] as const)(
'loads the screenshot naming details through the worker API on a %s worker',
async (runtimePlatform, platform) => {
const ctx = { ...createCtx({ withToken: true }), runtimePlatform } as CustomBuildContext;
jest.mocked(turtleFetch).mockResolvedValueOnce(
jsonResponse({
data: {
build: {
id: BUILD_ID,
gitCommitHash: 'abc',
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
},
},
})
);
await expect(loadScreenshotSessionAsync(ctx, 'drs-id', logger)).resolves.toEqual({
platform,
app: null,
build: { id: BUILD_ID, gitCommitHash: 'abc', app: null },
});
expectWorkerApiCall('application', 'GET', { signal: expect.any(AbortSignal), retries: 0 });
expect(ctx.graphqlClient.query).not.toHaveBeenCalled();
}
);
it('downloads the session build from the worker API archive URL without an Authorization header', async () => {
const ctx = createCtx({ withToken: true });
jest.mocked(turtleFetch).mockResolvedValueOnce(
jsonResponse({
data: {
build: {
id: BUILD_ID,
gitCommitHash: null,
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
},
},
})
);
jest.mocked(fetch).mockResolvedValueOnce(new Response('stop here', { status: 500 }));
await expect(
downloadBuildAsync({
logger,
buildId: BUILD_ID,
graphqlClient: ctx.graphqlClient,
deviceRunSessionWorkerApi: new DeviceRunSessionWorkerApi(API_V2_BASE_URL, TOKEN),
robotAccessToken: ROBOT_ACCESS_TOKEN,
extensions: ['app'],
})
).rejects.toThrow('Unexpected response from server (500): stop here');
expectWorkerApiCall('application', 'GET');
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
APPLICATION_ARCHIVE_URL,
expect.objectContaining({ headers: undefined })
);
expect(ctx.graphqlClient.query).not.toHaveBeenCalled();
});
it('refuses to download a build that is not the session build', async () => {
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { build: null } }));
const error = await downloadBuildAsync({
logger,
buildId: BUILD_ID,
graphqlClient: createCtx({ withToken: true }).graphqlClient,
deviceRunSessionWorkerApi: new DeviceRunSessionWorkerApi(API_V2_BASE_URL, TOKEN),
robotAccessToken: ROBOT_ACCESS_TOKEN,
extensions: ['app'],
}).catch(err => err);
expect(error).toBeInstanceOf(SystemError);
expect(error.message).toContain(
`Could not fetch build ${BUILD_ID}: this device run session runs no build`
);
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('reports a rejected build lookup as a system error, because the job sends no user input', async () => {
jest.mocked(turtleFetch).mockResolvedValueOnce(
jsonResponse(
{
errors: [
{
code: 'UNAUTHORIZED_ERROR',
message: 'This device run session no longer accepts worker requests.',
},
],
},
403
)
);
const error = await downloadBuildAsync({
logger,
buildId: BUILD_ID,
graphqlClient: createCtx({ withToken: true }).graphqlClient,
deviceRunSessionWorkerApi: new DeviceRunSessionWorkerApi(API_V2_BASE_URL, TOKEN),
robotAccessToken: ROBOT_ACCESS_TOKEN,
extensions: ['app'],
}).catch(err => err);
expect(error).toBeInstanceOf(SystemError);
expect(error.message).toContain(
`Could not fetch build ${BUILD_ID} for this device run session: GET /v2/device-run-session-worker/application failed with status 403: This device run session no longer accepts worker requests.`
);
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
});
describe('without a device run session token', () => {
it('polls the session status with GraphQL', async () => {
const ctx = createCtx({ withToken: false });
jest
.mocked(ctx.graphqlClient.query)
.mockReturnValueOnce(
graphqlResult({ deviceRunSessions: { byId: { id: 'drs-id', status: 'STOPPED' } } }) as never
);
await waitForDeviceRunSessionStoppedAsync({ ctx, deviceRunSessionId: 'drs-id', logger });
expect(ctx.graphqlClient.query).toHaveBeenCalledWith(expect.anything(), {
deviceRunSessionId: 'drs-id',
});
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
});
it('stops an idle session with GraphQL', async () => {
const ctx = createCtx({ withToken: false });
jest.mocked(ctx.graphqlClient.mutation).mockReturnValueOnce(
graphqlResult({
deviceRunSession: { ensureDeviceRunSessionStopped: { id: 'drs-id', status: 'STOPPED' } },
}) as never
);
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId: 'drs-id',
logger,
idleTimeout: { maxIdleTimeMinutes: 0, getLastEventObservedAt: () => undefined },
});
expect(ctx.graphqlClient.mutation).toHaveBeenCalledWith(expect.anything(), {
deviceRunSessionId: 'drs-id',
});
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
});
it('starts the session with GraphQL', async () => {
const ctx = createCtx({ withToken: false });
jest.mocked(ctx.graphqlClient.mutation).mockReturnValueOnce(
graphqlResult({
deviceRunSession: { startDeviceRunSession: { id: 'drs-id', status: 'IN_PROGRESS' } },
}) as never
);
await uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId: 'drs-id',
remoteConfig: { type: 'agent-device' },
logger,
});
expect(ctx.graphqlClient.mutation).toHaveBeenCalledWith(expect.anything(), {
deviceRunSessionId: 'drs-id',
remoteConfig: { type: 'agent-device' },
});
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
});
it('fetches TURN ICE servers from the device run sessions route with the robot token', async () => {
const ctx = createCtx({ withToken: false });
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { iceServers: [] } }));
await fetchWebPreviewTurnArgsAsync(ctx, { env, logger });
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
'https://api.expo.test/v2/device-run-sessions/drs-id/turn-ice-servers',
'POST',
expect.objectContaining({
headers: { Authorization: `Bearer ${ROBOT_ACCESS_TOKEN}` },
timeout: 5000,
retries: 1,
})
);
});
it('creates artifact upload sessions with GraphQL', async () => {
const ctx = createCtx({ withToken: false });
jest.mocked(ctx.graphqlClient.mutation).mockReturnValueOnce(
graphqlResult({
deviceRunSession: {
createArtifactUploadSession: {
uploadSession: { url: 'https://uploads.expo.test/artifact', headers: {} },
},
},
}) as never
);
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 200 }));
await uploadDeviceRunSessionArtifactAsync(ctx, {
deviceRunSessionId: 'drs-id',
artifactId: 'artifact-id',
name: 'Report',
filename: 'report.json',
kind: undefined,
size: 4,
openStream: () => Readable.from(Buffer.from('data')),
});
expect(ctx.graphqlClient.mutation).toHaveBeenCalled();
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
});
it('creates the event log upload session with GraphQL', async () => {
const ctx = createCtx({ withToken: false });
jest.mocked(ctx.graphqlClient.mutation).mockReturnValueOnce(
graphqlResult({
deviceRunSession: {
createEventLogUploadSession: {
uploadSession: { url: 'https://uploads.expo.test/events', headers: {} },
},
},
}) as never
);
const collection = await startDeviceRunSessionEventCollectionAsync({
ctx,
deviceRunSessionId: 'drs-id',
source: eventSource,
logger,
});
await collection.stopAsync();
expect(ctx.graphqlClient.mutation).toHaveBeenCalledWith(expect.anything(), {
deviceRunSessionId: 'drs-id',
});
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
});
it('creates the preview upload session with GraphQL', async () => {
const ctx = createCtx({ withToken: false });
jest.mocked(ctx.graphqlClient.mutation).mockReturnValueOnce(
graphqlResult({
deviceRunSession: {
createPreviewUploadSession: {
uploadSession: { url: 'https://uploads.expo.test/preview', headers: {} },
},
},
}) as never
);
jest.mocked(fetch).mockResolvedValue(new Response('', { status: 200 }));
const preview = startDeviceRunSessionPreview({
ctx,
deviceRunSessionId: 'drs-id',
captureAsync: async () => Buffer.from('webp'),
logger,
});
await waitForCallAsync(jest.mocked(fetch));
await preview.stopAsync();
expect(ctx.graphqlClient.mutation).toHaveBeenCalled();
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
});
it('loads the screenshot naming details with GraphQL', async () => {
const ctx = createCtx({ withToken: false });
jest.mocked(ctx.graphqlClient.query).mockReturnValueOnce(
graphqlResult({
deviceRunSessions: {
byId: { platform: 'ANDROID', app: { slug: 'app' }, build: null },
},
}) as never
);
await expect(loadScreenshotSessionAsync(ctx, 'drs-id', logger)).resolves.toEqual({
platform: 'ANDROID',
app: { slug: 'app' },
build: null,
});
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
});
it('looks up the build archive URL with GraphQL', async () => {
const ctx = createCtx({ withToken: false });
jest.mocked(ctx.graphqlClient.query).mockReturnValueOnce(
graphqlResult({
builds: {
byId: {
id: BUILD_ID,
platform: 'IOS',
artifacts: { applicationArchiveUrl: APPLICATION_ARCHIVE_URL },
},
},
}) as never
);
jest.mocked(fetch).mockResolvedValueOnce(new Response('stop here', { status: 500 }));
await expect(
downloadBuildAsync({
logger,
buildId: BUILD_ID,
graphqlClient: ctx.graphqlClient,
deviceRunSessionWorkerApi: null,
robotAccessToken: ROBOT_ACCESS_TOKEN,
extensions: ['app'],
})
).rejects.toThrow('Unexpected response from server (500)');
expect(ctx.graphqlClient.query).toHaveBeenCalled();
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
APPLICATION_ARCHIVE_URL,
expect.objectContaining({ headers: { Authorization: `Bearer ${ROBOT_ACCESS_TOKEN}` } })
);
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,63 @@
import spawn from '@expo/turtle-spawn';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { readIosApplicationIdentifierAsync } from '../iosAppArtifact';
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
describe(readIosApplicationIdentifierAsync, () => {
let directory: string;
let artifactPath: string;
beforeEach(async () => {
jest.mocked(spawn).mockReset();
directory = await mkdtemp(path.join(os.tmpdir(), 'ios-app-artifact-'));
artifactPath = path.join(directory, 'Example.app');
await mkdir(artifactPath);
});
afterEach(async () => {
await rm(directory, { recursive: true, force: true });
});
it('reads app metadata without installing or launching', async () => {
jest.mocked(spawn).mockResolvedValue({ stdout: 'com.example.app\n', stderr: '' } as never);
await expect(readIosApplicationIdentifierAsync({ artifactPath, env: {} })).resolves.toBe(
'com.example.app'
);
expect(jest.mocked(spawn).mock.calls).toEqual([
[
'plutil',
['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', path.join(artifactPath, 'Info.plist')],
{ stdio: 'pipe', env: {} },
],
]);
});
it.each(['missing', 'file', 'wrong extension'])('rejects an invalid artifact: %s', async kind => {
await rm(artifactPath, { recursive: true });
if (kind === 'file') {
await writeFile(artifactPath, 'not an app directory');
} else if (kind === 'wrong extension') {
artifactPath = path.join(directory, 'Example.ipa');
await mkdir(artifactPath);
}
await expect(
readIosApplicationIdentifierAsync({ artifactPath, env: {} })
).rejects.toMatchObject({ errorCode: 'EAS_INSTALL_BUILD_INVALID_ARTIFACT' });
expect(spawn).not.toHaveBeenCalled();
});
it('rejects a missing bundle identifier', async () => {
jest.mocked(spawn).mockResolvedValue({ stdout: ' ', stderr: '' } as never);
await expect(
readIosApplicationIdentifierAsync({ artifactPath, env: {} })
).rejects.toMatchObject({ errorCode: 'EAS_INSTALL_BUILD_MISSING_IDENTIFIER' });
});
});
@@ -0,0 +1,191 @@
import { BuildRuntimePlatform } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../../utils/IosSimulatorUtils';
import { resolveIosSessionStartupAsync } from '../iosSimulatorSession';
import { readIosApplicationIdentifierAsync } from '../iosAppArtifact';
import {
resolveLocalEgressServeSimBootEnvironmentAsync,
startLocalEgressGuardRelayAsync,
} from '../localEgressGuard';
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('../../../utils/IosSimulatorUtils');
jest.mock('../iosAppArtifact', () => ({ readIosApplicationIdentifierAsync: jest.fn() }));
jest.mock('../localEgressGuard', () => ({
resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn(),
startLocalEgressGuardRelayAsync: jest.fn(),
}));
const mockedSpawn = jest.mocked(spawn);
const mockedUtils = jest.mocked(IosSimulatorUtils);
describe('serve-sim Simulator preparation', () => {
const hostOptions = {
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: {},
logger: createMockLogger(),
};
beforeEach(() => {
jest.clearAllMocks();
mockedUtils.getAvailableDevicesAsync.mockResolvedValue([
{
udid: 'selected-udid',
name: 'iPhone 17',
isAvailable: true,
deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-17',
},
] as never);
mockedUtils.resolveUdidAsync.mockResolvedValue('selected-udid' as IosSimulatorUuid);
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(null);
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as never);
jest.mocked(readIosApplicationIdentifierAsync).mockResolvedValue('dev.example.app');
});
it.each([
['argument count', Array(257).fill('a')],
['argument length', ['a'.repeat(9000)]],
['NUL argument', ['a\u0000b']],
['total UTF-8 bytes', Array(8).fill('😃'.repeat(4096))],
])('rejects invalid launch %s before Simulator preparation', async (_, launchArgs) => {
await expect(
resolveIosSessionStartupAsync({
...hostOptions,
bootSimulator: true,
launchAppIdentifier: 'dev.example.app',
launchArgs,
})
).rejects.toThrow('iOS launch_args');
expect(mockedUtils.getAvailableDevicesAsync).not.toHaveBeenCalled();
expect(mockedUtils.resolveUdidAsync).not.toHaveBeenCalled();
expect(resolveLocalEgressServeSimBootEnvironmentAsync).not.toHaveBeenCalled();
});
it.each([
['an oversized URL', `https://example.test/${'a'.repeat(8192)}`],
['NUL in a URL', 'exp://example.test/a\0b'],
])('rejects %s before Simulator preparation', async (_, openUrl) => {
await expect(
resolveIosSessionStartupAsync({
...hostOptions,
bootSimulator: true,
launchAppIdentifier: 'dev.example.app',
openUrl,
})
).rejects.toThrow('iOS open_url');
expect(mockedUtils.getAvailableDevicesAsync).not.toHaveBeenCalled();
expect(mockedUtils.resolveUdidAsync).not.toHaveBeenCalled();
expect(resolveLocalEgressServeSimBootEnvironmentAsync).not.toHaveBeenCalled();
});
it('keeps an explicit launch identifier independent of the installed app', async () => {
const startup = await resolveIosSessionStartupAsync({
...hostOptions,
installAppPath: '/tmp/App.app',
launchAppIdentifier: 'another.installed.app',
});
expect(startup.launch.launchAppIdentifier).toBe('another.installed.app');
expect(readIosApplicationIdentifierAsync).not.toHaveBeenCalled();
});
it('infers the launch identifier from the downloaded app', async () => {
const startup = await resolveIosSessionStartupAsync({
...hostOptions,
installAppPath: '/tmp/App.app',
});
expect(startup.launch.launchAppIdentifier).toBe('dev.example.app');
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({
artifactPath: '/tmp/App.app',
env: {},
});
});
it('does no Simulator work on Android and rejects iOS installation inputs', async () => {
const options = { ...hostOptions, runtimePlatform: BuildRuntimePlatform.LINUX };
await expect(resolveIosSessionStartupAsync(options)).resolves.toEqual({
launch: { launchArgs: [], launchAppIdentifier: undefined, openUrl: undefined },
});
await expect(
resolveIosSessionStartupAsync({ ...options, installAppPath: '/tmp/App.app' })
).rejects.toThrow('only supported for iOS');
expect(mockedUtils.resolveUdidAsync).not.toHaveBeenCalled();
});
it('preserves older workflows that already prepared the Simulator and app', async () => {
const startup = await resolveIosSessionStartupAsync({
...hostOptions,
launchAppIdentifier: 'previously.installed.app',
});
expect(startup.iosStartup).toBeUndefined();
expect(startup.launch.launchAppIdentifier).toBe('previously.installed.app');
expect(mockedUtils.resolveUdidAsync).not.toHaveBeenCalled();
expect(resolveLocalEgressServeSimBootEnvironmentAsync).not.toHaveBeenCalled();
expect(spawn).not.toHaveBeenCalled();
});
it('prepares a session without an app when the workflow requests owned startup', async () => {
const startup = await resolveIosSessionStartupAsync({ ...hostOptions, bootSimulator: true });
expect(startup.iosStartup?.iosSimulatorUdid).toBe('SELECTED-UDID');
expect(startup.launch.launchAppIdentifier).toBeUndefined();
});
it.each(['Booted', 'Booting', 'Shutdown'])(
'prepares guarded startup on a %s device',
async state => {
const bootEnv = { SERVE_SIM_ADDITIONAL_DYLIBS: '/guard.dylib' };
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(bootEnv);
mockedUtils.getDeviceAsync.mockResolvedValue({ state } as never);
const startup = await resolveIosSessionStartupAsync({ ...hostOptions, bootSimulator: true });
expect(startup.iosStartup).toEqual({
iosSimulatorUdid: 'SELECTED-UDID',
bootEnv,
installAppPath: undefined,
});
if (state === 'Shutdown') {
expect(spawn).not.toHaveBeenCalled();
} else {
expect(spawn).toHaveBeenCalledWith(
'xcrun',
['simctl', 'shutdown', 'SELECTED-UDID'],
expect.anything()
);
}
expect(startLocalEgressGuardRelayAsync).toHaveBeenCalled();
expect(mockedUtils.bootAsync).not.toHaveBeenCalled();
}
);
it('resolves an explicit Simulator without booting or configuring it', async () => {
const startup = await resolveIosSessionStartupAsync({
...hostOptions,
deviceIdentifier: 'iPhone 17',
});
expect(startup.iosStartup?.iosSimulatorUdid).toBe('SELECTED-UDID');
expect(mockedUtils.resolveUdidAsync).toHaveBeenCalledWith({
deviceIdentifier: 'iPhone 17',
env: {},
});
expect(mockedUtils.getAvailableDevicesAsync).not.toHaveBeenCalled();
expect(mockedUtils.bootAsync).not.toHaveBeenCalled();
expect(mockedUtils.startAsync).not.toHaveBeenCalled();
expect(mockedUtils.enableAccessibilitySettingsAsync).not.toHaveBeenCalled();
});
it('selects the default iPhone without booting', async () => {
mockedUtils.getAvailableDevicesAsync.mockResolvedValue([
{ name: 'iPad', udid: 'ipad' },
{ name: 'iPhone 17 Pro', udid: 'pro' },
{ name: 'iPhone 17', udid: 'base' },
] as never);
await resolveIosSessionStartupAsync({ ...hostOptions, bootSimulator: true });
expect(mockedUtils.resolveUdidAsync).toHaveBeenCalledWith({
deviceIdentifier: 'base',
env: {},
});
expect(mockedUtils.bootAsync).not.toHaveBeenCalled();
mockedUtils.getAvailableDevicesAsync.mockResolvedValue([]);
await expect(
resolveIosSessionStartupAsync({ ...hostOptions, bootSimulator: true })
).rejects.toThrow('Could not find an iPhone');
});
});
@@ -437,11 +437,13 @@ describe(configureSimulatorProxyEnvironmentAsync, () => {
});
describe(startChiselServerAsync, () => {
const logger = { warn: jest.fn() } as unknown as bunyan;
const options = {
chiselPath: '/tmp/chisel',
controlPort: 52001,
authfilePath: '/tmp/authfile.json',
env: { PATH: '/bin', AUTH: 'unexpected:password' },
logger,
};
let output: string;
let stopAsync: jest.Mock;
@@ -483,7 +485,9 @@ describe(startChiselServerAsync, () => {
expect(stopAsync).not.toHaveBeenCalled();
});
it('rejects and stops a child that prints its fingerprint then fails to bind', async () => {
it('keeps the bind error and warns when a child that failed to bind cannot be stopped', async () => {
const stopError = new Error('drain timed out');
stopAsync.mockRejectedValueOnce(stopError);
const started = startChiselServerAsync(options);
const rejected = expect(started).rejects.toThrow('address already in use');
output += 'listen tcp 127.0.0.1:52001: bind: address already in use\n';
@@ -493,16 +497,27 @@ describe(startChiselServerAsync, () => {
await jest.advanceTimersByTimeAsync(250);
await rejected;
expect(stopAsync).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
{ err: stopError },
'Could not stop the reverse tunnel server after it failed to start.'
);
});
it('stops the child when startup is cancelled', async () => {
it('keeps the cancellation and warns when the cancelled child cannot be stopped', async () => {
const stopError = new Error('drain timed out');
stopAsync.mockRejectedValueOnce(stopError);
const controller = new AbortController();
const cancelled = new Error('cancelled');
const started = startChiselServerAsync({ ...options, signal: controller.signal });
const rejected = expect(started).rejects.toThrow('cancelled');
controller.abort(new Error('cancelled'));
const rejected = expect(started).rejects.toBe(cancelled);
controller.abort(cancelled);
await jest.advanceTimersByTimeAsync(250);
await rejected;
expect(stopAsync).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
{ err: stopError },
'Could not stop the reverse tunnel server after it failed to start.'
);
});
});
@@ -21,6 +21,8 @@ import {
rebindLocalEgressGuardRelays,
resolveEgressGuardLibraryAsync,
resolveLocalEgressBootEnvironmentAsync,
resolveLocalEgressServeSimBootEnvironmentAsync,
startLocalEgressGuardRelayAsync,
stopLocalEgressGuardRelaysAsync,
verifyLocalEgressGuardAsync,
} from '../localEgressGuard';
@@ -401,6 +403,38 @@ describe(installLocalEgressGuardAsync, () => {
expect(sessionLogger.lines.at(-1)?.msg).toContain('refused 1 connection attempt(s)');
});
it('still installs the guard when its event log cannot be created', async () => {
const handoffPath = path.join(dir, 'handoff.json');
await writeLocalEgressHandoffAsync(handoff, handoffPath);
const libraryPath = path.join(dir, 'egress-guard.dylib');
const blockedDirectory = path.join(dir, 'not-a-directory');
await fs.promises.writeFile(blockedDirectory, 'file');
await expect(
installLocalEgressGuardAsync({
udid: 'u' as any,
env: process.env,
logger,
handoffPath,
libraryPath,
logPath: path.join(blockedDirectory, 'guard.log'),
})
).resolves.toBe(true);
expect(mockedSetEnv).toHaveBeenCalledWith({
udid: 'u',
env: process.env,
variables: buildGuardLaunchdEnvironment({
libraryPath,
logPath: path.join(blockedDirectory, 'guard.log'),
mode: 'block',
}),
});
expect(
logger.lines.some(
line => line.level === 'warn' && line.msg.includes('They are still refused.')
)
).toBe(true);
});
it('fails the session when launchctl fails', async () => {
const handoffPath = path.join(dir, 'handoff.json');
await writeLocalEgressHandoffAsync(handoff, handoffPath);
@@ -469,6 +503,101 @@ describe(resolveLocalEgressBootEnvironmentAsync, () => {
});
});
describe(resolveLocalEgressServeSimBootEnvironmentAsync, () => {
let dir: string;
let handoffPath: string;
beforeEach(async () => {
dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'guard-serve-sim-'));
handoffPath = path.join(dir, 'handoff.json');
});
afterEach(async () => {
await fs.promises.rm(dir, { recursive: true, force: true });
});
it('does not require the guard or check for an ordinary session', async () => {
await expect(
resolveLocalEgressServeSimBootEnvironmentAsync({
handoffPath,
libraryPath: null,
checkPath: null,
})
).resolves.toBeNull();
});
it('requires both packaged files before handing startup to serve-sim', async () => {
await writeLocalEgressHandoffAsync(handoff, handoffPath);
await expect(
resolveLocalEgressServeSimBootEnvironmentAsync({ handoffPath, libraryPath: null })
).rejects.toThrow('missing bin/egress-guard.dylib');
await expect(
resolveLocalEgressServeSimBootEnvironmentAsync({
handoffPath,
libraryPath: '/w/bin/egress-guard.dylib',
checkPath: null,
})
).rejects.toThrow('missing bin/egress-guard-check');
});
it('passes the additional dylib and prefixed child variables without host DYLD injection', async () => {
await writeLocalEgressHandoffAsync(handoff, handoffPath);
const bootEnv = await resolveLocalEgressServeSimBootEnvironmentAsync({
handoffPath,
libraryPath: '/w/bin/egress-guard.dylib',
checkPath: '/w/bin/egress-guard-check',
logPath: '/tmp/guard.log',
});
expect(bootEnv).toEqual({
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
SIMCTL_CHILD_EAS_EGRESS_GUARD_LOG: '/tmp/guard.log',
SIMCTL_CHILD_EAS_EGRESS_GUARD_MODE: 'block',
SIMCTL_CHILD_http_proxy: 'http://127.0.0.1:8899',
SIMCTL_CHILD_https_proxy: 'http://127.0.0.1:8899',
SIMCTL_CHILD_HTTP_PROXY: 'http://127.0.0.1:8899',
SIMCTL_CHILD_HTTPS_PROXY: 'http://127.0.0.1:8899',
SIMCTL_CHILD_grpc_proxy: 'http://127.0.0.1:8899',
SIMCTL_CHILD_no_proxy: 'localhost,127.0.0.1,::1',
SIMCTL_CHILD_NO_PROXY: 'localhost,127.0.0.1,::1',
});
});
});
describe(startLocalEgressGuardRelayAsync, () => {
it('relays boot events once after repeated starts without changing Simulator state', async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'guard-relay-'));
const logPath = path.join(dir, 'guard.log');
const logger = createLogger();
mockedSetEnv.mockClear();
await fs.promises.writeFile(
logPath,
'eas-egress-guard\tSpringBoard\t4001\tconnect\tblocked\t1.1.1.1:443\tNetwork\n'
);
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] });
try {
await startLocalEgressGuardRelayAsync({ logger, logPath, tailIntervalMs: 5 });
await startLocalEgressGuardRelayAsync({ logger, logPath, tailIntervalMs: 5 });
await fs.promises.appendFile(
logPath,
'eas-egress-guard\tMyApp\t4002\tconnect\tblocked\t1.1.1.1:443\tNetwork\n'
);
await jest.advanceTimersByTimeAsync(20);
await stopLocalEgressGuardRelaysAsync(logger);
expect(mockedSetEnv).not.toHaveBeenCalled();
expect(
logger.lines.filter(line => /refused connect from SpringBoard/.test(line.msg))
).toHaveLength(1);
expect(logger.lines.filter(line => /refused connect from MyApp/.test(line.msg))).toHaveLength(
1
);
expect(logger.lines.at(-1)?.msg).toContain('refused 2 connection attempt(s)');
} finally {
await stopLocalEgressGuardRelaysAsync(logger);
jest.clearAllTimers();
jest.useRealTimers();
await fs.promises.rm(dir, { recursive: true, force: true });
}
});
});
describe(parseGuardCoverage, () => {
const ps = [
' 1 0 /sbin/launchd',
@@ -0,0 +1,119 @@
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { preparePreinstalledWdaAsync } from '../preinstalledWda';
jest.unmock('node:fs');
jest.unmock('@expo/turtle-spawn');
jest.unmock('@expo/logger');
const realSetTimeout = setTimeout;
const realClearTimeout = clearTimeout;
const sleep = (ms: number): Promise<void> => new Promise(resolve => realSetTimeout(resolve, ms));
async function waitUntil(predicate: () => boolean): Promise<void> {
const deadline = Date.now() + 3_000;
while (!predicate()) {
if (Date.now() >= deadline) {
throw new Error('Subprocess fixture did not reach the expected state');
}
await sleep(20);
}
}
function isRunning(pid: number): boolean {
try {
// Orphaned descendants can remain zombies until init reaps them.
const state = execFileSync('ps', ['-p', String(pid), '-o', 'stat='])
.toString()
.trim();
return state.length > 0 && !state.startsWith('Z');
} catch {
return false;
}
}
it('kills a stalled downloader and its descendants before fallback removes the artifact', async () => {
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'wda-download-timeout-'));
const appiumHome = path.join(root, 'appium');
const outdir = path.join(appiumHome, 'preinstalled-wda');
const pidFiles = ['parent', 'child', 'grandchild'].map(name => path.join(root, `${name}.pid`));
const executable = path.join(root, 'appium.cjs');
const logger = { child: () => logger, info: jest.fn(), warn: jest.fn() };
let preparation: Promise<Record<string, unknown>> | undefined;
let fallbackDeadline: NodeJS.Timeout | undefined;
try {
await fs.promises.mkdir(outdir, { recursive: true });
await fs.promises.writeFile(path.join(outdir, 'incomplete'), 'partial download');
await fs.promises.writeFile(path.join(appiumHome, 'keep'), 'unrelated');
await fs.promises.writeFile(
executable,
`#!${process.execPath}
const fs = require('node:fs');
const path = require('node:path');
const {spawn} = require('node:child_process');
const role = process.argv[2] === 'driver' ? 'parent' : process.argv[2];
const outdir = role === 'parent' ? process.argv[process.argv.indexOf('--outdir') + 1] : process.argv[3];
fs.writeFileSync(path.join(__dirname, role + '.pid'), String(process.pid));
if (role !== 'grandchild') {
spawn(process.execPath, [__filename, role === 'parent' ? 'child' : 'grandchild', outdir], {stdio: 'inherit'});
}
setInterval(() => {
if (role === 'grandchild') {
fs.mkdirSync(outdir, {recursive: true});
fs.writeFileSync(path.join(outdir, 'incomplete'), 'still downloading');
}
}, 50);
`
);
await fs.promises.chmod(executable, 0o700);
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'Date'] });
preparation = preparePreinstalledWdaAsync({
runtimeIdentifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5',
appiumHome,
appiumBinPath: executable,
appiumEnv: {},
logger: logger as never,
});
await waitUntil(() => pidFiles.every(file => fs.existsSync(file)));
const pids = pidFiles.map(file => Number(fs.readFileSync(file, 'utf8')));
expect(pids.every(isRunning)).toBe(true);
jest.advanceTimersByTime(60_000);
await expect(
Promise.race([
preparation,
new Promise((_, reject) => {
fallbackDeadline = realSetTimeout(
() => reject(new Error('Fallback remained blocked by inherited downloader pipes')),
2_000
);
}),
])
).resolves.toEqual({});
expect(logger.warn.mock.calls[0]?.[0].err.signal).toBe('SIGKILL');
await waitUntil(() => pids.every(pid => !isRunning(pid)));
await sleep(100);
expect(fs.existsSync(outdir)).toBe(false);
expect(fs.readFileSync(path.join(appiumHome, 'keep'), 'utf8')).toBe('unrelated');
expect(logger.warn).toHaveBeenCalled();
} finally {
realClearTimeout(fallbackDeadline);
jest.useRealTimers();
// Also clean up the fixture if an assertion fails before the timeout fires.
for (const file of pidFiles) {
if (fs.existsSync(file)) {
try {
const pid = Number(fs.readFileSync(file, 'utf8'));
if (isRunning(pid)) {
process.kill(pid, 'SIGKILL');
}
} catch {}
}
}
await preparation?.catch(() => {});
await fs.promises.rm(root, { recursive: true, force: true });
}
}, 10_000);
@@ -0,0 +1,80 @@
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { preparePreinstalledWdaAsync } from '../preinstalledWda';
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
const appiumHome = path.join(os.tmpdir(), 'owned-appium-home');
const outdir = path.join(appiumHome, 'preinstalled-wda');
const runner = path.join(outdir, 'WebDriverAgentRunner-Runner.app');
const logger = { info: jest.fn(), warn: jest.fn() };
const options = {
runtimeIdentifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5',
appiumHome,
appiumBinPath: path.join(appiumHome, 'node_modules', '.bin', 'appium'),
appiumEnv: { APPIUM_HOME: appiumHome },
logger: logger as never,
};
it('downloads the driver-matched Simulator runner and retains its XCTest frameworks', async () => {
jest.mocked(spawn).mockImplementation((async () => {
await fs.promises.mkdir(path.join(runner, 'Frameworks', 'XCTest.framework'), {
recursive: true,
});
return { stdout: '' };
}) as never);
await expect(preparePreinstalledWdaAsync(options)).resolves.toEqual({
'appium:usePreinstalledWDA': true,
'appium:prebuiltWDAPath': runner,
});
expect(spawn).toHaveBeenCalledWith(
options.appiumBinPath,
[
'driver',
'run',
'xcuitest',
'download-wda',
'--',
'--outdir',
outdir,
'--platform',
'iOS',
'--kind',
'sim',
],
expect.objectContaining({ env: options.appiumEnv, detached: true })
);
expect(
(await fs.promises.stat(path.join(runner, 'Frameworks', 'XCTest.framework'))).isDirectory()
).toBe(true);
});
it.each([
undefined,
'com.apple.CoreSimulator.SimRuntime.iOS-16-4',
'com.apple.CoreSimulator.SimRuntime.tvOS-26-5',
])('keeps the existing startup for unsupported runtime %s', async runtimeIdentifier => {
await expect(preparePreinstalledWdaAsync({ ...options, runtimeIdentifier })).resolves.toEqual({});
expect(spawn).not.toHaveBeenCalled();
});
it('falls back after a failed download and removes only its own incomplete artifact', async () => {
await fs.promises.mkdir(outdir, { recursive: true });
const sibling = path.join(appiumHome, 'keep');
await fs.promises.writeFile(sibling, 'keep');
jest.mocked(spawn).mockRejectedValue(new Error('download unavailable'));
await expect(preparePreinstalledWdaAsync(options)).resolves.toEqual({});
await expect(fs.promises.stat(outdir)).rejects.toThrow();
await expect(fs.promises.readFile(sibling, 'utf8')).resolves.toBe('keep');
expect(logger.warn).toHaveBeenCalled();
});
it('does not enable preinstalled startup when the download produces no runner', async () => {
jest.mocked(spawn).mockResolvedValue({ stdout: '' } as never);
await expect(preparePreinstalledWdaAsync(options)).resolves.toEqual({});
});
@@ -0,0 +1,139 @@
import { type bunyan } from '@expo/logger';
import { createProcessOutput } from '../processOutput';
const logger = { info: jest.fn() } as unknown as bunyan;
beforeEach(() => jest.clearAllMocks());
it('redacts split credentials before publishing complete lines', () => {
const output = createProcessOutput(logger, ['turn secret']);
output.stdout.append('https://preview.test/?tok');
output.stdout.append('en=preview-secret&ok=true\n');
output.stderr.append('Authorization: Bea');
output.stderr.append('rer tool-secret\n{"credential":"turn secret"}\n');
expect(logger.info).toHaveBeenNthCalledWith(
1,
{ source: 'stdout' },
'https://preview.test/?token=[REDACTED]&ok=true'
);
expect(logger.info).toHaveBeenNthCalledWith(
2,
{ source: 'stderr' },
'Authorization: Bearer [REDACTED]'
);
expect(output.getOutput()).not.toMatch(/preview-secret|tool-secret|turn secret/);
});
it.each([
[
'argent link argent://[email protected]:5678',
'argent link argent://[REDACTED]@127.0.0.1:5678',
],
[
'argent link argent://encoded%[email protected]:5678',
'argent link argent://[REDACTED]@127.0.0.1:5678',
],
[
'argent link --host 127.0.0.1 --port 5678 --token startup-secret',
'argent link --host 127.0.0.1 --port 5678 --token [REDACTED]',
],
['(or: argent link --token startup-secret)', '(or: argent link --token [REDACTED])'],
[
'argent link --token "quoted \\"secret\\"" --port 5678',
'argent link --token [REDACTED] --port 5678',
],
["argent link --token='quoted secret' --port 5678", 'argent link --token=[REDACTED] --port 5678'],
])('redacts startup credentials at every chunk boundary: %s', (line, expected) => {
for (let split = 0; split <= line.length; split++) {
jest.mocked(logger.info).mockClear();
const output = createProcessOutput(logger);
output.stdout.append(line.slice(0, split));
expect(logger.info).not.toHaveBeenCalled();
output.stdout.append(line.slice(split) + '\n');
expect(logger.info).toHaveBeenCalledWith({ source: 'stdout' }, expected);
expect(output.getOutput()).toBe(expected + '\n');
}
});
it('keeps stdout and stderr fragments separate and decodes split UTF-8', () => {
const output = createProcessOutput(logger);
const bytes = Buffer.from('hello 🌍\n');
output.stdout.append(bytes.subarray(0, 8));
output.stderr.append('error\n');
output.stdout.append(bytes.subarray(8));
expect(logger.info).toHaveBeenNthCalledWith(1, { source: 'stderr' }, 'error');
expect(logger.info).toHaveBeenNthCalledWith(2, { source: 'stdout' }, 'hello 🌍');
});
it('omits overlong lines completely and keeps logging the following diagnostics', () => {
const output = createProcessOutput(logger);
output.stdout.append('https://preview.test/?token=');
output.stdout.append('secret'.repeat(20_000));
expect(logger.info).not.toHaveBeenCalled();
output.stdout.append('\nfatal diagnostic\n');
expect(logger.info).toHaveBeenNthCalledWith(
1,
{ source: 'stdout' },
'[Overlong output line omitted.]'
);
expect(logger.info).toHaveBeenNthCalledWith(2, { source: 'stdout' }, 'fatal diagnostic');
expect(output.getOutput()).not.toContain('secret');
});
it('splits multi-line and CRLF chunks and keeps a line exactly at the length limit', () => {
const output = createProcessOutput(logger);
const limit = 16 * 1024;
output.stdout.append('first\r\nsecond\nthi');
output.stdout.append('rd\n' + 'x'.repeat(limit - 1));
output.stdout.append('x\n' + 'y'.repeat(limit) + 'y\nafter');
output.finish();
expect(jest.mocked(logger.info).mock.calls.map(([, line]) => line)).toEqual([
'first',
'second',
'third',
'x'.repeat(limit),
'[Overlong output line omitted.]',
'after',
]);
});
it('keeps unfinished stdout and stderr lines apart in diagnostics', () => {
const output = createProcessOutput(logger);
output.stdout.append('done\nDownloading 45%');
output.stderr.append('fatal: port in use');
expect(output.getOutput()).toBe('done\nDownloading 45%\nfatal: port in use');
output.stdout.append('\n');
expect(output.getOutput()).toBe('done\nDownloading 45%\nfatal: port in use');
});
it('redacts final partial lines and bounds diagnostics without capping live output', () => {
const output = createProcessOutput(logger);
for (let index = 0; index < 100; index++) {
output.stdout.append('x'.repeat(1023) + '\n');
}
output.stderr.append('token=final-secret');
output.finish();
expect(logger.info).toHaveBeenCalledTimes(101);
expect(logger.info).toHaveBeenLastCalledWith({ source: 'stderr' }, 'token=[REDACTED]');
expect(output.getOutput()).toHaveLength(64 * 1024);
expect(output.getOutput()).toContain('token=[REDACTED]');
});
it('applies newly learned secrets to pending output and retained diagnostics', () => {
const secrets: string[] = [];
const output = createProcessOutput(undefined, secrets);
output.stdout.append('opaque-secret\n');
output.stderr.append('opaque-secret');
secrets.push('opaque-secret');
expect(output.getOutput()).not.toContain('opaque-secret');
});
it('redacts JSON token fields with escaped quotes before the token is known', () => {
const output = createProcessOutput(logger);
output.stdout.append(JSON.stringify({ authToken: 'a"b', controlToken: 'android-secret' }) + '\n');
expect(logger.info).toHaveBeenCalledWith(
{ source: 'stdout' },
'{"authToken":"[REDACTED]","controlToken":"[REDACTED]"}'
);
});
@@ -0,0 +1,156 @@
import { type bunyan } from '@expo/logger';
import { setTimeout as delay } from 'node:timers/promises';
import { spawnDetached } from '../remoteDeviceRunSession';
jest.unmock('node:fs');
jest.unmock('node:fs/promises');
jest.mock('../../../sentry');
it.each([0, 1])(
'streams redacted stdout/stderr and drains final partial output on exit %s',
async exitCode => {
const logger = { info: jest.fn() } as unknown as bunyan;
const handle = spawnDetached({
command: process.execPath,
args: [
'-e',
`
console.log('stdout-marker');
console.error('https://preview.test/?token=startup-secret');
process.stdout.write('argent link argent://startup-');
setTimeout(() => {
console.log('[email protected]:5678');
console.log('argent link --host 127.0.0.1 --port 5678 --token startup-secret');
}, 25);
setTimeout(() => {
process.stderr.write('final diagnostic');
process.exit(${exitCode});
}, 100);
`,
],
env: {},
logger,
});
try {
for (let tries = 0; tries < 100 && !handle.getExitError(); tries++) {
await delay(20);
}
await handle.stopAsync();
expect(handle.getExitError()?.message).toContain(`code ${exitCode}`);
expect(logger.info).toHaveBeenCalledWith({ source: 'stdout' }, 'stdout-marker');
expect(logger.info).toHaveBeenCalledWith(
{ source: 'stderr' },
'https://preview.test/?token=[REDACTED]'
);
expect(logger.info).toHaveBeenCalledWith({ source: 'stderr' }, 'final diagnostic');
expect(logger.info).toHaveBeenCalledWith(
{ source: 'stdout' },
'argent link argent://[REDACTED]@127.0.0.1:5678'
);
expect(logger.info).toHaveBeenCalledWith(
{ source: 'stdout' },
'argent link --host 127.0.0.1 --port 5678 --token [REDACTED]'
);
expect(JSON.stringify(jest.mocked(logger.info).mock.calls)).not.toContain('startup-secret');
expect(handle.getOutput()).not.toContain('startup-secret');
} finally {
await handle.stopAsync();
}
}
);
it('drains descendant shutdown logs after the launcher has exited', async () => {
const logger = { info: jest.fn() } as unknown as bunyan;
const childScript = `
process.on('SIGTERM', () => setTimeout(() => {
console.error('descendant-shutdown-marker'); process.exit(0);
}, 150));
console.log('descendant-ready'); setInterval(() => {}, 1000);
`;
const handle = spawnDetached({
command: process.execPath,
args: [
'-e',
`
require('node:child_process').spawn(process.execPath, ['-e', ${JSON.stringify(childScript)}], { stdio: 'inherit' });
setTimeout(() => process.exit(0), 200);
`,
],
env: {},
logger,
});
try {
for (let tries = 0; tries < 100 && !handle.getExitError(); tries++) {
await delay(20);
}
expect(handle.getExitError()).toBeDefined();
await handle.stopAsync();
expect(logger.info).toHaveBeenCalledWith({ source: 'stderr' }, 'descendant-shutdown-marker');
} finally {
await handle.stopAsync();
}
}, 20_000);
it('fails a stop whose output a detached descendant still holds open, after flushing it', async () => {
const logger = { info: jest.fn() } as unknown as bunyan;
const lines = (source: string) =>
jest
.mocked(logger.info)
.mock.calls.filter(([fields]) => (fields as { source: string }).source === source)
.map(([, line]) => line as unknown as string);
// The descendant leaves the process group and keeps the inherited pipes until killed.
const escapedScript = `
process.stderr.on('error', () => {});
setInterval(() => console.error('escaped-tick'), 50);
setTimeout(() => process.exit(0), 30_000);
`;
const handle = spawnDetached({
command: process.execPath,
args: [
'-e',
`
const escaped = require('node:child_process').spawn(
process.execPath,
['-e', ${JSON.stringify(escapedScript)}],
{ stdio: 'inherit', detached: true }
);
console.error('escaped-pid ' + escaped.pid);
process.stdout.write('launcher partial');
setInterval(() => {}, 1000);
`,
],
env: {},
logger,
});
let escapedPid: number | undefined;
try {
for (let tries = 0; tries < 100; tries++) {
const pidLine = lines('stderr').find(line => line.startsWith('escaped-pid '));
escapedPid = pidLine ? Number(pidLine.split(' ')[1]) : undefined;
if (escapedPid !== undefined && lines('stderr').includes('escaped-tick')) {
break;
}
await delay(20);
}
expect(escapedPid).toBeDefined();
expect(lines('stdout')).toEqual([]);
await expect(handle.stopAsync()).rejects.toThrow(
'Process output drain timed out after 5000ms.'
);
expect(handle.getExitError()?.message).toBe('Process exited with signal SIGTERM.');
expect(lines('stdout')).toEqual(['launcher partial']);
expect(handle.getOutput()).toContain('launcher partial');
const ticks = lines('stderr').length;
await delay(250);
expect(lines('stderr')).toHaveLength(ticks);
} finally {
if (escapedPid !== undefined) {
try {
process.kill(escapedPid, 'SIGKILL');
} catch {}
}
await handle.stopAsync().catch(() => {});
}
}, 20_000);
@@ -4,6 +4,7 @@ import { BuildRuntimePlatform, BuildStepEnv, BuildStepInputValueTypeName } from
import spawn from '@expo/turtle-spawn';
import * as ngrok from '@ngrok/ngrok';
import { EventEmitter } from 'node:events';
import { mkdirSync, writeFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
@@ -26,29 +27,44 @@ import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
import { sleepAsync } from '../../../utils/retry';
import { uploadDeviceRunSessionScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
import {
createCorsOriginsInputProvider,
createFrameAncestorsInputProvider,
createIosSessionStartupInputProviders,
createPrivatePreviewUrlInputProvider,
createPublicPreviewInputProvider,
createServeSimLaunchInputProviders,
describeServeSimLaunch,
ensureFfmpegInstalledOnceAsync,
fetchWebPreviewTurnArgsAsync,
parseCorsOriginsInput,
parseFrameAncestorsInput,
parseServeSimLaunchInputs,
spawnDetached,
startNgrokTunnelAsync,
turnIceServersToWebPreviewArgs,
validateServeSimLaunchOptions,
waitForDeviceRunSessionStoppedAsync,
} from '../remoteDeviceRunSession';
import {
EXPO_DEVICE_HUB_STATE_DIR,
corsOriginArgs,
createExpoDeviceHubArgs,
createServeSimArgs,
frameAncestorArgs,
simulatorPreviewPageUrl,
startDeviceSessionHostAsync,
waitForWebPreviewReadyAsync,
websiteOrigin,
websiteOriginServeSimArgs,
websiteOriginArgs,
} from '../deviceSessionHost';
import { parseNetworkCaptureFieldsInput, parseNetworkCaptureInputs } from '../networkCaptureFields';
jest.mock('@ngrok/ngrok');
jest.mock('../localEgress', () => ({
...jest.requireActual('../localEgress'),
readLocalEgressHandoffAsync: jest.fn().mockResolvedValue(null),
}));
jest.mock('node:timers');
jest.mock('node:timers/promises');
jest.mock('../../../utils/turtleFetch');
@@ -71,6 +87,7 @@ function createLoggerMock(): bunyan {
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
child: jest.fn().mockReturnThis(),
} as unknown as bunyan;
}
@@ -210,6 +227,51 @@ function createEnvMock(): BuildStepEnv {
return { DEVICE_RUN_SESSION_ID: 'drs-id' } as unknown as BuildStepEnv;
}
describe(createIosSessionStartupInputProviders, () => {
it('declares optional inputs so existing workflows do not opt into owned startup', () => {
const inputs = createIosSessionStartupInputProviders().map(provider =>
provider(createGlobalContextMock(), 'Test step')
);
expect(
inputs.map(({ id, required, allowedValueTypeName }) => ({
id,
required,
allowedValueTypeName,
}))
).toEqual([
{
id: 'boot_simulator',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
},
{
id: 'device_identifier',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
},
{
id: 'install_app_path',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
},
]);
});
});
describe(validateServeSimLaunchOptions, () => {
it('accepts empty options and arguments at the count, character and byte limits', () => {
expect(() => validateServeSimLaunchOptions({})).not.toThrow();
expect(() => validateServeSimLaunchOptions({ launchArgs: Array(256).fill('a') })).not.toThrow();
expect(() => validateServeSimLaunchOptions({ launchArgs: ['a'.repeat(8192)] })).not.toThrow();
expect(() =>
validateServeSimLaunchOptions({ launchArgs: Array(16).fill('a'.repeat(8191)) })
).not.toThrow();
expect(() =>
validateServeSimLaunchOptions({ openUrl: `https://${'a'.repeat(8184)}` })
).not.toThrow();
});
});
describe(createServeSimLaunchInputProviders, () => {
it('declares the launch inputs as optional', () => {
const globalCtx = createGlobalContextMock();
@@ -243,6 +305,110 @@ describe(createServeSimLaunchInputProviders, () => {
});
});
describe(createPublicPreviewInputProvider, () => {
it('preserves the native Share button for legacy jobs without the input', () => {
const input = createPublicPreviewInputProvider()(createGlobalContextMock(), 'Test step');
expect(input).toMatchObject({
id: 'public_preview',
required: false,
defaultValue: true,
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
});
});
});
describe(createPrivatePreviewUrlInputProvider, () => {
it('accepts an optional authenticated preview URL from the session job', () => {
const provider = createPrivatePreviewUrlInputProvider();
const input = provider(createGlobalContextMock(), 'step-id');
expect(input.id).toBe('private_preview_url');
expect(input.required).toBe(false);
expect(input.allowedValueTypeName).toBe(BuildStepInputValueTypeName.STRING);
});
});
describe(createFrameAncestorsInputProvider, () => {
it('accepts an optional JSON list of origins from the session job', () => {
const input = createFrameAncestorsInputProvider()(createGlobalContextMock(), 'step-id');
expect(input).toMatchObject({
id: 'frame_ancestors',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
});
});
});
describe(parseFrameAncestorsInput, () => {
it('defaults to no extra origins when the step declares nothing', () => {
expect(parseFrameAncestorsInput(undefined)).toEqual([]);
});
it('returns the origins as given', () => {
expect(
parseFrameAncestorsInput(['https://embed.example.test', 'https://*.example.test'])
).toEqual(['https://embed.example.test', 'https://*.example.test']);
});
it.each([['https://embed.example.test'], [['https://embed.example.test', '']], [[1]]])(
'rejects %j',
value => {
expect(() => parseFrameAncestorsInput(value)).toThrow(
'Input "frame_ancestors" must be an array of non-empty strings, one origin each.'
);
}
);
});
describe(createCorsOriginsInputProvider, () => {
it('accepts an optional JSON list of origins from the session job', () => {
const input = createCorsOriginsInputProvider()(createGlobalContextMock(), 'step-id');
expect(input).toMatchObject({
id: 'cors_origins',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
});
});
});
describe(parseCorsOriginsInput, () => {
it('defaults to no extra origins and returns the origins as given', () => {
expect(parseCorsOriginsInput(undefined)).toEqual([]);
expect(parseCorsOriginsInput(['https://app.example.test'])).toEqual([
'https://app.example.test',
]);
});
it('rejects anything but a list of non-empty strings', () => {
expect(() => parseCorsOriginsInput(['https://app.example.test', ''])).toThrow(
'Input "cors_origins" must be an array of non-empty strings, one origin each.'
);
});
});
describe(corsOriginArgs, () => {
it('repeats the flag once per origin', () => {
expect(corsOriginArgs(['https://a.example.test', 'https://b.example.test'])).toEqual([
'--cors-origin',
'https://a.example.test',
'--cors-origin',
'https://b.example.test',
]);
expect(corsOriginArgs([])).toEqual([]);
});
});
describe(frameAncestorArgs, () => {
it('repeats the flag once per origin', () => {
expect(frameAncestorArgs(['https://a.example.test', 'https://b.example.test'])).toEqual([
'--frame-ancestor',
'https://a.example.test',
'--frame-ancestor',
'https://b.example.test',
]);
expect(frameAncestorArgs([])).toEqual([]);
});
});
describe(parseServeSimLaunchInputs, () => {
const darwin = { runtimePlatform: BuildRuntimePlatform.DARWIN };
@@ -410,6 +576,26 @@ describe(createServeSimArgs, () => {
]);
});
it('appends the session frame ancestors after the website args', () => {
const args = createServeSimArgs({
port: 4321,
websiteArgs: ['--frame-ancestor', 'https://expo.dev'],
frameAncestorArgs: ['--frame-ancestor', 'https://embed.example.test'],
corsOriginArgs: ['--cors-origin', 'https://app.example.test'],
shareUrl: 'https://expo.dev/simulator-preview/preview-id',
});
expect(args.slice(-8)).toEqual([
'--frame-ancestor',
'https://expo.dev',
'--frame-ancestor',
'https://embed.example.test',
'--cors-origin',
'https://app.example.test',
'--share-url',
'https://expo.dev/simulator-preview/preview-id',
]);
});
it('pins the requested package version', () => {
expect(createServeSimArgs({ port: 4321, packageVersion: '0.1.38' })[0]).toBe(
'@expo/[email protected]'
@@ -436,6 +622,14 @@ describe(createServeSimArgs, () => {
]);
});
it('disables the native Share button only when requested', () => {
expect(createServeSimArgs({ port: 4321, disableShare: true })).toContain('--disable-share');
expect(createServeSimArgs({ port: 4321, disableShare: false })).not.toContain(
'--disable-share'
);
expect(createServeSimArgs({ port: 4321 })).not.toContain('--disable-share');
});
it('appends the launch flags after the streaming policy', () => {
const args = createServeSimArgs({
port: 4321,
@@ -560,6 +754,7 @@ describe(createExpoDeviceHubArgs, () => {
'4321',
'--host',
'127.0.0.1',
'--require-token',
'--platform',
'android',
'--transport',
@@ -581,6 +776,40 @@ describe(createExpoDeviceHubArgs, () => {
]);
});
// The same website flags as serve-sim, after the TURN args.
it('appends the website args after the TURN args when provided', () => {
const args = createExpoDeviceHubArgs({
port: 4321,
turnArgs: ['--turn-url', 'turns:turn.example.test:443'],
websiteArgs: ['--cors-origin', 'https://expo.dev', '--frame-ancestor', 'https://expo.dev'],
});
expect(args.slice(-6)).toEqual([
'--turn-url',
'turns:turn.example.test:443',
'--cors-origin',
'https://expo.dev',
'--frame-ancestor',
'https://expo.dev',
]);
});
it('appends the session frame ancestors after the website args', () => {
const args = createExpoDeviceHubArgs({
port: 4321,
websiteArgs: ['--frame-ancestor', 'https://expo.dev'],
frameAncestorArgs: ['--frame-ancestor', 'https://embed.example.test'],
corsOriginArgs: ['--cors-origin', 'https://app.example.test'],
});
expect(args.slice(-6)).toEqual([
'--frame-ancestor',
'https://expo.dev',
'--frame-ancestor',
'https://embed.example.test',
'--cors-origin',
'https://app.example.test',
]);
});
it('pins the requested package version', () => {
expect(createExpoDeviceHubArgs({ port: 4321, packageVersion: '0.7.0' })[0]).toBe(
'[email protected]'
@@ -588,9 +817,9 @@ describe(createExpoDeviceHubArgs, () => {
});
});
describe(websiteOriginServeSimArgs, () => {
describe(websiteOriginArgs, () => {
it('names only the production website by default', () => {
expect(websiteOriginServeSimArgs({} as BuildStepEnv)).toEqual([
expect(websiteOriginArgs({} as BuildStepEnv)).toEqual([
'--cors-origin',
'https://expo.dev',
'--frame-ancestor',
@@ -599,7 +828,7 @@ describe(websiteOriginServeSimArgs, () => {
});
it('names staging, its deploy previews and local website subdomains on staging', () => {
const args = websiteOriginServeSimArgs({ EXPO_STAGING: '1' } as BuildStepEnv);
const args = websiteOriginArgs({ EXPO_STAGING: '1' } as BuildStepEnv);
expect(args).toEqual([
'--cors-origin',
'https://staging.expo.dev',
@@ -622,14 +851,14 @@ describe(websiteOriginServeSimArgs, () => {
it('names only https origins', () => {
for (const env of [{}, { EXPO_STAGING: '1' }, { EXPO_LOCAL: '1' }]) {
const args = websiteOriginServeSimArgs(env as BuildStepEnv);
const args = websiteOriginArgs(env as BuildStepEnv);
expect(args.filter(value => value.startsWith('http://'))).toEqual([]);
}
});
it('names local website subdomains without the deploy-preview wildcard on local', () => {
for (const env of [{ EXPO_LOCAL: '1' }, { EXPO_LOCAL: '1', EXPO_STAGING: '1' }]) {
expect(websiteOriginServeSimArgs(env as BuildStepEnv)).toEqual([
expect(websiteOriginArgs(env as BuildStepEnv)).toEqual([
'--cors-origin',
'https://expo.test',
'--frame-ancestor',
@@ -655,6 +884,8 @@ describe(waitForWebPreviewReadyAsync, () => {
.mocked(turtleFetch)
.mockRejectedValueOnce(new Error('not ready'))
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ status: 'ready', device: 'DEVICE-A' }),
} as unknown as Awaited<ReturnType<typeof turtleFetch>>);
@@ -677,6 +908,43 @@ describe(waitForWebPreviewReadyAsync, () => {
});
describe(startNgrokTunnelAsync, () => {
it.each(['rejected', 'stalled'] as const)(
'retains a %s close failure on repeated stops',
async mode => {
jest.useFakeTimers();
try {
const failure = new Error('close failed');
const close = jest
.fn()
.mockImplementation(() =>
mode === 'rejected' ? Promise.reject(failure) : new Promise<void>(() => {})
);
jest.mocked(ngrok.forward).mockResolvedValue({
url: () => 'https://web-preview.example.test',
close,
} as never);
const tunnel = await startNgrokTunnelAsync({
port: 4321,
subdomainPrefix: 'web-preview',
baseDomain: 'eas-simulator.ngrok.dev',
authtoken: 'token',
logger: createLoggerMock(),
});
const stopping = tunnel.stopAsync();
const assertion = expect(stopping).rejects.toThrow(
mode === 'rejected' ? 'close failed' : 'Ngrok tunnel stop timed out after 4000ms.'
);
expect(tunnel.stopAsync()).toBe(stopping);
await jest.advanceTimersByTimeAsync(4_000);
await assertion;
await expect(tunnel.stopAsync()).rejects.toThrow();
expect(close).toHaveBeenCalledTimes(1);
} finally {
jest.useRealTimers();
}
}
);
it('uses a 128-bit capability hostname and exposes explicit cleanup', async () => {
const close = jest.fn().mockResolvedValue(undefined);
jest.mocked(ngrok.forward).mockResolvedValue({
@@ -716,7 +984,15 @@ describe('spawnDetached process group shutdown', () => {
beforeEach(() => {
const spawned = Object.assign(Promise.resolve(undefined), {
child: { pid: 4321, unref: jest.fn(), once: jest.fn() },
child: {
pid: 4321,
unref: jest.fn(),
once: jest.fn((event, callback) => {
if (event === 'close') {
queueMicrotask(callback);
}
}),
},
});
jest.mocked(spawn).mockReturnValue(spawned as never);
});
@@ -751,7 +1027,16 @@ describe('spawnDetached process group shutdown', () => {
});
it('kills a detached child that outlives the shutdown deadline', async () => {
const kill = jest.spyOn(process, 'kill').mockReturnValue(true);
let killed = false;
const kill = jest.spyOn(process, 'kill').mockImplementation((pid, signal) => {
if (pid === -4321 && signal === 'SIGKILL') {
killed = true;
}
if (killed && signal === 0) {
throw Object.assign(new Error('Process group exited'), { code: 'ESRCH' });
}
return true;
});
const detached = spawnDetached({ command: 'npx', args: [], env, stopGracePeriodMs: 0 });
await detached.stopAsync();
@@ -772,10 +1057,11 @@ describe(startDeviceSessionHostAsync, () => {
'turn-credential',
];
const websiteArgs = ['--cors-origin', 'https://expo.dev', '--frame-ancestor', 'https://expo.dev'];
const authtoken = 'ngrok-token';
const env = {
DEVICE_RUN_SESSION_ID: 'drs-id',
NGROK_AUTHTOKEN: 'ngrok-token',
} as unknown as BuildStepEnv;
const hubStateFiles: string[] = [];
beforeEach(() => {
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mockReset();
@@ -790,10 +1076,24 @@ describe(startDeviceSessionHostAsync, () => {
child: {
pid: undefined,
unref: jest.fn(),
once: jest.fn(),
once: jest.fn((event, callback) => {
if (event === 'close') {
queueMicrotask(callback);
}
}),
},
});
jest.mocked(spawn).mockReturnValue(spawnPromise as never);
// expo-device-hub records its session token in `server-<port>.json` before it is ready.
jest.mocked(spawn).mockImplementation(((_command: string, args: string[] = []) => {
if (args.includes('--platform')) {
const port = args[args.indexOf('--port') + 1];
const file = path.join(EXPO_DEVICE_HUB_STATE_DIR, `server-${port}.json`);
mkdirSync(EXPO_DEVICE_HUB_STATE_DIR, { recursive: true });
writeFileSync(file, JSON.stringify({ pid: 1, port: Number(port), token: 'hub-token' }));
hubStateFiles.push(file);
}
return spawnPromise;
}) as never);
jest.mocked(turtleFetch).mockImplementation(async url => {
if (url.endsWith('/turn-ice-servers')) {
@@ -812,13 +1112,16 @@ describe(startDeviceSessionHostAsync, () => {
} as unknown as Awaited<ReturnType<typeof turtleFetch>>;
}
return {
ok: true,
status: 200,
json: async () => ({ status: 'ready', device: 'device-id' }),
} as unknown as Awaited<ReturnType<typeof turtleFetch>>;
});
});
afterEach(() => {
afterEach(async () => {
jest.restoreAllMocks();
await Promise.all(hubStateFiles.splice(0).map(file => fs.rm(file, { force: true })));
});
it('gives serve-sim its recording grace period on shutdown', async () => {
@@ -833,6 +1136,85 @@ describe(startDeviceSessionHostAsync, () => {
expect(spawnDetachedSpy).toHaveBeenCalledWith(
expect.objectContaining({ stopGracePeriodMs: SERVE_SIM_STOP_GRACE_PERIOD_MS })
);
expect(spawnDetachedSpy.mock.calls[0][0].stopGracePeriodMs).toBeGreaterThanOrEqual(120_000);
await host.finishAsync();
});
it.each([undefined, 'latest', '1.2.3'])(
'passes private sharing flags directly to serve-sim version %s',
async packageVersion => {
const host = await startDeviceSessionHostAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.DARWIN,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
packageVersion,
disableShare: true,
});
const serveSimCalls = jest
.mocked(spawn)
.mock.calls.filter(([, args]) =>
args.includes(`@expo/serve-sim@${packageVersion ?? 'latest'}`)
);
expect(serveSimCalls).toHaveLength(1);
expect(serveSimCalls[0][1]).toContain('--disable-share');
expect(serveSimCalls[0][1]).not.toContain('--share-url');
await host.finishAsync();
}
);
it('keeps the authenticated link out of serve-sim while retaining the public frame URL', async () => {
jest.mocked(ngrok.forward).mockResolvedValue({
url: () => 'https://preview.example.test',
close: jest.fn().mockResolvedValue(undefined),
} as never);
const privatePreviewUrl =
'https://expo.dev/accounts/owner/projects/app/simulator-sessions/session-id/preview';
const host = await startDeviceSessionHostAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.DARWIN,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
disableShare: true,
privatePreviewUrl,
});
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
const serveSimArgs = jest
.mocked(spawn)
.mock.calls.find(([, args]) => args.includes('@expo/serve-sim@latest'))![1];
expect(serveSimArgs).not.toContain('--share-url');
expect(serveSimArgs).toContain('--disable-share');
expect(preview.sharePageUrl).toBe(privatePreviewUrl);
expect(preview.previewPageUrl).toMatch(
/^https:\/\/expo\.dev\/simulator-preview\/[a-f0-9]{32}$/
);
await host.finishAsync();
});
it('does not expose a public share URL when a private job omits its authenticated URL', async () => {
jest.mocked(ngrok.forward).mockResolvedValue({
url: () => 'https://preview.example.test',
close: jest.fn().mockResolvedValue(undefined),
} as never);
const host = await startDeviceSessionHostAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.DARWIN,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
disableShare: true,
});
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
const serveSimArgs = jest
.mocked(spawn)
.mock.calls.find(([, args]) => args.includes('@expo/serve-sim@latest'))![1];
expect(serveSimArgs).not.toContain('--share-url');
expect(preview.sharePageUrl).toBeUndefined();
expect(preview.previewPageUrl).toMatch(
/^https:\/\/expo\.dev\/simulator-preview\/[a-f0-9]{32}$/
);
await host.finishAsync();
});
@@ -856,7 +1238,7 @@ describe(startDeviceSessionHostAsync, () => {
timeoutMs: 10_000,
packageVersion,
});
const preview = await host.openPreviewAsync({ baseDomain });
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
const spawnCalls = jest.mocked(spawn).mock.calls;
expect(spawnCalls[0]).toEqual([
@@ -887,7 +1269,13 @@ describe(startDeviceSessionHostAsync, () => {
const recordingDirectory = args[args.indexOf('--android-recording-directory') + 1];
expect(args).toEqual([
'--yes',
...createExpoDeviceHubArgs({ port, turnArgs, packageVersion, recordingDirectory }),
...createExpoDeviceHubArgs({
port,
turnArgs,
websiteArgs,
packageVersion,
recordingDirectory,
}),
]);
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
expect(preview.apiUrl).toBe('https://android-preview.example.test');
@@ -909,10 +1297,16 @@ describe(startDeviceSessionHostAsync, () => {
logger: createLoggerMock(),
timeoutMs: 10_000,
});
const preview = await host.openPreviewAsync({ baseDomain });
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
expect(preview.previewToken).toBe('tok-1');
expect(preview.apiUrl).toBe('https://preview.example.test');
expect(preview.sharePageUrl).toBe(preview.previewPageUrl);
const serveSimArgs = jest
.mocked(spawn)
.mock.calls.find(([, args]) => args.includes('@expo/serve-sim@latest'))![1];
expect(serveSimArgs[serveSimArgs.indexOf('--share-url') + 1]).toBe(preview.previewPageUrl);
expect(serveSimArgs).not.toContain('--disable-share');
});
it.each([
@@ -938,7 +1332,7 @@ describe(startDeviceSessionHostAsync, () => {
logger: createLoggerMock(),
timeoutMs: 10_000,
});
const preview = await host.openPreviewAsync({ baseDomain });
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
expect(preview.previewPageUrl).toMatch(
/^https:\/\/staging\.expo\.dev\/simulator-preview\/[a-f0-9]{32}$/
@@ -966,8 +1360,8 @@ describe(startDeviceSessionHostAsync, () => {
).rejects.toThrow(/wrote no session token/);
});
// expo-device-hub mints no token, so the Android preview must not require one.
it('starts for Linux without a token, and does not gate expo-device-hub', async () => {
// expo-device-hub records its token in a state file, as serve-sim does.
it('carries the expo-device-hub session token for Linux', async () => {
jest.mocked(readServeSimServersAsync).mockResolvedValue([]);
jest.mocked(ngrok.forward).mockResolvedValue({
url: () => 'https://android-preview.example.test',
@@ -980,11 +1374,12 @@ describe(startDeviceSessionHostAsync, () => {
logger: createLoggerMock(),
timeoutMs: 10_000,
});
const preview = await host.openPreviewAsync({ baseDomain });
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
expect(preview.previewToken).toBeUndefined();
const [, args] = jest.mocked(spawn).mock.calls[0];
expect(args).not.toContain('--require-token');
expect(preview.previewToken).toBe('hub-token');
const [, args] = jest.mocked(spawn).mock.calls.find(([command]) => command === 'npx')!;
expect(args).toContain('--require-token');
await host.finishAsync();
});
it('starts serve-sim for Darwin with its metrics policy and cleans up the preview resources', async () => {
@@ -1002,7 +1397,7 @@ describe(startDeviceSessionHostAsync, () => {
timeoutMs: 10_000,
packageVersion,
});
const preview = await host.openPreviewAsync({ baseDomain });
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
const [command, args] = jest.mocked(spawn).mock.calls[0];
const port = Number(args[args.indexOf('--port') + 1]);
@@ -1041,7 +1436,7 @@ describe(startDeviceSessionHostAsync, () => {
timeoutMs: 10_000,
packageVersion: '4.5.6',
});
const preview = await host.openPreviewAsync({ baseDomain });
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
const [command, args] = jest.mocked(spawn).mock.calls[0];
const port = Number(args[args.indexOf('--port') + 1]);
@@ -1075,7 +1470,7 @@ describe(startDeviceSessionHostAsync, () => {
logger: createLoggerMock(),
timeoutMs: 10_000,
});
const preview = await host.openPreviewAsync({ baseDomain });
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
const [command, args] = jest.mocked(spawn).mock.calls[0];
const port = Number(args[args.indexOf('--port') + 1]);
@@ -0,0 +1,184 @@
import { once } from 'node:events';
import {
access,
lstat,
mkdir,
mkdtemp,
readFile,
readdir,
readlink,
rm,
stat,
symlink,
writeFile,
} from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { WebSocketServer } from 'ws';
import { runServeSimActionAsync, stageServeSimAppAsync } from '../serveSimActions';
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
let server: WebSocketServer;
let port: number;
const token = 'test-session-token';
const request = () => ({
port,
token,
action: 'app.install',
params: { udid: 'device', path: '/tmp/App.app' },
timeoutMs: 2_000,
});
beforeEach(async () => {
server = new WebSocketServer({ host: '127.0.0.1', port: 0 });
await once(server, 'listening');
port = (server.address() as { port: number }).port;
});
afterEach(async () => {
for (const socket of server.clients) {
socket.terminate();
}
await new Promise<void>(resolve => server.close(() => resolve()));
});
it('authenticates the upgrade, waits for ready, and sends the action once', async () => {
const actions: unknown[] = [];
const upgrades: unknown[] = [];
server.on('connection', (socket, upgrade) => {
upgrades.push({
url: upgrade.url,
protocol: upgrade.headers['sec-websocket-protocol'],
});
socket.on('message', raw => {
actions.push(JSON.parse(raw.toString()));
socket.send(JSON.stringify({ id: 1, stdout: '', stderr: '', exitCode: 0 }));
});
socket.send(JSON.stringify({ ready: true }));
socket.send(JSON.stringify({ ready: true }));
});
await runServeSimActionAsync(request());
expect(upgrades).toEqual([{ url: '/exec-ws', protocol: `serve-sim.token.${token}` }]);
expect(actions).toEqual([{ id: 1, action: 'app.install', params: request().params }]);
});
it.each([
[{ id: 1, exitCode: 1, stderr: 'install refused' }, 'install refused'],
[{ id: 1, error: 'unknown action app.install' }, 'unknown action'],
[{ id: 1, stdout: '' }, 'invalid response'],
[{ id: 1, exitCode: 1, stderr: '' }, 'invalid response'],
])('rejects unsuccessful action replies without retrying', async (reply, message) => {
let attempts = 0;
server.on('connection', socket => {
socket.send(JSON.stringify({ ready: true }));
socket.on('message', () => {
attempts++;
socket.send(JSON.stringify(reply));
});
});
await expect(runServeSimActionAsync(request())).rejects.toThrow(message);
expect(attempts).toBe(1);
});
it('rejects a connection that closes before its action finishes', async () => {
server.on('connection', socket => socket.close());
await expect(runServeSimActionAsync(request())).rejects.toThrow('closed before completion');
});
it('rejects an upgrade refused by the server without executing an action', async () => {
const connected = jest.fn();
server.options.verifyClient = () => false;
server.on('connection', connected);
await expect(runServeSimActionAsync(request())).rejects.toThrow('connection failed');
expect(connected).not.toHaveBeenCalled();
});
it('rejects malformed replies without resending the action', async () => {
const actions = jest.fn();
server.on('connection', socket => {
socket.send(JSON.stringify({ ready: true }));
socket.on('message', () => {
actions();
socket.send('not JSON');
});
});
await expect(runServeSimActionAsync(request())).rejects.toThrow('returned invalid JSON');
expect(actions).toHaveBeenCalledTimes(1);
});
it('times out and closes a socket that never becomes ready', async () => {
const disconnected = new Promise(resolve =>
server.on('connection', socket => socket.on('close', resolve))
);
await expect(runServeSimActionAsync({ ...request(), timeoutMs: 30 })).rejects.toThrow(
'timed out'
);
await disconnected;
});
it('aborts an in-flight action without resending it', async () => {
const controller = new AbortController();
let attempts = 0;
server.on('connection', socket => {
socket.send(JSON.stringify({ ready: true }));
socket.on('message', () => {
attempts++;
controller.abort(new Error('session stopped'));
});
});
await expect(runServeSimActionAsync({ ...request(), signal: controller.signal })).rejects.toThrow(
'session stopped'
);
expect(attempts).toBe(1);
});
it('does not connect when already aborted', async () => {
const connected = jest.fn();
server.on('connection', connected);
await expect(
runServeSimActionAsync({
...request(),
signal: AbortSignal.abort(new Error('already stopped')),
})
).rejects.toThrow('already stopped');
expect(connected).not.toHaveBeenCalled();
});
it("stages a local app without consuming the caller's original", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'serve-sim-stage-test-'));
try {
const source = path.join(root, 'downloaded', 'App.app');
await mkdir(source, { recursive: true });
await writeFile(path.join(source, 'marker'), 'app');
await symlink('marker', path.join(source, 'current'));
const alias = path.join(root, 'Alias.app');
await symlink(source, alias);
const staged = await stageServeSimAppAsync(alias, root);
expect(staged.path).toMatch(/serve-sim-uploads\/eas-app-[^/]+\/Alias.app$/);
expect((await lstat(staged.path)).isDirectory()).toBe(true);
await access(path.join(staged.path, 'marker'));
expect(await readlink(path.join(staged.path, 'current'))).toBe('marker');
expect((await stat(path.join(root, 'serve-sim-uploads'))).mode & 0o777).toBe(0o700);
await rm(staged.directory, { recursive: true, force: true });
expect(await readFile(path.join(source, 'marker'), 'utf8')).toBe('app');
} finally {
await rm(root, { recursive: true, force: true });
}
});
it('removes its staging directory when the source app cannot be read', async () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'serve-sim-stage-test-'));
try {
await expect(stageServeSimAppAsync(path.join(root, 'Missing.app'), root)).rejects.toThrow(
'ENOENT'
);
expect(await readdir(path.join(root, 'serve-sim-uploads'))).toEqual([]);
} finally {
await rm(root, { recursive: true, force: true });
}
});
@@ -98,13 +98,22 @@ it.each([undefined, 'all'])('refuses an unconfirmed user-app scope (%s)', async
it('aborts a quiet live stream and drains preceding writes before returning', async () => {
const controller = new AbortController();
const fsPromises = jest.requireActual<typeof import('node:fs/promises')>('node:fs/promises');
const openFile = fsPromises.open;
jest.spyOn(fsPromises, 'open').mockImplementationOnce(async (...args) => {
const file = await openFile(...args);
const write = file.writeFile.bind(file);
jest.spyOn(file, 'writeFile').mockImplementationOnce(async (...args) => {
const pending = write(...args);
controller.abort();
await pending;
});
return file;
});
const stream = new Readable({ read() {} });
stream.push('data: {"pid":42}\n\n');
jest.mocked(fetch).mockResolvedValue(new Response(stream));
const done = record({ signal: controller.signal });
await delay(20);
controller.abort();
expect((await done).receivedData).toBe(true);
expect((await record({ signal: controller.signal })).receivedData).toBe(true);
expect(stream.destroyed).toBe(true);
expect(await readFile(path.join(directory, 'logs.ndjson'), 'utf8')).toBe('{"pid":42}\n');
});
@@ -88,4 +88,23 @@ describe(createStartupTasks, () => {
createStartupTasks(createLoggerMock()).untilAborted(Promise.resolve('ok'))
).resolves.toBe('ok');
});
it.each([false, true])(
'inherits parent cancellation, already aborted=%s',
async alreadyAborted => {
const parent = new AbortController();
const reason = new Error('cancelled');
if (alreadyAborted) {
parent.abort(reason);
}
const tasks = createStartupTasks(createLoggerMock(), parent.signal);
const waiting = tasks.untilAborted(new Promise<void>(() => {}));
if (!alreadyAborted) {
parent.abort(reason);
}
await expect(waiting).rejects.toBe(reason);
expect(tasks.signal.reason).toBe(reason);
tasks.abort(new Error('later failure'));
expect(tasks.signal.reason).toBe(reason);
}
);
});
@@ -35,6 +35,7 @@ export async function runAgentAsync({
pathEnv,
npmCacheUrl,
logger,
addBuildLogMask,
signal,
}: {
agentKind: AgentKind;
@@ -47,11 +48,11 @@ export async function runAgentAsync({
pathEnv: string;
npmCacheUrl?: string;
logger: bunyan;
addBuildLogMask: (secret: string) => void;
signal: AbortSignal;
}): Promise<void> {
const leaseOptions = { expoApiV2BaseUrl, expoToken, agentRunId, signal };
let credentials = await leaseAgentRunProviderCredentialsAsync(leaseOptions);
const secrets = new Set<string>();
const runDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-agent-run-'));
try {
@@ -126,14 +127,13 @@ export async function runAgentAsync({
}
for (const secret of invocation.secrets) {
secrets.add(secret);
addBuildLogMask(secret);
}
const { exitCode, exitSignal } = await runAgentProcessAsync({
command: path.join(cliDirectory, 'node_modules', '.bin', bin),
...invocation,
cwd: workingDirectory,
formatOutputLine,
secrets: [...secrets],
logger,
signal,
});
@@ -170,7 +170,6 @@ async function runAgentProcessAsync({
cwd,
stdin,
formatOutputLine,
secrets,
logger,
signal,
}: {
@@ -180,7 +179,6 @@ async function runAgentProcessAsync({
cwd: string;
stdin: string;
formatOutputLine: AgentCli['formatOutputLine'];
secrets: string[];
logger: bunyan;
signal: AbortSignal;
}): Promise<{ exitCode: number | null; exitSignal: NodeJS.Signals | null }> {
@@ -188,8 +186,6 @@ async function runAgentProcessAsync({
// Detached, so the agent leads a process group that can be signaled as a whole.
const child = spawn(command, args, { cwd, env, detached: true });
const outputClosed = new Promise<void>(resolve => child.once('close', () => resolve()));
const redact = (text: string): string =>
secrets.reduce((masked, secret) => masked.replaceAll(secret, '[redacted]'), text);
const stdoutLogger = logger.child({ source: 'stdout' });
readline.createInterface({ input: child.stdout }).on('line', line => {
let messages: AgentLogMessage[];
@@ -201,12 +197,12 @@ async function runAgentProcessAsync({
return;
}
for (const { level, message } of messages) {
stdoutLogger[level](redact(message).trimEnd());
stdoutLogger[level](message.trimEnd());
}
});
const stderrLogger = logger.child({ source: 'stderr' });
readline.createInterface({ input: child.stderr }).on('line', line => {
stderrLogger.info(redact(line));
stderrLogger.info(line);
});
// The agent may exit before it reads the prompt; its exit status reports that.
child.stdin.on('error', () => {});
@@ -0,0 +1,70 @@
import { ErrorMetadata } from '@expo/eas-build-job';
import { Response } from 'node-fetch';
import { z } from 'zod';
const MAX_RESPONSE_BODY_LENGTH = 8_000;
export class GooglePlayApiError extends Error {
constructor(
readonly status: number,
readonly apiMessage: string,
readonly reasons: string[],
// Kept for Sentry only, so build logs stay short.
readonly diagnostics: { requestPath?: string; responseBody?: string } = {}
) {
super(`Google Play request failed (HTTP ${status})${apiMessage ? `: ${apiMessage}` : '.'}`);
}
static isRetryableStatus(status: number): boolean {
return status === 408 || status === 429 || (status >= 500 && status < 600);
}
// Error metadata for the first Google Play API error in the cause chain. Retries and
// uncertain commits wrap the API error, e.g. in an AggregateError.
static getErrorMetadata(error: unknown): ErrorMetadata | undefined {
let cause = error;
for (let depth = 0; cause instanceof Error && depth < 5; depth++) {
if (cause instanceof GooglePlayApiError) {
return {
googlePlayRequestPath: cause.diagnostics.requestPath,
// A string, so Sentry does not cut off nested error details.
googlePlayErrorResponse: cause.diagnostics.responseBody,
};
}
cause = cause.cause;
}
return undefined;
}
static async fromResponseAsync(
response: Response,
signal?: AbortSignal
): Promise<GooglePlayApiError> {
let data: unknown;
try {
data = await response.json();
} catch {
signal?.throwIfAborted();
// Raw text or HTML can contain the request URL, including an upload secret.
}
const parsed = z
.object({
error: z.object({
message: z.string().optional(),
errors: z.array(z.object({ reason: z.string().optional() })).optional(),
}),
})
.safeParse(data);
return new GooglePlayApiError(
response.status,
parsed.data?.error.message ?? '',
parsed.data?.error.errors?.flatMap(error => (error.reason ? [error.reason] : [])) ?? [],
{
// Path only: the upload session query contains a secret.
requestPath: response.url ? new URL(response.url).pathname : undefined,
responseBody:
data === undefined ? undefined : JSON.stringify(data).slice(0, MAX_RESPONSE_BODY_LENGTH),
}
);
}
}
@@ -3,6 +3,9 @@ import { asyncResult } from '@expo/results';
import fetch, { Response } from 'node-fetch';
import { z } from 'zod';
import { promiseRetryWithCondition } from '../../../utils/promiseRetryWithCondition';
import { GooglePlayApiError } from './GooglePlayApiError';
const PostApi = {
'/token': {
request: z.object({
@@ -46,14 +49,22 @@ export class GooglePlayAuthClient {
signal?: AbortSignal
): Promise<z.output<(typeof PostApi)[TPath]['response']>> {
const schema = PostApi[path];
return await this.requestAsync({
method: 'POST',
path,
body,
requestSchema: schema.request,
responseSchema: schema.response,
signal,
});
return await promiseRetryWithCondition(
() =>
this.requestAsync({
method: 'POST',
path,
body,
requestSchema: schema.request,
responseSchema: schema.response,
signal,
}),
error =>
!signal?.aborted &&
error instanceof GooglePlayAuthRequestError &&
GooglePlayApiError.isRetryableStatus(error.status),
{ retries: 3, factor: 2, minTimeout: 1000, maxTimeout: 4000 }
)();
}
private async requestAsync({
@@ -1,5 +1,8 @@
import { UserError } from '@expo/eas-build-job';
import fs from 'node:fs/promises';
import { z } from 'zod';
import * as jose from 'jose';
import { KeyObject } from 'node:crypto';
import { KeyObject, createPrivateKey } from 'node:crypto';
import { GooglePlayAuthClient } from './GooglePlayAuthClient';
@@ -10,6 +13,46 @@ export type GoogleServiceAccount = {
};
export namespace GooglePlayAuthUtils {
export async function loadGoogleServiceAccountAsync({
keyPath,
}: {
keyPath: string;
}): Promise<GoogleServiceAccount> {
let contents: string;
try {
contents = await fs.readFile(keyPath, 'utf8');
} catch (error) {
throw new UserError(
'EAS_GOOGLE_PLAY_INVALID_CREDENTIALS',
(error as NodeJS.ErrnoException).code === 'ENOENT'
? `Service-account key file not found at ${keyPath}.`
: `Cannot read the service-account key file at ${keyPath}.`,
{ cause: error }
);
}
try {
const credentials = z
.object({
type: z.literal('service_account'),
client_email: z.email(),
private_key: z.string().min(1),
private_key_id: z.string().optional(),
})
.parse(JSON.parse(contents));
const privateKey = createPrivateKey(credentials.private_key);
if (privateKey.asymmetricKeyType !== 'rsa') {
throw new Error('Expected an RSA private key.');
}
return { ...credentials, private_key: privateKey };
} catch {
// Neither JSON parse errors nor validation errors may include credential content.
throw new UserError(
'EAS_GOOGLE_PLAY_INVALID_CREDENTIALS',
'Cannot read the Google service-account key. Provide a valid service-account JSON file.'
);
}
}
async function createAssertionAsync(serviceAccount: GoogleServiceAccount): Promise<string> {
return await new jose.SignJWT({
scope: 'https://www.googleapis.com/auth/androidpublisher',
@@ -1,20 +1,15 @@
import { SystemError } from '@expo/eas-build-job';
import fetch, { Response } from 'node-fetch';
import fetch, { RequestInit, Response } from 'node-fetch';
import { bunyan } from '@expo/logger';
import { promiseRetryWithCondition } from '../../../utils/promiseRetryWithCondition';
import { z } from 'zod';
import { isConnectFailure, isConnectionInterruptedError } from '../../../utils/networkErrors';
import { GooglePlayApiError } from './GooglePlayApiError';
import { GooglePlayAuthUtils, GoogleServiceAccount } from './GooglePlayAuthUtils';
export class GooglePlayApiError extends Error {
constructor(
readonly status: number,
readonly apiMessage: string,
readonly reasons: string[]
) {
super(`Google Play request failed (HTTP ${status})${apiMessage ? `: ${apiMessage}` : '.'}`);
}
}
const ReleaseStatusZ = z.enum(['draft', 'inProgress', 'halted', 'completed']);
export const ReleaseStatusZ = z.enum(['draft', 'inProgress', 'halted', 'completed']);
export type ReleaseStatus = z.output<typeof ReleaseStatusZ>;
const ReleaseNoteZ = z.object({
language: z.string(),
@@ -61,7 +56,9 @@ const PostApi = {
'/androidpublisher/v3/applications/:packageName/edits/:editId:commit': {
path: z.object({ packageName: z.string().min(1), editId: z.string().min(1) }),
request: z.object({}),
query: z.object({ changesNotSentForReview: z.boolean().optional() }),
query: z.object({
changesNotSentForReview: z.boolean().optional(),
}),
response: z.object({ id: z.string().min(1), expiryTimeSeconds: z.string().optional() }),
},
'/androidpublisher/v3/applications/:packageName/edits/:editId:validate': {
@@ -101,7 +98,10 @@ export class GooglePlayClient {
private readonly baseUrl = 'https://androidpublisher.googleapis.com';
private cachedToken?: Promise<{ value: string; expiresAt: number }>;
constructor(private readonly serviceAccount: GoogleServiceAccount) {}
constructor(
private readonly serviceAccount: GoogleServiceAccount,
private readonly logger?: bunyan
) {}
private async getTokenAsync(signal?: AbortSignal): Promise<string> {
signal?.throwIfAborted();
@@ -143,7 +143,10 @@ export class GooglePlayClient {
path: TPath,
body: z.input<(typeof PostApi)[TPath]['request']>,
params: z.input<(typeof PostApi)[TPath]['path']>,
options: { query?: z.input<(typeof PostApi)[TPath]['query']>; signal?: AbortSignal } = {}
options: {
query?: z.input<(typeof PostApi)[TPath]['query']>;
signal?: AbortSignal;
} = {}
): Promise<z.output<(typeof PostApi)[TPath]['response']>> {
return await this.sendJsonRequestAsync(
'POST',
@@ -174,7 +177,21 @@ export class GooglePlayClient {
for (const [key, value] of Object.entries(DeleteApi[path].path.parse(params))) {
url = url.replace(`:${key}`, encodeURIComponent(value));
}
await this.requestAsync('DELETE', url, undefined, signal);
try {
await this.requestWithRetriesAsync(
() => this.requestAsync('DELETE', url, undefined, signal),
signal
);
} catch (error) {
if (
error instanceof AggregateError &&
error.cause instanceof GooglePlayApiError &&
error.cause.status === 404
) {
return;
}
throw error;
}
}
private async sendJsonRequestAsync(
@@ -206,10 +223,15 @@ export class GooglePlayClient {
}
}
const response = await this.requestAsync(
method,
search.size ? `${path}?${search}` : path,
method === 'GET' ? undefined : JSON.stringify(parsedBody),
const response = await this.requestWithRetriesAsync(
() =>
this.requestAsync(
method,
search.size ? `${path}?${search}` : path,
method === 'GET' ? undefined : JSON.stringify(parsedBody),
signal,
{ 'Content-Type': 'application/json' }
),
signal
);
@@ -233,11 +255,53 @@ export class GooglePlayClient {
return parsed.data;
}
private async requestAsync(
private async requestWithRetriesAsync<T>(
request: () => Promise<T>,
signal?: AbortSignal
): Promise<T> {
const errors: unknown[] = [];
try {
return await promiseRetryWithCondition(
async () => {
try {
signal?.throwIfAborted();
return await request();
} catch (error) {
errors.push(error);
throw error;
}
},
error =>
!signal?.aborted &&
(error instanceof GooglePlayApiError
? GooglePlayApiError.isRetryableStatus(error.status)
: isConnectFailure(error) || isConnectionInterruptedError(error)),
{ retries: 5, factor: 2, minTimeout: 1000, maxTimeout: 32_000, randomize: true },
({ attemptNumber, maxAttemptsCount, error }) => {
const reason =
error instanceof GooglePlayApiError ? `HTTP ${error.status}` : 'network error';
this.logger?.warn(
`Retrying Google Play request (${reason}, attempt ${attemptNumber}/${maxAttemptsCount})...`
);
}
)();
} catch (error) {
// Keep earlier failures: a later rejection does not prove that every attempt failed.
if (errors.length > 1) {
throw new AggregateError(errors, 'Google Play request failed after retries.', {
cause: error,
});
}
throw error;
}
}
async requestAsync(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
path: string,
body?: string,
signal?: AbortSignal
signal?: AbortSignal,
headers?: RequestInit['headers']
): Promise<Response> {
const url = new URL(path, this.baseUrl);
const token = await this.getTokenAsync(signal);
@@ -248,36 +312,18 @@ export class GooglePlayClient {
method,
body,
signal,
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
headers: { ...headers, Authorization: `Bearer ${token}` },
redirect: 'manual',
});
} catch {
} catch (error) {
signal?.throwIfAborted();
throw new SystemError('Google Play request failed before a response was received.');
throw new SystemError('Google Play request failed before a response was received.', {
cause: error,
});
}
if (!response.ok) {
let data: unknown;
try {
data = await response.json();
} catch {
signal?.throwIfAborted();
}
const parsed = z
.object({
error: z.object({
message: z.string().optional(),
errors: z.array(z.object({ reason: z.string().optional() })).optional(),
}),
})
.safeParse(data);
throw new GooglePlayApiError(
response.status,
parsed.success ? (parsed.data.error.message ?? '') : '',
parsed.success
? (parsed.data.error.errors?.flatMap(error => (error.reason ? [error.reason] : [])) ?? [])
: []
);
throw await GooglePlayApiError.fromResponseAsync(response, signal);
}
return response;
@@ -0,0 +1,282 @@
import { SystemError } from '@expo/eas-build-job';
import fetch, { Response } from 'node-fetch';
import promiseRetry from 'promise-retry';
import { FileHandle } from 'node:fs/promises';
import { z } from 'zod';
import { isConnectFailure, isConnectionInterruptedError } from '../../../utils/networkErrors';
import { GooglePlayApiError } from './GooglePlayApiError';
import { GooglePlayClient } from './GooglePlayClient';
import { AndroidArtifactType } from './appArtifact';
const UploadResponseZ = z.object({
versionCode: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
});
const CHUNK_SIZE = 8 * 1024 * 1024;
// Google recommends a two-minute timeout for bundle uploads, including processing.
const REQUEST_TIMEOUT_MS = 120_000;
export namespace GooglePlayResumableUpload {
export async function createUploadSessionAsync({
client,
packageName,
editId,
artifactType,
signal,
size,
}: {
client: GooglePlayClient;
packageName: string;
editId: string;
artifactType: AndroidArtifactType;
signal?: AbortSignal;
size: number;
}): Promise<URL> {
const resource = artifactType === 'apk' ? 'apks' : 'bundles';
const response = await promiseRetry(
async retry => {
signal?.throwIfAborted();
const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
try {
return await client.requestAsync(
'POST',
`/upload/androidpublisher/v3/applications/${encodeURIComponent(packageName)}/edits/${encodeURIComponent(editId)}/${resource}?uploadType=resumable`,
undefined,
requestSignal,
{
'Content-Length': '0',
'X-Upload-Content-Length': String(size),
'X-Upload-Content-Type': getContentTypeFromArtifactType(artifactType),
}
);
} catch (error) {
signal?.throwIfAborted();
if (
timeout.aborted ||
isConnectFailure(error) ||
isConnectionInterruptedError(error) ||
(error instanceof GooglePlayApiError &&
GooglePlayApiError.isRetryableStatus(error.status))
) {
retry(
timeout.aborted
? new SystemError('Google Play upload session request timed out.')
: error
);
}
throw error;
}
},
{
retries: 5,
factor: 2,
minTimeout: 1000,
maxTimeout: 32_000,
randomize: true,
}
);
const location = response.headers.get('location');
if (!location) {
throw new SystemError('Google did not return an upload session.');
}
let session: URL;
try {
session = new URL(location);
} catch {
// Node's URL error retains the input, which can contain the upload secret.
throw new SystemError('Google returned an invalid upload URL.');
}
if (session.origin !== 'https://androidpublisher.googleapis.com') {
throw new SystemError(
`Google returned an unsafe upload URL: expected origin https://androidpublisher.googleapis.com, got ${session.origin}.`
);
}
if (session.username || session.password) {
throw new SystemError(
'Google returned an unsafe upload URL containing a username or password. Upload sessions must not send HTTP Basic credentials.'
);
}
return session;
}
export async function uploadSessionAsync({
file,
size,
session,
artifactType,
signal,
onProgress,
}: {
file: FileHandle;
size: number;
session: URL;
artifactType: AndroidArtifactType;
signal?: AbortSignal;
onProgress?: (uploadedBytes: number, totalBytes: number) => void;
}): Promise<z.output<typeof UploadResponseZ>> {
const contentType = getContentTypeFromArtifactType(artifactType);
let offset = 0;
onProgress?.(0, size);
while (offset < size) {
const result = await uploadChunkAsync({ file, size, session, contentType, offset, signal });
if (result.status === 'completed') {
onProgress?.(size, size);
return result.data;
}
offset = result.offset;
onProgress?.(offset, size);
}
throw new SystemError(`Google upload stopped at ${offset} of ${size} bytes.`);
}
async function uploadChunkAsync({
file,
size,
session,
contentType,
offset,
signal,
}: {
file: FileHandle;
size: number;
session: URL;
contentType: string;
offset: number;
signal?: AbortSignal;
}): Promise<
| { status: 'completed'; data: z.output<typeof UploadResponseZ> }
| { status: 'progress'; offset: number }
> {
let checkServerOffset = false;
// Confirmed progress starts a new retry budget. Retries first check the server offset.
return await promiseRetry(
async retry => {
while (true) {
signal?.throwIfAborted();
const end = Math.min(offset + CHUNK_SIZE, size);
let body: Buffer | undefined;
if (!checkServerOffset) {
// Only this chunk is held in memory, never the whole file.
body = Buffer.alloc(end - offset);
const { bytesRead } = await file.read(body, 0, body.length, offset);
if (bytesRead !== body.length) {
throw new SystemError(
`The Android binary changed during upload: expected ${body.length} bytes at offset ${offset}, got ${bytesRead}.`
);
}
}
const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
let response: Response;
try {
response = await fetch(session.toString(), {
method: 'PUT',
redirect: 'manual',
headers: {
'Content-Length': String(body?.length ?? 0),
'Content-Range': checkServerOffset
? `bytes */${size}`
: `bytes ${offset}-${end - 1}/${size}`,
'Content-Type': contentType,
},
body,
signal: requestSignal,
});
} catch (error) {
signal?.throwIfAborted();
const failure = new SystemError(
'Google Play upload failed before a response was received.'
);
if (timeout.aborted || isConnectFailure(error) || isConnectionInterruptedError(error)) {
checkServerOffset = true;
retry(failure);
}
throw failure;
}
if (!response.ok && response.status !== 308) {
const error = await GooglePlayApiError.fromResponseAsync(response, signal);
if (timeout.aborted) {
checkServerOffset = true;
return retry(new SystemError('Google Play upload response timed out.'));
}
if (GooglePlayApiError.isRetryableStatus(response.status)) {
checkServerOffset = true;
retry(error);
}
throw error;
}
if (response.status === 200 || response.status === 201) {
let data: unknown;
try {
data = await response.json();
} catch {
signal?.throwIfAborted();
checkServerOffset = true;
return retry(new SystemError('Could not read the Google Play upload response.'));
}
const parsed = UploadResponseZ.safeParse(data);
if (!parsed.success) {
throw new SystemError('Google completed the upload without a valid version code.');
}
return { status: 'completed', data: parsed.data };
}
if (response.status !== 308) {
throw new SystemError(`Unexpected Google upload status: ${response.status}.`);
}
const range = response.headers.get('range');
const match = range ? /^bytes=0-(\d+)$/.exec(range) : undefined;
const next = range ? (match ? Number(match[1]) + 1 : NaN) : 0;
if (
!Number.isSafeInteger(next) ||
next < offset ||
next > size ||
(!checkServerOffset && next > end)
) {
throw new SystemError('Google returned an invalid upload range.');
}
if (next === size) {
checkServerOffset = true;
// All bytes are stored, but only 200/201 confirms completion.
// Keep the current retry budget and send only status checks.
return retry(
new SystemError(`Google stored all ${size} bytes but has not completed the upload.`)
);
}
if (next > offset) {
return { status: 'progress', offset: next };
}
if (!checkServerOffset) {
checkServerOffset = true;
retry(
new SystemError(
`Google upload made no progress: confirmed ${offset} of ${size} bytes after sending bytes ${offset}-${end - 1}.`
)
);
}
// The status check confirmed no new bytes. Send the chunk again.
checkServerOffset = false;
}
},
{
retries: 5,
factor: 2,
minTimeout: 1000,
maxTimeout: 32_000,
randomize: true,
}
);
}
}
function getContentTypeFromArtifactType(artifactType: AndroidArtifactType): string {
return artifactType === 'apk'
? 'application/vnd.android.package-archive'
: 'application/octet-stream';
}
@@ -0,0 +1,323 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import fs from 'node:fs/promises';
import promiseRetry from 'promise-retry';
import { promiseRetryWithCondition } from '../../../utils/promiseRetryWithCondition';
import { GooglePlayApiError } from './GooglePlayApiError';
import { GooglePlayAuthRequestError } from './GooglePlayAuthClient';
import { GooglePlayClient, ReleaseStatus } from './GooglePlayClient';
import { GooglePlayResumableUpload } from './GooglePlayResumableUpload';
import { AndroidArtifactType } from './appArtifact';
export class GooglePlayCommitOutcomeUnknownError extends SystemError {}
export namespace GooglePlayUtils {
export async function uploadApplicationAsync(
client: GooglePlayClient,
options: {
packageName: string;
editId: string;
artifactPath: string;
artifactType: AndroidArtifactType;
signal?: AbortSignal;
onProgress?: (uploadedBytes: number, totalBytes: number) => void;
}
): Promise<{ versionCode: number }> {
options.signal?.throwIfAborted();
const file = await fs.open(options.artifactPath, 'r');
try {
const { size } = await file.stat();
if (!Number.isSafeInteger(size) || size <= 0) {
throw new UserError(
'EAS_GOOGLE_PLAY_INVALID_BINARY',
'Cannot upload an empty or invalid Android binary.'
);
}
return await promiseRetry(
async retry => {
const session = await GooglePlayResumableUpload.createUploadSessionAsync({
...options,
client,
size,
});
try {
return await GooglePlayResumableUpload.uploadSessionAsync({
...options,
file,
size,
session,
});
} catch (error) {
options.signal?.throwIfAborted();
// 404/410 from an existing session mean it is gone; other failures
// must not restart the whole file upload.
if (error instanceof GooglePlayApiError && [404, 410].includes(error.status)) {
retry(error);
}
throw error;
}
},
{ retries: 2, factor: 2, minTimeout: 1000, maxTimeout: 32_000, randomize: true }
);
} finally {
await file.close();
}
}
export async function createEditAsync(
client: GooglePlayClient,
{ packageName, signal }: { packageName: string; signal?: AbortSignal }
): Promise<{ id: string }> {
return await client.postAsync(
'/androidpublisher/v3/applications/:packageName/edits',
{},
{ packageName },
{ signal }
);
}
export async function updateTrackAsync(
client: GooglePlayClient,
{
packageName,
editId,
track,
release,
versionCode,
changelog,
signal,
}: {
packageName: string;
editId: string;
track: string;
release: { status: ReleaseStatus; userFraction?: number };
versionCode: number;
changelog?: { locale: string; text: string };
signal?: AbortSignal;
}
): Promise<void> {
await client.putAsync(
'/androidpublisher/v3/applications/:packageName/edits/:editId/tracks/:track',
{
track,
releases: [
{
...release,
versionCodes: [String(versionCode)],
...(changelog
? { releaseNotes: [{ language: changelog.locale, text: changelog.text }] }
: {}),
},
],
},
{ packageName, editId, track },
signal
);
}
export async function deleteEditAsync(
client: GooglePlayClient,
{ packageName, editId, signal }: { packageName: string; editId: string; signal?: AbortSignal }
): Promise<void> {
await client.deleteAsync(
'/androidpublisher/v3/applications/:packageName/edits/:editId',
{ packageName, editId },
signal
);
}
export async function commitEditAsync(
client: GooglePlayClient,
{
packageName,
editId,
changesNotSentForReview,
logger,
signal,
}: {
packageName: string;
editId: string;
changesNotSentForReview: boolean;
logger: bunyan;
signal?: AbortSignal;
}
): Promise<void> {
const sendCommitAsync = async ({
changesNotSentForReview,
}: {
changesNotSentForReview?: boolean;
}): Promise<void> => {
signal?.throwIfAborted();
try {
const committed = await client.postAsync(
'/androidpublisher/v3/applications/:packageName/edits/:editId:commit',
{},
{ packageName, editId },
{ query: { changesNotSentForReview }, signal }
);
if (committed?.id !== editId) {
throw new SystemError('Google did not confirm the committed edit.');
}
} catch (error) {
if (error instanceof GooglePlayAuthRequestError) {
throw error;
}
if (
!(error instanceof GooglePlayApiError) ||
GooglePlayApiError.isRetryableStatus(error.status)
) {
throw new GooglePlayCommitOutcomeUnknownError(
`Could not confirm whether Google Play saved the release for ${packageName}. Check its release status at https://play.google.com/console/ before submitting again.`,
{ cause: error, metadata: GooglePlayApiError.getErrorMetadata(error) }
);
}
throw error;
}
logger.info(
changesNotSentForReview
? 'Google Play saved the changes without sending them for review. Send them for review at https://play.google.com/console/.'
: 'Google Play saved the release using its normal review process. Check its review and publishing status at https://play.google.com/console/.'
);
};
// Google can accept the bundle upload before the edit is ready to commit.
// Retry only this explicit rejection, not other HTTP 400 preconditions.
const isUploadStillProcessing = (error: unknown): boolean =>
error instanceof GooglePlayApiError &&
error.status === 400 &&
error.apiMessage === 'Some of the Android App Bundle uploads are not completed yet.';
const commitAsync = async (options: { changesNotSentForReview?: boolean }): Promise<void> => {
try {
await promiseRetryWithCondition(
sendCommitAsync,
error => {
signal?.throwIfAborted();
return isUploadStillProcessing(error);
},
{ retries: 5, factor: 2, minTimeout: 2000, maxTimeout: 32_000 },
({ attemptNumber, maxAttemptsCount }) => {
logger.warn(
`Google Play is still processing the uploaded bundle. Retrying the same edit commit (attempt ${attemptNumber}/${maxAttemptsCount}).`
);
}
)(options);
} catch (error) {
if (isUploadStillProcessing(error)) {
throw new SystemError(
`Google Play did not finish processing the uploaded bundle for ${packageName} after six commit attempts. Wait a few minutes, then check the release at https://play.google.com/console/ before submitting again.`,
{ cause: error, metadata: GooglePlayApiError.getErrorMetadata(error) }
);
}
throw error;
}
};
try {
// Omit the optional parameter for normal review submission. Some app states
// reject its presence, even when false. True saves changes for review in Console.
await commitAsync({ changesNotSentForReview: changesNotSentForReview || undefined });
} catch (error) {
if (!(error instanceof GooglePlayApiError) || error.status !== 400) {
throw error;
}
// Like Fastlane, retry only an explicit rejection of the review setting.
if (
changesNotSentForReview &&
error.apiMessage.includes('The query parameter changesNotSentForReview must not be set')
) {
logger.warn(
'Google does not allow changesNotSentForReview for this app right now. Retrying commit once without it. The changes will be sent for review.'
);
await commitAsync({});
} else if (
!changesNotSentForReview &&
error.apiMessage.includes('Please set the query parameter changesNotSentForReview to true')
) {
logger.warn(
'Google requires changesNotSentForReview=true. Retrying commit once. Send the saved changes for review from Play Console.'
);
await commitAsync({ changesNotSentForReview: true });
} else {
throw error;
}
}
}
export function mapGooglePlayError(error: unknown, packageName: string): unknown {
if (error instanceof AggregateError) {
return mapGooglePlayError(error.cause, packageName);
}
if (
(error instanceof GooglePlayApiError || error instanceof GooglePlayAuthRequestError) &&
GooglePlayApiError.isRetryableStatus(error.status)
) {
return new SystemError(
`Google Play is temporarily unavailable or rate limited (HTTP ${error.status}). Try again later.`,
{ cause: error, metadata: GooglePlayApiError.getErrorMetadata(error) }
);
}
if (
error instanceof GooglePlayAuthRequestError &&
error.status >= 400 &&
error.status < 500 &&
!GooglePlayApiError.isRetryableStatus(error.status)
) {
return new UserError(
'EAS_GOOGLE_PLAY_INVALID_CREDENTIALS',
`Google rejected the service-account key (${error.errorCode ?? `HTTP ${error.status}`}). Check that the key is valid and the service account is enabled.`,
{ cause: error }
);
}
if (!(error instanceof GooglePlayApiError)) {
return error;
}
const message = error.apiMessage;
const reasons = new Set(error.reasons);
let code = 'EAS_GOOGLE_PLAY_REQUEST_FAILED';
let detail = `Google Play rejected the request (HTTP ${error.status})${message ? `: ${message}` : '.'} Check the app settings before submitting again.`;
if (message.includes('Package not found')) {
code = 'EAS_GOOGLE_PLAY_FIRST_UPLOAD';
detail =
'Google Play cannot find the app. Check the package name and upload the first version manually in Play Console.';
} else if (
/version code.*already (?:been )?used/i.test(message) ||
reasons.has('apkNotificationMessageKeyUpgradeVersionConflict') ||
message.includes('apkNotificationMessageKeyUpgradeVersionConflict')
) {
code = 'EAS_GOOGLE_PLAY_VERSION_CODE_USED';
detail =
'This version code was already uploaded. Check whether the intended release already exists. To upload a new binary, increase the Android version code and build again.';
} else if (
/privacy policy/i.test(message) ||
reasons.has('apkNotificationMessageKeyPermissionsRequirePrivacyPolicy') ||
message.includes('apkNotificationMessageKeyPermissionsRequirePrivacyPolicy')
) {
code = 'EAS_GOOGLE_PLAY_PRIVACY_POLICY_REQUIRED';
detail = 'This app requires a privacy policy. Add it in Play Console before you submit.';
} else if (
message.includes('Only releases with status draft may be created on draft app') ||
message.includes('Staged rollout is not permitted on draft app')
) {
code = 'EAS_GOOGLE_PLAY_DRAFT_APP';
detail =
'This app is still a draft in Play Console. Submit a draft release without a rollout, or complete the app setup in Play Console before submitting a release to testers. Use releaseStatus: "draft" in eas.json, or release_status: "draft" in a workflow.';
} else if (error.status === 409 || /edit.*(?:expired|invalid|not found)/i.test(message)) {
code = 'EAS_GOOGLE_PLAY_EDIT_INVALID';
detail =
'The Google Play edit is no longer valid. A concurrent edit or Play Console change can invalidate it. Check the app in Play Console before you submit again.';
} else if (error.status === 401 || message.includes('The caller does not have permission')) {
code = 'EAS_GOOGLE_PLAY_PERMISSION_DENIED';
detail =
'The Google service account cannot submit this app. Check its key and app permissions in Play Console.';
} else if (error.status === 400) {
code = 'EAS_GOOGLE_PLAY_INVALID_RELEASE';
detail = `Google Play rejected the release or review settings${message ? `: ${message}` : '.'} Check the track, release status, rollout, and review settings in Play Console.`;
}
return new UserError(
code,
`${detail}\n\nApp: ${packageName}\nGoogle Play Console: https://play.google.com/console/`,
{ cause: error, metadata: GooglePlayApiError.getErrorMetadata(error) }
);
}
}
@@ -109,3 +109,19 @@ it.each(['network failure', 'malformed JSON'])('returns a system error for %s',
}
}
});
it.each([429, 503])('retries temporary OAuth HTTP %s responses', async status => {
nock('https://oauth2.googleapis.com')
.post('/token', request)
.reply(status)
.post('/token', request)
.reply(200, { access_token: 'recovered', expires_in: 3600, token_type: 'Bearer' });
await expect(client.postAsync('/token', request)).resolves.toMatchObject({
access_token: 'recovered',
});
});
it('stops after three OAuth retries', async () => {
nock('https://oauth2.googleapis.com').post('/token', request).times(4).reply(503);
await expect(client.postAsync('/token', request)).rejects.toMatchObject({ status: 503 });
}, 15000);
@@ -1,9 +1,9 @@
import nock from 'nock';
import { generateKeyPairSync } from 'node:crypto';
import { SystemError } from '@expo/eas-build-job';
import { ZodError } from 'zod';
import { GooglePlayClient } from '../GooglePlayClient';
import { createMockLogger } from '../../../../__tests__/utils/logger';
const packageName = 'dev.expo.submitfixture';
const editPath = `/androidpublisher/v3/applications/${packageName}/edits/edit`;
@@ -13,6 +13,11 @@ const api = (): nock.Scope =>
});
jest.unmock('node-fetch');
jest.mock('promise-retry', () => {
const actual = jest.requireActual('promise-retry');
return (fn: Parameters<typeof actual>[0], options: object) =>
actual(fn, { ...options, minTimeout: 1, maxTimeout: 1, randomize: false });
});
const serviceAccount = {
client_email: '[email protected]',
@@ -75,11 +80,12 @@ it('rejects invalid rollout requests before sending them', async () => {
).rejects.toBeInstanceOf(ZodError);
});
it('does not retry an ambiguous edit commit', async () => {
it('keeps the outcome unknown when a lost commit response is followed by an invalid edit', async () => {
api()
.post(`${editPath}:commit`, {})
.query({ changesNotSentForReview: false })
.replyWithError('Lost response');
.replyWithError(Object.assign(new Error('Lost response'), { code: 'ECONNRESET' }));
api().post(`${editPath}:commit`, {}).query({ changesNotSentForReview: false }).reply(404);
await expect(
client.postAsync(
'/androidpublisher/v3/applications/:packageName/edits/:editId:commit',
@@ -87,7 +93,62 @@ it('does not retry an ambiguous edit commit', async () => {
{ packageName, editId: 'edit' },
{ query: { changesNotSentForReview: false } }
)
).rejects.toBeInstanceOf(SystemError);
).rejects.toMatchObject({
errors: [expect.any(Error), expect.objectContaining({ status: 404 })],
});
});
it.each([408, 429, 500, 503])('stops after five retries for HTTP %s', async status => {
api().get(editPath).times(6).reply(status);
await expect(
client.getAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
packageName,
editId: 'edit',
})
).rejects.toMatchObject({
cause: expect.objectContaining({ status }),
errors: expect.arrayContaining([expect.objectContaining({ status })]),
});
});
it('retries the same track update body after a temporary error', async () => {
const body = {
track: 'internal',
releases: [{ versionCodes: ['123'], status: 'draft' as const }],
};
api().put(`${editPath}/tracks/internal`, body).reply(503);
api().put(`${editPath}/tracks/internal`, body).reply(200, { track: 'internal' });
await expect(
client.putAsync(
'/androidpublisher/v3/applications/:packageName/edits/:editId/tracks/:track',
body,
{ packageName, editId: 'edit', track: 'internal' }
)
).resolves.toEqual({ track: 'internal' });
});
it('does not add retries to upload-session requests', async () => {
api().post('/upload/session').reply(503);
await expect(client.requestAsync('POST', '/upload/session')).rejects.toMatchObject({
status: 503,
});
});
it('stops retrying when cancelled after a transient response', async () => {
const controller = new AbortController();
api()
.get(editPath)
.reply(() => {
controller.abort();
return [503];
});
await expect(
client.getAsync(
'/androidpublisher/v3/applications/:packageName/edits/:editId',
{ packageName, editId: 'edit' },
controller.signal
)
).rejects.toBeDefined();
});
it('deletes an edit with an empty response', async () => {
@@ -100,6 +161,44 @@ it('deletes an edit with an empty response', async () => {
).resolves.toBeUndefined();
});
it('reports a retry even when the next request succeeds', async () => {
const logger = createMockLogger();
client = new GooglePlayClient(serviceAccount, logger);
api().get(editPath).reply(503);
api().get(editPath).reply(200, { id: 'edit' });
await client.getAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
packageName,
editId: 'edit',
});
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
'Retrying Google Play request (HTTP 503, attempt 2/6)...'
);
});
it('accepts a missing edit after a lost delete response', async () => {
api()
.delete(editPath)
.replyWithError(Object.assign(new Error('Lost response'), { code: 'ECONNRESET' }));
api().delete(editPath).reply(404);
await expect(
client.deleteAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
packageName,
editId: 'edit',
})
).resolves.toBeUndefined();
});
it('retains an initial delete rejection', async () => {
api().delete(editPath).reply(404);
await expect(
client.deleteAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
packageName,
editId: 'edit',
})
).rejects.toMatchObject({ status: 404 });
});
it('rejects malformed success responses without exposing their contents', async () => {
api()
.get(editPath)
@@ -124,6 +223,42 @@ it('retains structured API errors', async () => {
).rejects.toMatchObject({ status: 403, apiMessage: 'Denied', reasons: ['forbidden'] });
});
it.each(['CERT_HAS_EXPIRED', 'EPROTO', 'EACCES', undefined])(
'does not retry a system error with code %s',
async code => {
const logger = createMockLogger();
client = new GooglePlayClient(serviceAccount, logger);
api()
.get(editPath)
.replyWithError(Object.assign(new Error('Request failed'), { code }));
await expect(
client.getAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
packageName,
editId: 'edit',
})
).rejects.toMatchObject({
cause: expect.objectContaining({ type: 'system' }),
});
expect(logger.warn).not.toHaveBeenCalled();
}
);
it.each(['EAI_AGAIN', 'ECONNREFUSED', 'ECONNRESET', 'UND_ERR_SOCKET'])(
'retries a recognised network error %s',
async code => {
api()
.get(editPath)
.replyWithError(Object.assign(new Error('Request failed'), { code }));
api().get(editPath).reply(200, { id: 'edit' });
await expect(
client.getAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
packageName,
editId: 'edit',
})
).resolves.toEqual({ id: 'edit' });
}
);
it.each(['body', 'query'])('accepts extra fields in an empty %s object', async field => {
api()
.post(`/androidpublisher/v3/applications/${packageName}/edits`, {})
@@ -0,0 +1,271 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import nock from 'nock';
import { Response } from 'node-fetch';
import { api, editPath, mockToken, packageName, serviceAccount } from './googlePlayTestUtils';
import { createMockLogger } from '../../../../__tests__/utils/logger';
import { GooglePlayAuthRequestError } from '../GooglePlayAuthClient';
import { GooglePlayApiError } from '../GooglePlayApiError';
import { GooglePlayClient } from '../GooglePlayClient';
import { GooglePlayCommitOutcomeUnknownError, GooglePlayUtils } from '../GooglePlayUtils';
jest.unmock('node-fetch');
jest.mock('promise-retry', () => {
const actual = jest.requireActual('promise-retry');
return (fn: Parameters<typeof actual>[0], options: object) =>
actual(fn, { ...options, minTimeout: 1, maxTimeout: 1, randomize: false });
});
beforeEach(() => nock.disableNetConnect());
afterEach(() => {
const pending = nock.pendingMocks();
nock.cleanAll();
nock.enableNetConnect();
expect(pending).toEqual([]);
});
function commit({
signal,
changesNotSentForReview = false,
logger = createMockLogger(),
}: {
signal?: AbortSignal;
changesNotSentForReview?: boolean;
logger?: ReturnType<typeof createMockLogger>;
} = {}) {
return GooglePlayUtils.commitEditAsync(new GooglePlayClient(serviceAccount), {
packageName,
editId: 'edit',
changesNotSentForReview,
logger,
signal,
});
}
it.each([
[false, {}],
[true, { changesNotSentForReview: 'true' }],
] as const)(
'commits with changesNotSentForReview=%s without a failed first request',
async (changesNotSentForReview, query) => {
mockToken();
api().post(`${editPath}:commit`).query(query).reply(200, { id: 'edit' });
await expect(commit({ changesNotSentForReview })).resolves.toBeUndefined();
}
);
it.each([
[
true,
{ changesNotSentForReview: 'true' },
'The query parameter changesNotSentForReview must not be set',
{},
],
[
false,
{},
'Please set the query parameter changesNotSentForReview to true',
{ changesNotSentForReview: 'true' },
],
] as const)(
'retries the rejected review setting (requested=%s)',
async (changesNotSentForReview, initialQuery, message, retryQuery) => {
const logger = createMockLogger();
mockToken();
api().post(`${editPath}:commit`).query(initialQuery).reply(400, { error: { message } });
api().post(`${editPath}:commit`).query(retryQuery).reply(200, { id: 'edit' });
await expect(commit({ changesNotSentForReview, logger })).resolves.toBeUndefined();
expect(logger.info).toHaveBeenLastCalledWith(
changesNotSentForReview
? 'Google Play saved the release using its normal review process. Check its review and publishing status at https://play.google.com/console/.'
: 'Google Play saved the changes without sending them for review. Send them for review at https://play.google.com/console/.'
);
}
);
it.each([408, 429, 503])('retries a transient HTTP %s commit on the same edit', async status => {
mockToken();
api().post(`${editPath}:commit`).query(true).reply(status);
api().post(`${editPath}:commit`).query({}).reply(200, { id: 'edit' });
await expect(commit()).resolves.toBeUndefined();
});
it('preserves cancellation before commit is sent', async () => {
const controller = new AbortController();
const reason = new Error('cancelled before commit');
controller.abort(reason);
await expect(commit({ signal: controller.signal })).rejects.toBe(reason);
});
const pendingUploadMessage = 'Some of the Android App Bundle uploads are not completed yet.';
it.each([false, true])(
'waits for bundle processing without changing review settings (%s)',
async changesNotSentForReview => {
const logger = createMockLogger();
const query = changesNotSentForReview ? { changesNotSentForReview: 'true' } : {};
mockToken();
api()
.post(`${editPath}:commit`, {})
.query(query)
.reply(400, { error: { message: pendingUploadMessage } });
api().post(`${editPath}:commit`, {}).query(query).reply(200, { id: 'edit' });
await expect(commit({ changesNotSentForReview, logger })).resolves.toBeUndefined();
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('Google Play is still processing')
);
}
);
it('stops waiting for bundle processing after six attempts', async () => {
mockToken();
api()
.post(`${editPath}:commit`)
.query({})
.times(6)
.reply(400, { error: { message: pendingUploadMessage } });
const error = await commit().catch(error => error);
expect(GooglePlayUtils.mapGooglePlayError(error, packageName)).toMatchObject({
message: expect.stringContaining('Google Play did not finish processing'),
metadata: {
googlePlayRequestPath: `${editPath}:commit`,
googlePlayErrorResponse: JSON.stringify({ error: { message: pendingUploadMessage } }),
},
});
});
it('does not retry an unrelated commit precondition', async () => {
mockToken();
api()
.post(`${editPath}:commit`)
.query({})
.reply(400, { error: { message: 'Precondition check failed.' } });
await expect(commit()).rejects.toBeInstanceOf(GooglePlayApiError);
});
it('checks cancellation before sending a processing retry', async () => {
const controller = new AbortController();
const reason = new Error('cancelled while waiting');
const logger = createMockLogger();
jest.spyOn(logger, 'warn').mockImplementation(() => controller.abort(reason));
mockToken();
api()
.post(`${editPath}:commit`)
.query({})
.reply(400, { error: { message: pendingUploadMessage } });
await expect(commit({ signal: controller.signal, logger })).rejects.toBe(reason);
});
it('does not report success if a processing retry has no valid commit receipt', async () => {
mockToken();
api()
.post(`${editPath}:commit`)
.query({})
.reply(400, { error: { message: pendingUploadMessage } });
api().post(`${editPath}:commit`).query({}).reply(200, { id: 'different-edit' });
await expect(commit()).rejects.toBeInstanceOf(GooglePlayCommitOutcomeUnknownError);
});
it('preserves OAuth rejection before commit is sent', async () => {
nock('https://oauth2.googleapis.com').post('/token').reply(400, { error: 'invalid_grant' });
await expect(commit()).rejects.toBeInstanceOf(GooglePlayAuthRequestError);
});
it.each([
[404, 'Package not found', 'EAS_GOOGLE_PLAY_FIRST_UPLOAD'],
[403, 'Version code 144 has already been used', 'EAS_GOOGLE_PLAY_VERSION_CODE_USED'],
[403, 'The caller does not have permission', 'EAS_GOOGLE_PLAY_PERMISSION_DENIED'],
[403, 'privacy policy required', 'EAS_GOOGLE_PLAY_PRIVACY_POLICY_REQUIRED'],
[403, 'wrong signing key', 'EAS_GOOGLE_PLAY_REQUEST_FAILED'],
[400, 'privacy policy required', 'EAS_GOOGLE_PLAY_PRIVACY_POLICY_REQUIRED'],
[400, 'Only releases with status draft may be created on draft app', 'EAS_GOOGLE_PLAY_DRAFT_APP'],
[400, 'Staged rollout is not permitted on draft app', 'EAS_GOOGLE_PLAY_DRAFT_APP'],
[409, 'edit invalid', 'EAS_GOOGLE_PLAY_EDIT_INVALID'],
[400, 'unknown track', 'EAS_GOOGLE_PLAY_INVALID_RELEASE'],
] as const)('keeps the Google error behind %s %s', (status, message, code) => {
const original = new GooglePlayApiError(status, message, []);
const mapped = GooglePlayUtils.mapGooglePlayError(original, packageName);
expect(mapped).toBeInstanceOf(UserError);
expect(mapped).toMatchObject({
errorCode: code,
cause: original,
});
if (code === 'EAS_GOOGLE_PLAY_REQUEST_FAILED' || code === 'EAS_GOOGLE_PLAY_INVALID_RELEASE') {
expect((mapped as Error).message).toContain(message);
} else {
expect((mapped as Error).message).not.toContain(message);
}
expect((mapped as Error).message).toContain(`App: ${packageName}`);
expect((mapped as Error).message).toContain('https://play.google.com/console/');
expect((mapped as Error).message).not.toContain('Google Play error:');
if (code === 'EAS_GOOGLE_PLAY_DRAFT_APP') {
expect((mapped as Error).message).toContain('without a rollout');
expect((mapped as Error).message).toContain('releaseStatus: "draft" in eas.json');
}
});
it('maps a rejected OAuth key to a credential error', () => {
const original = new GooglePlayAuthRequestError(400, 'invalid_grant');
expect(GooglePlayUtils.mapGooglePlayError(original, packageName)).toMatchObject({
errorCode: 'EAS_GOOGLE_PLAY_INVALID_CREDENTIALS',
cause: original,
});
});
it.each([408, 429, 500, 503])('classifies temporary HTTP %s errors as system errors', status => {
for (const original of [
new GooglePlayApiError(status, 'temporary failure', []),
new GooglePlayAuthRequestError(status, 'temporarily_unavailable'),
]) {
const mapped = GooglePlayUtils.mapGooglePlayError(original, packageName);
expect(mapped).toBeInstanceOf(SystemError);
expect(mapped).toMatchObject({ cause: original });
expect((mapped as Error).message).not.toContain('settings');
}
});
it('keeps non-Google errors unchanged', () => {
const error = new SystemError('file failure');
expect(GooglePlayUtils.mapGooglePlayError(error, packageName)).toBe(error);
});
it('keeps the Google Play error response in metadata, not in the message', async () => {
const response = {
error: {
code: 400,
message: 'Precondition check failed.',
status: 'FAILED_PRECONDITION',
details: [
{
'@type': 'type.googleapis.com/google.rpc.PreconditionFailure',
violations: [{ type: 'APP_SETUP', description: 'Complete the app setup.' }],
},
],
},
};
mockToken();
api().post(`${editPath}:commit`).query({}).reply(400, response);
const error = await commit().catch(error => error);
const mapped = GooglePlayUtils.mapGooglePlayError(error, packageName);
expect(mapped).toMatchObject({
errorCode: 'EAS_GOOGLE_PLAY_INVALID_RELEASE',
metadata: {
googlePlayRequestPath: `${editPath}:commit`,
googlePlayErrorResponse: JSON.stringify(response),
},
});
expect((mapped as Error).message).not.toContain('APP_SETUP');
});
it('keeps the last Google Play error response when the commit outcome is unknown', async () => {
mockToken();
api()
.post(`${editPath}:commit`)
.query({})
.times(6)
.reply(503, { error: { message: 'Backend error' } });
await expect(commit()).rejects.toMatchObject({
metadata: {
googlePlayRequestPath: `${editPath}:commit`,
googlePlayErrorResponse: JSON.stringify({ error: { message: 'Backend error' } }),
},
});
});
it('does not keep the upload session query or a non-JSON response', async () => {
const error = await GooglePlayApiError.fromResponseAsync(
new Response('<html>upload_id=SECRET</html>', {
status: 400,
url: 'https://androidpublisher.googleapis.com/upload/session?upload_id=SECRET',
})
);
expect(GooglePlayApiError.getErrorMetadata(error)).toEqual({
googlePlayRequestPath: '/upload/session',
googlePlayErrorResponse: undefined,
});
});
@@ -0,0 +1,370 @@
import nock from 'nock';
import { truncateSync } from 'node:fs';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { api, editPath, mockToken, packageName, serviceAccount } from './googlePlayTestUtils';
import { GooglePlayClient } from '../GooglePlayClient';
import { GooglePlayUtils } from '../GooglePlayUtils';
jest.unmock('node-fetch');
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
jest.mock('promise-retry', () => {
const promiseRetry = jest.requireActual('promise-retry');
return (fn: Parameters<typeof promiseRetry>[0], options: object) =>
promiseRetry(fn, { ...options, minTimeout: 0, maxTimeout: 0 });
});
let directory: string;
let artifactPath: string;
let client: GooglePlayClient;
const session = '/upload/session';
const chunkSize = 8 * 1024 * 1024;
beforeEach(async () => {
nock.disableNetConnect();
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'play-client-test-'));
artifactPath = path.join(directory, 'binary');
await fs.writeFile(artifactPath, Buffer.alloc(12));
client = new GooglePlayClient(serviceAccount);
});
afterEach(async () => {
const pending = nock.pendingMocks();
nock.cleanAll();
nock.enableNetConnect();
jest.restoreAllMocks();
await fs.rm(directory, { recursive: true, force: true });
expect(pending).toEqual([]);
});
function mockStart(location = `https://androidpublisher.googleapis.com${session}`): void {
api()
.post(`/upload${editPath}/apks`)
.query({ uploadType: 'resumable' })
.reply(200, '', { Location: location });
}
function upload(signal?: AbortSignal, onProgress = jest.fn()) {
return GooglePlayUtils.uploadApplicationAsync(client, {
packageName,
editId: 'edit',
artifactPath,
artifactType: 'apk',
signal,
onProgress,
}).then(result => result.versionCode);
}
function uploadApi(): nock.Scope {
return nock('https://androidpublisher.googleapis.com', { badheaders: ['authorization'] });
}
function status(total = 12): nock.Interceptor {
return uploadApi()
.put(session)
.matchHeader('content-length', '0')
.matchHeader('content-range', `bytes */${total}`);
}
it('uploads aligned chunks and reports server-confirmed progress', async () => {
await fs.writeFile(artifactPath, Buffer.alloc(chunkSize + 12));
mockToken();
mockStart();
uploadApi()
.put(session)
.matchHeader('content-length', String(chunkSize))
.matchHeader('content-range', `bytes 0-${chunkSize - 1}/${chunkSize + 12}`)
.reply(308, '', { Range: `bytes=0-${chunkSize - 1}` });
uploadApi()
.put(session)
.matchHeader('content-length', '12')
.matchHeader('content-range', `bytes ${chunkSize}-${chunkSize + 11}/${chunkSize + 12}`)
.reply(200, { versionCode: 42 });
const progress = jest.fn();
await expect(upload(undefined, progress)).resolves.toBe(42);
expect(progress.mock.calls).toEqual([
[0, chunkSize + 12],
[chunkSize, chunkSize + 12],
[chunkSize + 12, chunkSize + 12],
]);
});
it.each(['network', 408, 429, 503])(
'recovers the accepted offset after a %s interruption',
async failure => {
mockToken();
mockStart();
const interrupted = uploadApi().put(session).matchHeader('content-range', 'bytes 0-11/12');
if (failure === 'network') {
interrupted.replyWithError(
Object.assign(new Error('connection reset'), { code: 'ECONNRESET' })
);
} else {
interrupted.reply(failure as number, { error: { message: 'retry' } });
}
status().reply(308, '', { Range: 'bytes=0-5' });
uploadApi()
.put(session)
.matchHeader('content-range', 'bytes 6-11/12')
.reply(200, { versionCode: 42 });
const progress = jest.fn();
await expect(upload(undefined, progress)).resolves.toBe(42);
expect(progress.mock.calls).toEqual([
[0, 12],
[6, 12],
[12, 12],
]);
}
);
it('recovers a completed upload when the final upload response is lost', async () => {
mockToken();
mockStart();
uploadApi()
.put(session)
.matchHeader('content-length', '12')
.replyWithError(Object.assign(new Error('lost final response'), { code: 'ECONNRESET' }));
status().reply(200, { versionCode: 42 });
await expect(upload()).resolves.toBe(42);
});
it('recovers completion after a damaged response body', async () => {
mockToken();
mockStart();
uploadApi().put(session).matchHeader('content-length', '12').reply(200, '{invalid JSON');
status().reply(200, { versionCode: 42 });
await expect(upload()).resolves.toBe(42);
});
it('sanitizes a malformed session URL', async () => {
mockToken();
mockStart('INVALID SECRET URL');
const error = await upload().catch(error => error);
expect(error).toBeInstanceOf(Error);
expect(String(error)).toContain('invalid upload URL');
expect(JSON.stringify(error)).not.toContain('SECRET');
expect(String(error)).not.toContain('SECRET');
});
it('retries status recovery after a transient Google error', async () => {
mockToken();
mockStart();
uploadApi().put(session).matchHeader('content-length', '12').reply(503);
status().reply(429);
status().reply(308);
uploadApi().put(session).matchHeader('content-length', '12').reply(200, { versionCode: 42 });
await expect(upload()).resolves.toBe(42);
});
it.each([404, 410])('restarts an expired %s upload session within the same edit', async expired => {
mockToken();
mockStart();
uploadApi().put(session).reply(expired);
mockStart();
uploadApi().put(session).reply(200, { versionCode: 42 });
const progress = jest.fn();
await expect(upload(undefined, progress)).resolves.toBe(42);
expect(progress.mock.calls).toEqual([
[0, 12],
[0, 12],
[12, 12],
]);
});
it('stops after the upload retry limit', async () => {
mockToken();
mockStart();
uploadApi().put(session).matchHeader('content-length', '12').reply(503);
status().times(5).reply(503);
await expect(upload()).rejects.toThrow('HTTP 503');
});
it('stops after the upload session restart limit', async () => {
mockToken();
for (let i = 0; i < 3; i++) {
mockStart();
uploadApi().put(session).reply(410);
}
await expect(upload()).rejects.toThrow('HTTP 410');
});
it('cancels before upload and does not request credentials', async () => {
const controller = new AbortController();
controller.abort();
await expect(upload(controller.signal)).rejects.toBe(controller.signal.reason);
});
it('cancels between chunks without sending the next chunk', async () => {
await fs.writeFile(artifactPath, Buffer.alloc(chunkSize + 12));
mockToken();
mockStart();
uploadApi()
.put(session)
.reply(308, '', { Range: `bytes=0-${chunkSize - 1}` });
const controller = new AbortController();
const reason = new Error('Upload cancelled');
await expect(
upload(
controller.signal,
jest.fn((bytes: number) => {
if (bytes > 0) {
controller.abort(reason);
}
})
)
).rejects.toBe(reason);
});
it.each([
'https://attacker.example/upload?secret=signed',
'http://androidpublisher.googleapis.com/upload',
'https://androidpublisher.googleapis.com.attacker.example/upload',
'https://user:[email protected]/upload',
])('rejects an unsafe session URL: %s', async location => {
mockToken();
mockStart(location);
await expect(upload()).rejects.toThrow('unsafe upload URL');
});
it('does not follow an upload redirect', async () => {
mockToken();
mockStart();
uploadApi().put(session).reply(302, '', { Location: 'https://attacker.example/upload' });
await expect(upload()).rejects.toThrow('HTTP 302');
});
it.each(['bytes=0-100', 'invalid', 'bytes=5-8'])(
'rejects an invalid acknowledged range: %s',
async range => {
mockToken();
mockStart();
uploadApi().put(session).reply(308, '', { Range: range });
await expect(upload()).rejects.toThrow('invalid upload range');
}
);
it('stops when uploads and status checks repeatedly confirm no progress', async () => {
mockToken();
mockStart();
uploadApi().put(session).matchHeader('content-length', '12').times(6).reply(308);
status().times(5).reply(308);
await expect(upload()).rejects.toThrow('confirmed 0 of 12 bytes');
});
it('preserves Google upload error details', async () => {
mockToken();
mockStart();
uploadApi()
.put(session)
.reply(403, {
error: {
message: 'Version code has already been used.',
errors: [{ reason: 'apkNotificationMessageKeyUpgradeVersionConflict' }],
},
});
await expect(upload()).rejects.toMatchObject({
status: 403,
apiMessage: 'Version code has already been used.',
reasons: ['apkNotificationMessageKeyUpgradeVersionConflict'],
});
});
it('resets the retry budget after confirmed progress', async () => {
mockToken();
mockStart();
for (let offset = 0; offset < 6; offset++) {
uploadApi().put(session).matchHeader('content-range', `bytes ${offset}-11/12`).reply(503);
status().reply(308, '', { Range: `bytes=0-${offset}` });
}
uploadApi()
.put(session)
.matchHeader('content-range', 'bytes 6-11/12')
.reply(200, { versionCode: 42 });
await expect(upload()).resolves.toBe(42);
});
it('retries a network failure during session creation', async () => {
mockToken();
api()
.post(`/upload${editPath}/apks`)
.query({ uploadType: 'resumable' })
.replyWithError(Object.assign(new Error('connection reset'), { code: 'ECONNRESET' }));
mockStart();
uploadApi().put(session).reply(200, { versionCode: 42 });
await expect(upload()).resolves.toBe(42);
});
it.each(['session', 'chunk', 'body', 'status'])('recovers a stalled %s request', async stalled => {
const timeout = AbortSignal.timeout.bind(AbortSignal);
const guard = timeout(1500);
jest.spyOn(AbortSignal, 'timeout').mockImplementation(() => timeout(100));
mockToken();
if (stalled === 'session') {
api()
.post(`/upload${editPath}/apks`)
.query({ uploadType: 'resumable' })
.delayConnection(300)
.reply(200, '', { Location: `https://androidpublisher.googleapis.com${session}` });
mockStart();
uploadApi().put(session).reply(200, { versionCode: 42 });
} else if (stalled === 'status') {
mockStart();
uploadApi().put(session).matchHeader('content-length', '12').reply(503);
status().delayConnection(300).reply(308);
status().reply(200, { versionCode: 42 });
} else {
mockStart();
const request = uploadApi().put(session).matchHeader('content-length', '12');
if (stalled === 'chunk') {
request.delayConnection(300);
} else {
request.delayBody(300);
}
request.reply(200, { versionCode: 42 });
status().reply(200, { versionCode: 42 });
}
await expect(upload(guard)).resolves.toBe(42);
});
it('stops when the binary becomes shorter during upload', async () => {
mockToken();
mockStart();
await expect(
upload(
undefined,
jest.fn(() => truncateSync(artifactPath, 0))
)
).rejects.toThrow('expected 12 bytes at offset 0, got 0');
});
it.each([
{ completes: true, finalResponse: 'lost' },
{ completes: false, finalResponse: 'lost' },
{ completes: true, finalResponse: 'stored' },
{ completes: false, finalResponse: 'stored' },
])(
'bounds completion checks (completes: $completes, final response: $finalResponse)',
async ({ completes, finalResponse }) => {
mockToken();
mockStart();
const finalChunk = uploadApi().put(session).matchHeader('content-length', '12');
if (finalResponse === 'lost') {
finalChunk.replyWithError(
Object.assign(new Error('lost final response'), { code: 'ECONNRESET' })
);
} else {
finalChunk.reply(308, '', { Range: 'bytes=0-11' });
}
status()
.times(completes ? 1 : 5)
.reply(308, '', { Range: 'bytes=0-11' });
if (completes) {
status().reply(200, { versionCode: 42 });
await expect(upload()).resolves.toBe(42);
} else {
await expect(upload()).rejects.toThrow(
'Google stored all 12 bytes but has not completed the upload.'
);
}
}
);
@@ -0,0 +1,21 @@
import nock from 'nock';
import { generateKeyPairSync } from 'node:crypto';
export const packageName = 'dev.expo.submitfixture';
export const editPath = `/androidpublisher/v3/applications/${packageName}/edits/edit`;
export const serviceAccount = {
client_email: '[email protected]',
private_key_id: 'test-key',
private_key: generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey,
};
export const api = (): nock.Scope =>
nock('https://androidpublisher.googleapis.com', {
reqheaders: { authorization: 'Bearer test-access-token' },
});
export function mockToken(times = 1): nock.Scope {
return nock('https://oauth2.googleapis.com').post('/token').times(times).reply(200, {
access_token: 'test-access-token',
expires_in: 3600,
token_type: 'Bearer',
});
}
@@ -7,9 +7,11 @@ import {
ArtifactPutError,
ArtifactPutNetworkError,
type ArtifactSource,
type ArtifactUploadSession,
putArtifactWithRetriesAsync,
} from './artifactPut';
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
import { getDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
const CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION = graphql(`
mutation CreateDeviceRunSessionArtifactUploadSession(
@@ -69,7 +71,33 @@ async function createDeviceRunSessionArtifactUploadSessionAsync(
ctx: CustomBuildContext,
{ deviceRunSessionId, artifactId, name, filename, kind, metadata, size }: ArtifactMetadata,
signal: AbortSignal
) {
): Promise<ArtifactUploadSession> {
const workerApi = getDeviceRunSessionWorkerApi(ctx);
if (workerApi) {
let uploadSession: ArtifactUploadSession;
try {
uploadSession = await workerApi.createArtifactUploadSessionAsync(
{
name,
filename,
...(kind !== undefined ? { kind } : {}),
...(metadata !== undefined ? { metadata } : {}),
size,
},
{ signal }
);
} catch (err) {
signal.throwIfAborted();
throw new SystemError(
`Failed to create upload session for device run session artifact ${artifactId}: ${
err instanceof Error ? err.message : String(err)
}`,
{ cause: err }
);
}
signal.throwIfAborted();
return uploadSession;
}
const result = await ctx.graphqlClient
.mutation(
CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION,
@@ -10,6 +10,7 @@ import HttpLogStream from '../../logging/HttpLogStream';
import RemoteLoggerStream from '../../logging/RemoteLoggerStream';
import { Sentry } from '../../sentry';
import { type SignedUrl } from '../../storage/uploadWithSignedUrl';
import { getDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
const POLL_INTERVAL_MS = 1_000;
const UPLOAD_INTERVAL_MS = 5_000;
@@ -349,6 +350,19 @@ async function createEventLogUploadSessionAsync(
ctx: CustomBuildContext,
deviceRunSessionId: string
): Promise<SignedUrl> {
const workerApi = getDeviceRunSessionWorkerApi(ctx);
if (workerApi) {
try {
return await workerApi.createEventLogUploadSessionAsync();
} catch (err) {
throw new SystemError(
`Failed to create device run session event log upload session: ${
err instanceof Error ? err.message : String(err)
}`,
{ cause: err }
);
}
}
const result = await ctx.graphqlClient
.mutation(CREATE_DEVICE_RUN_SESSION_EVENT_LOG_UPLOAD_SESSION_MUTATION, {
deviceRunSessionId,
@@ -1,7 +1,7 @@
import { type bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import spawn, { type SpawnOptions } from '@expo/turtle-spawn';
import { graphql } from 'gql.tada';
import fetch from 'node-fetch';
import { createWriteStream } from 'node:fs';
@@ -11,6 +11,8 @@ import os from 'node:os';
import path from 'node:path';
import { type CustomBuildContext } from '../../customBuildContext';
import { killProcessGroup } from '../../utils/processes';
import { getDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
const PREVIEW_INTERVAL_MS = 60_000;
const PREVIEW_MAX_DIMENSION = 320;
@@ -82,31 +84,7 @@ export function startDeviceRunSessionPreview({
throw new Error('Session preview is empty or exceeds the 5 MiB upload limit.');
}
if (!uploadSession) {
const result = await ctx.graphqlClient
.mutation(
CREATE_PREVIEW_UPLOAD_SESSION_MUTATION,
{ deviceRunSessionId },
{
// Keep the client's auth headers and combine cancellation after URQL sets its signal.
fetch: (url, options) =>
globalThis.fetch(url, {
...options,
signal: AbortSignal.any([signal, ...(options?.signal ? [options.signal] : [])]),
}),
}
)
.toPromise();
if (result.error) {
throw result.error;
}
const session = result.data?.deviceRunSession.createPreviewUploadSession.uploadSession;
if (!session) {
throw new Error('No session preview upload URL was returned.');
}
uploadSession = {
url: session.url,
headers: session.headers as Record<string, string>,
};
uploadSession = await createPreviewUploadSessionAsync(ctx, deviceRunSessionId, signal);
}
if (controller.signal.aborted) {
return;
@@ -139,6 +117,42 @@ export function startDeviceRunSessionPreview({
};
}
async function createPreviewUploadSessionAsync(
ctx: CustomBuildContext,
deviceRunSessionId: string,
signal: AbortSignal
): Promise<{ url: string; headers: Record<string, string> }> {
const workerApi = getDeviceRunSessionWorkerApi(ctx);
if (workerApi) {
return await workerApi.createPreviewUploadSessionAsync({ signal });
}
const result = await ctx.graphqlClient
.mutation(
CREATE_PREVIEW_UPLOAD_SESSION_MUTATION,
{ deviceRunSessionId },
{
// Keep the client's auth headers and combine cancellation after URQL sets its signal.
fetch: (url, options) =>
globalThis.fetch(url, {
...options,
signal: AbortSignal.any([signal, ...(options?.signal ? [options.signal] : [])]),
}),
}
)
.toPromise();
if (result.error) {
throw result.error;
}
const session = result.data?.deviceRunSession.createPreviewUploadSession.uploadSession;
if (!session) {
throw new Error('No session preview upload URL was returned.');
}
return {
url: session.url,
headers: session.headers as Record<string, string>,
};
}
/**
* Homebrew's FFmpeg is built without libwebp, so macOS encodes previews with `cwebp` from
* Homebrew's webp package instead. Throws when it cannot be installed.
@@ -146,18 +160,47 @@ export function startDeviceRunSessionPreview({
export async function ensureMacosPreviewEncoderInstalledAsync({
env,
logger,
signal,
}: {
env: BuildStepEnv;
logger: bunyan;
signal?: AbortSignal;
}): Promise<void> {
if ((await asyncResult(spawn('cwebp', ['-version'], { env, stdio: 'pipe' }))).ok) {
const probe = await asyncResult(
runEncoderCommandAsync('cwebp', ['-version'], { env, stdio: 'pipe' }, signal)
);
if (probe.ok) {
return;
}
signal?.throwIfAborted();
logger.info('Installing webp with Homebrew for the session preview.');
await spawn('brew', ['install', 'webp'], {
env: { ...env, HOMEBREW_NO_AUTO_UPDATE: '1' },
logger,
});
await runEncoderCommandAsync(
'brew',
['install', 'webp'],
{ env: { ...env, HOMEBREW_NO_AUTO_UPDATE: '1' }, logger },
signal
);
}
async function runEncoderCommandAsync(
command: string,
args: string[],
options: SpawnOptions,
signal?: AbortSignal
): Promise<void> {
signal?.throwIfAborted();
const task = spawn(command, args, { ...options, detached: true });
const abort = (): void => killProcessGroup(task.child, 'SIGKILL');
signal?.addEventListener('abort', abort, { once: true });
if (signal?.aborted) {
abort();
}
try {
await task;
signal?.throwIfAborted();
} finally {
signal?.removeEventListener('abort', abort);
}
}
export async function captureDeviceRunSessionPreviewAsync({
@@ -1,9 +1,11 @@
import type { bunyan } from '@expo/logger';
import { BuildRuntimePlatform } from '@expo/steps';
import { graphql } from 'gql.tada';
import { z } from 'zod';
import type { CustomBuildContext } from '../../customBuildContext';
import { graphqlAbortContext } from '../../utils/graphqlAbort';
import { getDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
const SHORT_COMMIT_HASH_LENGTH = 7;
const MAX_APP_SLUG_LENGTH = 80;
@@ -44,7 +46,15 @@ const ScreenshotSessionSchema = z.object({
.nullable(),
});
export type ScreenshotSession = z.infer<typeof ScreenshotSessionSchema>;
/**
* Session details used to name screenshots. `app` is null when the source does not report an app
* slug: the device run session worker API does not return one.
*/
export type ScreenshotSession = {
platform: 'IOS' | 'ANDROID';
app: { slug: string } | null;
build: { id: string; gitCommitHash: string | null; app: { slug: string } | null } | null;
};
export async function loadScreenshotSessionAsync(
ctx: CustomBuildContext,
@@ -52,12 +62,26 @@ export async function loadScreenshotSessionAsync(
logger: bunyan
): Promise<ScreenshotSession | null> {
try {
const signal = AbortSignal.timeout(SESSION_LOOKUP_TIMEOUT_MS);
const workerApi = getDeviceRunSessionWorkerApi(ctx);
if (workerApi) {
const application = await workerApi.getApplicationAsync({ signal });
return {
// www runs iOS sessions only on macOS workers and Android sessions only on Linux workers,
// so the worker API does not return the platform.
platform: ctx.runtimePlatform === BuildRuntimePlatform.DARWIN ? 'IOS' : 'ANDROID',
app: null,
build: application.build
? {
id: application.build.id,
gitCommitHash: application.build.gitCommitHash,
app: null,
}
: null,
};
}
const result = await ctx.graphqlClient
.query(
SCREENSHOT_SESSION_QUERY,
{ deviceRunSessionId },
graphqlAbortContext(AbortSignal.timeout(SESSION_LOOKUP_TIMEOUT_MS))
)
.query(SCREENSHOT_SESSION_QUERY, { deviceRunSessionId }, graphqlAbortContext(signal))
.toPromise();
if (result.error) {
throw result.error;
@@ -86,18 +110,20 @@ export function screenshotArtifactDetails(
}
const app = session.build?.app ?? session.app;
const appSlug =
app.slug
.replace(FILENAME_UNSAFE_RUN, '-')
.replace(LEADING_OR_TRAILING_DOTS_AND_HYPHENS, '')
.slice(0, MAX_APP_SLUG_LENGTH) || 'app';
const platform = session.platform.toLowerCase();
const filenameParts = [appSlug, platform];
const metadata: Record<string, string> = {
__eas_type: 'screenshot',
appSlug: app.slug,
platform,
};
const filenameParts = [platform];
const metadata: Record<string, string> = { __eas_type: 'screenshot' };
// Without an app slug, the filename starts with the platform.
if (app) {
const appSlug =
app.slug
.replace(FILENAME_UNSAFE_RUN, '-')
.replace(LEADING_OR_TRAILING_DOTS_AND_HYPHENS, '')
.slice(0, MAX_APP_SLUG_LENGTH) || 'app';
filenameParts.unshift(appSlug);
metadata.appSlug = app.slug;
}
metadata.platform = platform;
if (session.build) {
metadata.buildId = session.build.id;
@@ -0,0 +1,322 @@
import { type EnvironmentSecret, EnvironmentSecretType, SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import { z } from 'zod';
import { type CustomBuildContext } from '../../customBuildContext';
import { turtleFetch } from '../../utils/turtleFetch';
/**
* The environment secret that holds the device run session token. www gives it only to the job of
* one device run session, and the token names that session.
*/
export const DEVICE_RUN_SESSION_TOKEN_ENV = 'EAS_DEVICE_RUN_SESSION_TOKEN';
/**
* www reads the device run session token only from this header, and only on the
* `/v2/device-run-session-worker/*` routes. It is not an `Authorization` credential.
*/
export const DEVICE_RUN_SESSION_TOKEN_HEADER = 'expo-device-run-session-token';
const DEVICE_RUN_SESSION_WORKER_PATH = 'device-run-session-worker/';
/**
* www returns the entity value of the status. The client maps it to the GraphQL enum value, so
* callers compare statuses the same way on both paths.
*/
const DEVICE_RUN_SESSION_STATUS_FROM_ENTITY_VALUE = {
new: 'NEW',
queued: 'QUEUED',
starting: 'STARTING',
'in-progress': 'IN_PROGRESS',
stopped: 'STOPPED',
errored: 'ERRORED',
} as const;
const DeviceRunSessionStatusSchema = z.object({
id: z.string(),
status: z
.enum(['new', 'queued', 'starting', 'in-progress', 'stopped', 'errored'])
.transform(status => DEVICE_RUN_SESSION_STATUS_FROM_ENTITY_VALUE[status]),
});
const UploadSessionSchema = z.object({
uploadSession: z.object({
url: z.string(),
headers: z.record(z.string(), z.string()),
}),
});
const TurnIceServersSchema = z.object({
iceServers: z.array(
z.object({
urls: z.array(z.string()),
username: z.string().optional(),
credential: z.string().optional(),
})
),
});
const DeviceRunSessionApplicationSchema = z.object({
build: z
.object({
id: z.string(),
gitCommitHash: z.string().nullable(),
applicationArchiveUrl: z.string().nullable(),
})
.nullable(),
});
const ApiV2ErrorResponseSchema = z.object({
errors: z.array(z.object({ code: z.string().optional(), message: z.string() })).min(1),
});
export type DeviceRunSessionStatus = z.infer<typeof DeviceRunSessionStatusSchema>;
export type DeviceRunSessionUploadSession = z.infer<typeof UploadSessionSchema>['uploadSession'];
export type DeviceRunSessionTurnIceServers = z.infer<typeof TurnIceServersSchema>['iceServers'];
export type DeviceRunSessionApplication = z.infer<typeof DeviceRunSessionApplicationSchema>;
export type CreateDeviceRunSessionArtifactUploadSessionInput = {
name: string;
filename: string;
kind?: string | null;
size: number;
metadata?: Record<string, unknown> | null;
};
type RequestOptions = {
/** Stops the request when aborted. */
signal?: AbortSignal;
/** node-fetch timeout of each attempt, in milliseconds. */
timeoutMs?: number;
/** Additional attempts after a failed one. Defaults to none, so each call sends one request. */
retries?: number;
logger?: bunyan;
};
/**
* A request to the device run session worker API failed. The message names the route and the
* reason. It never includes the device run session token.
*/
export class DeviceRunSessionWorkerApiError extends Error {
constructor(
message: string,
/** The HTTP status of the response, or undefined when no response arrived. */
readonly status: number | undefined
) {
super(message);
this.name = 'DeviceRunSessionWorkerApiError';
}
}
/**
* Client for the www routes under `/v2/device-run-session-worker/`. These routes act only on the
* session that the token names, so no request carries a session ID.
*/
export class DeviceRunSessionWorkerApi {
constructor(
private readonly expoApiV2BaseUrl: string,
private readonly deviceRunSessionToken: string
) {}
public async getSessionAsync(options: RequestOptions = {}): Promise<DeviceRunSessionStatus> {
return await this.requestAsync('GET', 'session', DeviceRunSessionStatusSchema, options);
}
public async startAsync(
{ remoteConfig }: { remoteConfig: Record<string, unknown> },
options: RequestOptions = {}
): Promise<DeviceRunSessionStatus> {
return await this.requestAsync('POST', 'start', DeviceRunSessionStatusSchema, {
...options,
json: { remoteConfig },
});
}
public async stopAsync(options: RequestOptions = {}): Promise<DeviceRunSessionStatus> {
return await this.requestAsync('POST', 'stop', DeviceRunSessionStatusSchema, options);
}
public async createArtifactUploadSessionAsync(
input: CreateDeviceRunSessionArtifactUploadSessionInput,
options: RequestOptions = {}
): Promise<DeviceRunSessionUploadSession> {
const { uploadSession } = await this.requestAsync(
'POST',
'artifact-upload-sessions',
UploadSessionSchema,
{ ...options, json: input }
);
return uploadSession;
}
public async createEventLogUploadSessionAsync(
options: RequestOptions = {}
): Promise<DeviceRunSessionUploadSession> {
const { uploadSession } = await this.requestAsync(
'POST',
'event-log-upload-session',
UploadSessionSchema,
options
);
return uploadSession;
}
public async createPreviewUploadSessionAsync(
options: RequestOptions = {}
): Promise<DeviceRunSessionUploadSession> {
const { uploadSession } = await this.requestAsync(
'POST',
'preview-upload-session',
UploadSessionSchema,
options
);
return uploadSession;
}
public async fetchTurnIceServersAsync(
options: RequestOptions = {}
): Promise<DeviceRunSessionTurnIceServers> {
const { iceServers } = await this.requestAsync(
'POST',
'turn-ice-servers',
TurnIceServersSchema,
options
);
return iceServers;
}
public async getApplicationAsync(
options: RequestOptions = {}
): Promise<DeviceRunSessionApplication> {
return await this.requestAsync(
'GET',
'application',
DeviceRunSessionApplicationSchema,
options
);
}
private async requestAsync<T>(
method: 'GET' | 'POST',
route: string,
dataSchema: z.ZodType<T>,
{
signal,
timeoutMs,
retries = 0,
logger,
json,
}: RequestOptions & { json?: Record<string, unknown> }
): Promise<T> {
const routeDescription = `${method} /v2/${DEVICE_RUN_SESSION_WORKER_PATH}${route}`;
const url = new URL(
`${DEVICE_RUN_SESSION_WORKER_PATH}${route}`,
this.expoApiV2BaseUrl
).toString();
const responseResult = await asyncResult(
turtleFetch(url, method, {
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: this.deviceRunSessionToken },
...(json ? { json } : {}),
...(signal ? { signal } : {}),
...(timeoutMs !== undefined ? { timeout: timeoutMs } : {}),
retries,
shouldThrowOnNotOk: false,
logger,
})
);
if (!responseResult.ok) {
const reason = responseResult.reason;
throw new DeviceRunSessionWorkerApiError(
`${routeDescription} failed before www responded: ${
reason instanceof Error ? reason.message : String(reason)
}. Check the network connection of the worker.`,
undefined
);
}
const response = responseResult.value;
const bodyResult = await asyncResult(response.json());
if (!response.ok) {
const apiError = bodyResult.ok ? ApiV2ErrorResponseSchema.safeParse(bodyResult.value) : null;
const apiErrorMessage = apiError?.success
? apiError.data.errors.map(error => error.message).join(' ')
: response.statusText;
throw new DeviceRunSessionWorkerApiError(
`${routeDescription} failed with status ${response.status}: ${apiErrorMessage}`,
response.status
);
}
if (!bodyResult.ok) {
throw new DeviceRunSessionWorkerApiError(
`${routeDescription} returned a response that is not JSON.`,
response.status
);
}
const parsed = z.object({ data: dataSchema }).safeParse(bodyResult.value);
if (!parsed.success) {
throw new DeviceRunSessionWorkerApiError(
`${routeDescription} returned an unexpected response: ${parsed.error.message}`,
response.status
);
}
return parsed.data.data;
}
}
/**
* Returns the device run session token when www gave one to this job, or null. Without a token,
* callers keep using the robot access token and the account-wide API.
*/
export function getDeviceRunSessionToken(
env: Record<string, string | undefined> | undefined
): string | null {
const token = env?.[DEVICE_RUN_SESSION_TOKEN_ENV];
return token ? token : null;
}
/**
* Returns the device run session token from the job's environment secrets, or null. It resolves
* duplicate names like the build context env does (the last secret wins), so the worker and the
* steps use the same token. A file secret is not a token.
*/
export function getDeviceRunSessionTokenFromEnvironmentSecrets(
environmentSecrets: EnvironmentSecret[] | undefined
): string | null {
const secret = [...(environmentSecrets ?? [])]
.reverse()
.find(({ name }) => name === DEVICE_RUN_SESSION_TOKEN_ENV);
if (!secret || secret.type !== EnvironmentSecretType.STRING || !secret.value) {
return null;
}
return secret.value;
}
/**
* Returns a client for the device run session worker API when this job has a device run session
* token, or null when it does not.
*/
export function getDeviceRunSessionWorkerApi(
ctx: Pick<CustomBuildContext, 'env' | 'expoApiV2BaseUrl'>
): DeviceRunSessionWorkerApi | null {
const token = getDeviceRunSessionToken(ctx.env);
if (!token) {
return null;
}
return new DeviceRunSessionWorkerApi(getExpoApiV2BaseUrl(ctx), token);
}
function getExpoApiV2BaseUrl(ctx: Pick<CustomBuildContext, 'env' | 'expoApiV2BaseUrl'>): string {
if (ctx.expoApiV2BaseUrl) {
return ctx.expoApiV2BaseUrl;
}
if (ctx.env.__API_SERVER_URL) {
return new URL('/v2/', ctx.env.__API_SERVER_URL).toString();
}
throw new SystemError(
`${DEVICE_RUN_SESSION_TOKEN_ENV} is set, but the API server URL is not. ` +
'The worker cannot call the device run session API without it. ' +
'Check that the worker sets __API_SERVER_URL for device run session jobs.'
);
}
@@ -1,5 +1,6 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import { BuildPhaseResult, SystemError, UserError } from '@expo/eas-build-job';
import type { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
import { randomBytes } from 'node:crypto';
import fs from 'node:fs';
@@ -29,12 +30,12 @@ import {
import { startDeviceRunSessionScreenshotsAsync } from './deviceRunSessionScreenshots';
import {
type DetachedProcessHandle,
type ServeSimApplicationOptions,
type ServeSimLaunchOptions,
ensureFfmpegInstalledOnceAsync,
fetchWebPreviewTurnArgsAsync,
findAvailablePortAsync,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
isProcessRunning,
spawnDetached,
startNgrokTunnelAsync,
@@ -45,6 +46,11 @@ import {
SERVE_SIM_STOP_GRACE_PERIOD_MS,
} from './IosSimulatorRecordingUtils';
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
import { startLogPhase } from '../../utils/logPhase';
import { runServeSimActionAsync, stageServeSimAppAsync } from './serveSimActions';
import { verifyLocalEgressGuardAsync } from './localEgressGuard';
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
import { createStartupTasks } from './startupTasks';
const WEB_PREVIEW_HOST = '127.0.0.1';
const SERVE_SIM_PACKAGE_NAME = '@expo/serve-sim';
@@ -56,6 +62,8 @@ const EXPO_DEVICE_HUB_PACKAGE_NAME = 'expo-device-hub';
const EXPO_DEVICE_HUB_MAX_DIMENSION = '960';
const EXPO_DEVICE_HUB_VIDEO_BITRATE = '6000000';
const EXPO_DEVICE_HUB_VIDEO_FPS = '60';
// expo-device-hub's record of each running Hub, like serve-sim's: `server-<port>.json`.
export const EXPO_DEVICE_HUB_STATE_DIR = path.join(os.tmpdir(), 'expo-device-hub');
// On SIGTERM the Hub finalizes the recording itself, with this deadline before it force-exits,
// so a stop request that failed here still gets one more chance to write the MP4.
const EXPO_DEVICE_HUB_SIGTERM_FINALIZE_DEADLINE_MS = 60_000;
@@ -80,7 +88,8 @@ export function simulatorPreviewPageUrl(env: BuildStepEnv, subdomainId: string):
// Local website hosts are shared by local and staging simulator sessions.
const WEBSITE_DEV_ORIGINS = ['https://expo.test', 'https://*.expo.test'];
export function websiteOriginServeSimArgs(env: BuildStepEnv): string[] {
// serve-sim and expo-device-hub take the same website origin flags and values.
export function websiteOriginArgs(env: BuildStepEnv): string[] {
const origins = new Set([websiteOrigin(env)]);
if (!env.EXPO_LOCAL && env.EXPO_STAGING) {
origins.add('https://*.expo.dev');
@@ -93,6 +102,16 @@ export function websiteOriginServeSimArgs(env: BuildStepEnv): string[] {
return [...origins].flatMap(origin => ['--cors-origin', origin, '--frame-ancestor', origin]);
}
// Both hosts take the same flag; it only applies under --require-token, which both run with.
export function frameAncestorArgs(frameAncestors: readonly string[]): string[] {
return frameAncestors.flatMap(origin => ['--frame-ancestor', origin]);
}
// Both hosts take the same flag and match the same origin shapes.
export function corsOriginArgs(corsOrigins: readonly string[]): string[] {
return corsOrigins.flatMap(origin => ['--cors-origin', origin]);
}
function createServeSimPackageSpec(packageVersion: string | undefined): string {
return `${SERVE_SIM_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
}
@@ -105,8 +124,13 @@ export function createServeSimArgs({
port,
turnArgs = [],
websiteArgs = [],
frameAncestorArgs: frameArgs = [],
corsOriginArgs: corsArgs = [],
shareUrl,
disableShare = false,
packageVersion,
iosSimulatorUdid,
installAppPath,
launchAppIdentifier,
launchArgs = [],
openUrl,
@@ -116,13 +140,19 @@ export function createServeSimArgs({
port: number;
turnArgs?: string[];
websiteArgs?: string[];
frameAncestorArgs?: string[];
corsOriginArgs?: string[];
shareUrl?: string;
disableShare?: boolean;
packageVersion?: string;
iosSimulatorUdid?: string;
installAppPath?: string;
networkCapture?: boolean;
networkCaptureFields?: string[];
} & ServeSimLaunchOptions): string[] {
return [
createServeSimPackageSpec(packageVersion),
...(iosSimulatorUdid ? [iosSimulatorUdid] : []),
'--port',
String(port),
'--host',
@@ -142,8 +172,12 @@ export function createServeSimArgs({
SERVE_SIM_VIDEO_FPS,
...turnArgs,
...websiteArgs,
...frameArgs,
...corsArgs,
...(shareUrl ? ['--share-url', shareUrl] : []),
...(disableShare ? ['--disable-share'] : []),
...(launchAppIdentifier ? ['--launch-app-identifier', launchAppIdentifier] : []),
...(installAppPath ? ['--install-app-path', installAppPath] : []),
...launchArgs.flatMap(argument => ['--launch-arg', argument]),
...(openUrl ? ['--open-url', openUrl] : []),
// `--network-capture` also covers an already booted simulator. Fields are repeated, not
@@ -160,11 +194,17 @@ export function createServeSimArgs({
export function createExpoDeviceHubArgs({
port,
turnArgs = [],
websiteArgs = [],
frameAncestorArgs: frameArgs = [],
corsOriginArgs: corsArgs = [],
packageVersion,
recordingDirectory,
}: {
port: number;
turnArgs?: string[];
websiteArgs?: string[];
frameAncestorArgs?: string[];
corsOriginArgs?: string[];
packageVersion?: string;
recordingDirectory?: string;
}): string[] {
@@ -174,6 +214,7 @@ export function createExpoDeviceHubArgs({
String(port),
'--host',
WEB_PREVIEW_HOST,
'--require-token',
'--platform',
'android',
'--transport',
@@ -192,6 +233,9 @@ export function createExpoDeviceHubArgs({
'--hide-boot-device',
...(recordingDirectory ? ['--android-recording-directory', recordingDirectory] : []),
...turnArgs,
...websiteArgs,
...frameArgs,
...corsArgs,
];
}
@@ -200,25 +244,39 @@ const WebPreviewReadyResponseSchema = z.object({
device: z.string(),
});
// Startup output includes bearer URLs (or a token field with --quiet).
function redactPreviewTokens(output: string): string {
return output
.replace(/([?&#]token=)[^\s&"'<>]+/g, '$1[redacted]')
.replace(/("token"\s*:\s*")[^"]*/g, '$1[redacted]');
}
export async function waitForWebPreviewReadyAsync({
previewServer,
serverName,
port,
timeoutMs,
startupTimeoutMs,
signal,
}: {
previewServer: Pick<DetachedProcessHandle, 'pid' | 'getOutput'>;
serverName: string;
port: number;
timeoutMs: number;
startupTimeoutMs?: number;
signal?: AbortSignal;
}): Promise<string> {
const readyUrl = `http://${WEB_PREVIEW_HOST}:${port}/readyz`;
const deadline = Date.now() + timeoutMs;
let deadline = Date.now() + (startupTimeoutMs ?? timeoutMs);
// Preserve the boot budget until the host starts answering.
let waitingForHost = startupTimeoutMs !== undefined;
let lastError: unknown;
while (Date.now() < deadline) {
signal?.throwIfAborted();
if (previewServer.pid !== undefined && !isProcessRunning(previewServer.pid)) {
throw new SystemError(
`${serverName} exited before becoming ready. Last output:\n${
previewServer.getOutput() || '<empty>'
redactPreviewTokens(previewServer.getOutput()) || '<empty>'
}`
);
}
@@ -226,21 +284,52 @@ export async function waitForWebPreviewReadyAsync({
const response = await turtleFetch(readyUrl, 'GET', {
retries: 0,
timeout: 2_000,
shouldThrowOnNotOk: false,
signal,
});
if (!response.ok) {
throw new SystemError(`${serverName} readiness returned HTTP ${response.status}`);
}
const ready = WebPreviewReadyResponseSchema.parse(await response.json());
signal?.throwIfAborted();
return ready.device;
} catch (error) {
const connectionRefused =
typeof error === 'object' &&
error !== null &&
'code' in error &&
error.code === 'ECONNREFUSED';
if (waitingForHost && !connectionRefused) {
waitingForHost = false;
deadline = Math.min(deadline, Date.now() + timeoutMs);
}
lastError = error;
}
signal?.throwIfAborted();
await sleepAsync(WEB_PREVIEW_READY_POLL_INTERVAL_MS);
}
signal?.throwIfAborted();
throw new SystemError(
`Timed out waiting for ${serverName} readiness at ${readyUrl}${
lastError instanceof Error ? `: ${lastError.message}` : ''
}. Last output:\n${previewServer.getOutput() || '<empty>'}`
}. Last output:\n${redactPreviewTokens(previewServer.getOutput()) || '<empty>'}`
);
}
export async function readExpoDeviceHubPreviewTokenAsync(
port: number,
stateDir: string = EXPO_DEVICE_HUB_STATE_DIR
): Promise<string | undefined> {
try {
const state = JSON.parse(
await fs.promises.readFile(path.join(stateDir, `server-${port}.json`), 'utf-8')
) as { token?: unknown };
return typeof state.token === 'string' ? state.token : undefined;
} catch {
return undefined;
}
}
export async function readServeSimPreviewTokenAsync(
udid: string,
stateDir: string = SERVE_SIM_STATE_DIR
@@ -251,6 +340,8 @@ export async function readServeSimPreviewTokenAsync(
export type DeviceWebPreview = {
previewPageUrl: string;
/** Human-facing link, absent when a private job has no authenticated URL. */
sharePageUrl?: string;
apiUrl: string;
previewToken?: string;
/** Closes this tunnel only. The session host and recording remain running. */
@@ -258,7 +349,7 @@ export type DeviceWebPreview = {
};
export type DeviceSessionHost = {
openPreviewAsync(options: { baseDomain: string }): Promise<DeviceWebPreview>;
openPreviewAsync(options: { baseDomain: string; authtoken: string }): Promise<DeviceWebPreview>;
/**
* Terminal and idempotent. Never rejects: a failed finalization, host stop or upload is logged
* and reported, so callers need no error handling around it.
@@ -273,30 +364,94 @@ type AndroidSessionRecording = {
env: BuildStepEnv;
};
type DeviceSessionHostOptions = {
runtimePlatform: BuildRuntimePlatform;
env: BuildStepEnv;
logger: bunyan;
timeoutMs: number;
separateLogPhase?: boolean;
startupTimeoutMs?: number;
signal?: AbortSignal;
packageVersion?: string;
disableShare?: boolean;
privatePreviewUrl?: string;
/** Origins allowed to embed the preview host, in addition to the website origins. */
frameAncestors?: string[];
/** Origins whose pages may call the preview host's API, in addition to the website origins. */
corsOrigins?: string[];
iosSimulatorUdid?: string;
installAppPath?: string;
bootEnv?: Record<string, string>;
application?: Promise<ServeSimApplicationOptions | void>;
onStartupError?: (error: unknown) => void;
networkCapture?: boolean;
networkCaptureFields?: string[];
} & ServeSimLaunchOptions;
export async function startDeviceSessionHostAsync(
ctx: CustomBuildContext,
options: DeviceSessionHostOptions
): Promise<DeviceSessionHost> {
if (!options.separateLogPhase) {
return await startDeviceSessionHostInternalAsync(ctx, options, () => {}, options.logger);
}
const phase = startLogPhase(options.logger, 'Simulator preview');
let ready = false;
try {
const host = await startDeviceSessionHostInternalAsync(
ctx,
{ ...options, logger: phase.logger },
successful =>
phase.end(ready && successful ? BuildPhaseResult.SUCCESS : BuildPhaseResult.FAIL),
options.logger
);
ready = true;
return host;
} catch (error) {
phase.end(BuildPhaseResult.FAIL);
throw error;
}
}
async function startDeviceSessionHostInternalAsync(
ctx: CustomBuildContext,
{
runtimePlatform,
env,
logger,
timeoutMs,
startupTimeoutMs,
signal,
packageVersion,
iosSimulatorUdid,
installAppPath,
application,
onStartupError,
disableShare = false,
privatePreviewUrl,
frameAncestors = [],
corsOrigins = [],
launchAppIdentifier,
launchArgs,
openUrl,
networkCapture = false,
networkCaptureFields = [],
}: {
runtimePlatform: BuildRuntimePlatform;
env: BuildStepEnv;
logger: bunyan;
timeoutMs: number;
packageVersion?: string;
networkCapture?: boolean;
networkCaptureFields?: string[];
} & ServeSimLaunchOptions
bootEnv = {},
}: DeviceSessionHostOptions,
onFinished: (successful: boolean) => void,
artifactLogger: bunyan
): Promise<DeviceSessionHost> {
signal?.throwIfAborted();
const isAndroid = runtimePlatform === BuildRuntimePlatform.LINUX;
if (
Object.keys(bootEnv).length > 0 &&
(runtimePlatform !== BuildRuntimePlatform.DARWIN || !iosSimulatorUdid)
) {
throw new UserError(
'EAS_IOS_SIMULATOR_BOOT_INVALID_INPUT',
'Simulator boot options require an explicit iOS Simulator.'
);
}
// Unreachable from the step functions, which reject a non-Darwin launch while parsing.
// Kept because this function is exported and expo-device-hub cannot launch.
if (isAndroid && networkCapture) {
@@ -311,6 +466,9 @@ export async function startDeviceSessionHostAsync(
`Cannot launch ${launchAppIdentifier}: an application launch runs through serve-sim on an iOS simulator, and this session runs expo-device-hub on ${runtimePlatform}.`
);
}
if (application && (isAndroid || !iosSimulatorUdid)) {
throw new SystemError('Deferred startup requires an explicit iOS Simulator.');
}
if (isAndroid) {
await ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
}
@@ -322,8 +480,13 @@ export async function startDeviceSessionHostAsync(
env,
}
: null;
startupTimeoutMs ??= iosSimulatorUdid ? 30 * 60_000 : undefined;
const startup = createStartupTasks(logger, signal);
const serveSimTmpdir = env.TMPDIR ?? os.tmpdir();
const serveSimStateDir = env.SERVE_SIM_STATE_DIR ?? SERVE_SIM_STATE_DIR;
const subdomainId = randomBytes(16).toString('hex');
const previewPageUrl = simulatorPreviewPageUrl(env, subdomainId);
const sharePageUrl = disableShare ? privatePreviewUrl : previewPageUrl;
const port = await findAvailablePortAsync();
const serverName = isAndroid ? 'expo-device-hub' : 'serve-sim';
const packageSpec = isAndroid
@@ -338,16 +501,25 @@ export async function startDeviceSessionHostAsync(
turnArgs,
packageVersion,
recordingDirectory: recording?.directory,
websiteArgs: websiteOriginArgs(env),
frameAncestorArgs: frameAncestorArgs(frameAncestors),
corsOriginArgs: corsOriginArgs(corsOrigins),
})
: createServeSimArgs({
port,
turnArgs,
packageVersion,
websiteArgs: websiteOriginServeSimArgs(env),
shareUrl: previewPageUrl,
launchAppIdentifier,
launchArgs,
openUrl,
iosSimulatorUdid,
websiteArgs: websiteOriginArgs(env),
frameAncestorArgs: frameAncestorArgs(frameAncestors),
corsOriginArgs: corsOriginArgs(corsOrigins),
// serve-sim adds its bearer token to --share-url; private links are for worker logs only.
shareUrl: disableShare ? undefined : previewPageUrl,
disableShare,
installAppPath: iosSimulatorUdid ? undefined : installAppPath,
launchAppIdentifier: iosSimulatorUdid ? undefined : launchAppIdentifier,
launchArgs: iosSimulatorUdid ? [] : launchArgs,
openUrl: iosSimulatorUdid ? undefined : openUrl,
networkCapture,
networkCaptureFields,
})
@@ -355,24 +527,37 @@ export async function startDeviceSessionHostAsync(
logger.info(
`Launching ${packageSpec} on ${WEB_PREVIEW_HOST}:${port} via ${previewExec.command}.`
);
// Redact these credentials before forwarding process output to build logs.
const secrets = [
...turnArgs.filter((_, index) => turnArgs[index - 1] === '--turn-credential'),
...(recording ? [recording.controlToken] : []),
];
const screenshots = await startDeviceRunSessionScreenshotsAsync(ctx, {
deviceRunSessionId: getDeviceRunSessionIdOrThrow(env),
logger,
logger: artifactLogger,
});
let previewServer: DetachedProcessHandle;
try {
signal?.throwIfAborted();
previewServer = spawnDetached({
command: previewExec.command,
args: previewExec.args,
env: {
...env,
...bootEnv,
...(isAndroid ? {} : { TMPDIR: serveSimTmpdir, SERVE_SIM_STATE_DIR: serveSimStateDir }),
EXPO_DEVICE_HUB_SCREENSHOT_DIRECTORY: screenshots.directory,
...(recording ? { EXPO_DEVICE_HUB_RECORDING_CONTROL_TOKEN: recording.controlToken } : {}),
// The Hub would resolve its state directory from the step's env, which may set its own
// TMPDIR or state directory, so it writes its token where the host reads it below.
...(isAndroid ? { EXPO_DEVICE_HUB_STATE_DIR } : {}),
},
// Only an Android host records through expo-device-hub; serve-sim records on iOS.
stopGracePeriodMs: recording
? RECORDING_STOP_GRACE_PERIOD_MS
: SERVE_SIM_STOP_GRACE_PERIOD_MS,
logger,
secrets,
});
} catch (error) {
// Nothing was spawned, so nothing can still write into the directory.
@@ -380,14 +565,21 @@ export async function startDeviceSessionHostAsync(
throw error;
}
let stagedAppDirectory: string | undefined;
let beforeLaunch: ReturnType<typeof asyncResult<void>> | undefined;
const readinessController = new AbortController();
const readinessSignal = AbortSignal.any([startup.signal, readinessController.signal]);
let previewToken: string | undefined;
let previewTask: Promise<DeviceWebPreview> | null = null;
let finishTask: Promise<void> | null = null;
let hostReady = false;
let sessionPreview: ReturnType<typeof startDeviceRunSessionPreview> | null = null;
const previewSetupController = new AbortController();
let previewSetupTask: Promise<void> | undefined;
let previewFailed = false;
const host: DeviceSessionHost = {
openPreviewAsync({ baseDomain }) {
openPreviewAsync({ baseDomain, authtoken }) {
if (finishTask) {
return Promise.reject(
new SystemError('Cannot open a preview after session host finalization.')
@@ -402,7 +594,7 @@ export async function startDeviceSessionHostAsync(
subdomainPrefix: 'web-preview',
subdomainId,
baseDomain,
authtoken: getNgrokAuthtokenOrThrow(env),
authtoken,
logger,
});
if (finishTask) {
@@ -413,8 +605,10 @@ export async function startDeviceSessionHostAsync(
throw new SystemError('Session host finalized while the preview was opening.');
}
let closeTask: Promise<void> | null = null;
previewFailed = false;
return {
previewPageUrl,
sharePageUrl,
apiUrl: tunnel.url,
previewToken,
closeAsync() {
@@ -433,6 +627,7 @@ export async function startDeviceSessionHostAsync(
previewTask = opening;
// A failed tunnel may be retried without restarting capture.
void opening.catch(() => {
previewFailed = true;
if (previewTask === opening) {
previewTask = null;
}
@@ -440,9 +635,14 @@ export async function startDeviceSessionHostAsync(
return opening;
},
finishAsync() {
readinessController.abort(new Error('Simulator preview ended.'));
return (finishTask ??= finishDeviceSessionHostAsync(ctx, {
previewTask,
stopSessionPreviewAsync: async () => await sessionPreview?.stopAsync(),
stopSessionPreviewAsync: async () => {
previewSetupController.abort();
await previewSetupTask;
await sessionPreview?.stopAsync();
},
previewServer,
screenshots,
serverName,
@@ -450,35 +650,135 @@ export async function startDeviceSessionHostAsync(
// A host that never answered /readyz has nothing to finalize or upload.
recording: hostReady ? recording : null,
logger,
}));
artifactLogger,
onStopped: successful => onFinished(successful && !previewFailed),
})
.finally(async () => {
await beforeLaunch;
if (stagedAppDirectory) {
await fs.promises.rm(stagedAppDirectory, { recursive: true, force: true });
}
})
.catch(err => {
artifactLogger.warn({ err }, 'Could not finish the simulator preview.');
onFinished(false);
}));
},
};
try {
logger.info(`Waiting for ${serverName} to become ready.`);
const device = await waitForWebPreviewReadyAsync({
previewServer,
serverName,
port,
timeoutMs,
});
hostReady = true;
if (!isAndroid) {
previewToken = await readServeSimPreviewTokenAsync(device);
if (!previewToken) {
throw new SystemError(
`serve-sim became ready but wrote no session token for device ${device}. The preview is ` +
'on a public tunnel and would be reachable without one, so the session cannot continue. ' +
'This usually means the state file was not written as expected; retry the session, and ' +
'report it if it repeats.'
const readiness = asyncResult(
waitForWebPreviewReadyAsync({
previewServer,
serverName,
port,
timeoutMs,
startupTimeoutMs,
signal: readinessSignal,
}).catch(error => {
startup.abort(error);
throw error;
})
);
if (iosSimulatorUdid) {
previewToken = await waitForServeSimControlAsync({
device: iosSimulatorUdid,
port,
previewServer,
stateDir: serveSimStateDir,
timeoutMs: startupTimeoutMs ?? timeoutMs,
signal: readinessSignal,
});
secrets.push(previewToken);
if (Object.keys(bootEnv).length > 0) {
await startup.untilAborted(
verifyLocalEgressGuardAsync({ udid: iosSimulatorUdid as IosSimulatorUuid, env, logger })
);
}
signal?.throwIfAborted();
beforeLaunch = asyncResult(
startup.untilAborted(
startup.run('Simulator setup', async taskLogger => {
try {
await IosSimulatorUtils.disableApsdAsync({
udid: iosSimulatorUdid as IosSimulatorUuid,
env,
});
} catch (err) {
startup.signal.throwIfAborted();
taskLogger.warn({ err }, 'Failed to disable apsd in the Simulator.');
}
startup.signal.throwIfAborted();
})
)
);
const app = (application ? await startup.untilAborted(application) : undefined) ?? {
installAppPath,
launchAppIdentifier,
launchArgs,
openUrl,
};
signal?.throwIfAborted();
const action = (name: string, params: Record<string, unknown>) =>
runServeSimActionAsync({
port,
token: previewToken!,
action: name,
params,
timeoutMs: startupTimeoutMs ?? timeoutMs,
signal: startup.signal,
});
if (app.installAppPath) {
const staged = await stageServeSimAppAsync(app.installAppPath, serveSimTmpdir);
stagedAppDirectory = staged.directory;
signal?.throwIfAborted();
await action('app.install', { udid: iosSimulatorUdid, path: staged.path });
}
(await beforeLaunch)?.enforceValue();
if (app.launchAppIdentifier) {
signal?.throwIfAborted();
await action('app.launch', {
udid: iosSimulatorUdid,
bundleId: app.launchAppIdentifier,
launchArgs: app.launchArgs ?? [],
openUrl: app.openUrl,
});
}
}
logger.info(`Waiting for ${serverName} to become ready.`);
const device = (await readiness).enforceValue();
if (iosSimulatorUdid && device.toLowerCase() !== iosSimulatorUdid.toLowerCase()) {
throw new SystemError(
`serve-sim became ready on ${device}, but this session requested ${iosSimulatorUdid}.`
);
}
hostReady = true;
// Both servers record their session token in a state file once they are ready.
if (isAndroid) {
previewToken = await readExpoDeviceHubPreviewTokenAsync(port, EXPO_DEVICE_HUB_STATE_DIR);
} else {
previewToken ??= await readServeSimPreviewTokenAsync(device, serveSimStateDir);
}
if (!previewToken) {
throw new SystemError(
`${serverName} became ready but wrote no session token for device ${device}. The ` +
'preview is on a public tunnel and would be reachable without one, so the session ' +
'cannot continue. This usually means the state file was not written as expected; ' +
'retry the session, and report it if it repeats.'
);
}
secrets.push(previewToken);
if (!isAndroid) {
IosSimulatorRecordingUtils.useServeSimPackage(packageSpec);
}
// Android installed FFmpeg before launching the host. The optional thumbnail must not delay
// readiness on macOS, so it starts after its encoder is installed there.
void (async () => {
previewSetupTask = (async () => {
if (!isAndroid) {
await ensureMacosPreviewEncoderInstalledAsync({ env, logger });
await ensureMacosPreviewEncoderInstalledAsync({
env,
logger,
signal: previewSetupController.signal,
});
}
if (!finishTask) {
sessionPreview = startDeviceRunSessionPreview({
@@ -495,15 +795,60 @@ export async function startDeviceSessionHostAsync(
});
}
})().catch(err => {
logger.warn({ err }, 'Could not start refreshing the session preview.');
if (!previewSetupController.signal.aborted) {
logger.warn({ err }, 'Could not start refreshing the session preview.');
}
});
signal?.throwIfAborted();
return host;
} catch (error) {
onStartupError?.(error);
await host.finishAsync();
throw error;
}
}
async function waitForServeSimControlAsync({
device,
port,
stateDir,
previewServer,
timeoutMs,
signal,
}: {
device: string;
port: number;
stateDir: string;
previewServer: DetachedProcessHandle;
timeoutMs: number;
signal?: AbortSignal;
}): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
signal?.throwIfAborted();
if (
previewServer.getExitError() !== undefined ||
(previewServer.pid !== undefined && !isProcessRunning(previewServer.pid))
) {
throw new SystemError(
`serve-sim exited before its control API became ready. ${previewServer.getOutput()}`
);
}
const servers = await readServeSimServersAsync(stateDir);
const server = servers.find(
server =>
server.udid.toLowerCase() === device.toLowerCase() &&
server.url === `http://${WEB_PREVIEW_HOST}:${port}`
);
if (server?.token) {
return server.token;
}
await sleepAsync(WEB_PREVIEW_READY_POLL_INTERVAL_MS);
}
signal?.throwIfAborted();
throw new SystemError('Timed out waiting for serve-sim control readiness.');
}
async function finishDeviceSessionHostAsync(
ctx: CustomBuildContext,
{
@@ -515,6 +860,8 @@ async function finishDeviceSessionHostAsync(
port,
recording,
logger,
artifactLogger,
onStopped,
}: {
previewTask: Promise<DeviceWebPreview> | null;
stopSessionPreviewAsync: () => Promise<void>;
@@ -524,10 +871,13 @@ async function finishDeviceSessionHostAsync(
port: number;
recording: AndroidSessionRecording | null;
logger: bunyan;
artifactLogger: bunyan;
onStopped: (successful: boolean) => void;
}
): Promise<void> {
// Stop capturing before the host stops, so the last thumbnail shows the session, not shutdown.
await stopSessionPreviewAsync();
const hostExited = previewServer.getExitError() !== undefined;
// Native ngrok operations have no scoped cancellation. Retire a late listener too.
const retirePreview = withDeviceRunSessionTimeoutAsync(
{ name: 'Preview tunnel retirement', timeoutMs: 5_000 },
@@ -536,9 +886,13 @@ async function finishDeviceSessionHostAsync(
const preview = await previewTask?.catch(() => null);
await preview?.closeAsync();
}
).catch(err => {
logger.warn({ err }, `Could not close the ${serverName} preview tunnel within its deadline.`);
});
).then(
() => true,
err => {
logger.warn({ err }, `Could not close the ${serverName} preview tunnel within its deadline.`);
return false;
}
);
let finalization: AndroidRecordingFinalization | null = null;
if (recording) {
// stopAsync signals the whole process group, including capture's encoder.
@@ -559,18 +913,26 @@ async function finishDeviceSessionHostAsync(
} catch (err) {
logger.warn({ err }, `Could not stop the ${serverName} session host.`);
}
await retirePreview;
const previewRetired = await retirePreview;
onStopped(hostStopped && !hostExited && previewRetired);
await screenshots.finishAsync(hostStopped);
// A Hub that never recorded has logged its reason and left nothing to upload.
const captured = finalization !== 'not-recording';
let uploaded = false;
if (recording && captured && hostStopped) {
uploaded = await uploadFinishedAndroidRecordingAsync(ctx, { recording, logger });
uploaded = await uploadFinishedAndroidRecordingAsync(ctx, {
recording,
logger: artifactLogger,
});
}
if (recording && captured && (finalization === 'failed' || !uploaded)) {
// The Hub reports capture failures on stderr; the stop route answers with only a summary.
logger.warn(
{ hostOutput: previewServer.getOutput().slice(-HOST_OUTPUT_TAIL_CHARS) || '<empty>' },
artifactLogger.warn(
{
hostOutput:
redactPreviewTokens(previewServer.getOutput()).slice(-HOST_OUTPUT_TAIL_CHARS) ||
'<empty>',
},
'Session host output around the recording failure.'
);
}
@@ -0,0 +1,41 @@
import { UserError } from '@expo/eas-build-job';
import { type BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import path from 'node:path';
export async function readIosApplicationIdentifierAsync({
artifactPath,
env,
}: {
artifactPath: string;
env: BuildStepEnv;
}): Promise<string> {
const artifactStat = await fs.promises.stat(artifactPath).catch(err => {
throw new UserError(
'EAS_INSTALL_BUILD_INVALID_ARTIFACT',
`Build artifact does not exist at ${artifactPath}.`,
{ cause: err }
);
});
if (path.extname(artifactPath) !== '.app' || !artifactStat.isDirectory()) {
throw new UserError(
'EAS_INSTALL_BUILD_INVALID_ARTIFACT',
'iOS Simulator sessions require a .app build artifact.'
);
}
const infoPlistPath = path.join(artifactPath, 'Info.plist');
const { stdout } = await spawn(
'plutil',
['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', infoPlistPath],
{ stdio: 'pipe', env }
);
const applicationIdentifier = stdout.trim();
if (!applicationIdentifier) {
throw new UserError(
'EAS_INSTALL_BUILD_MISSING_IDENTIFIER',
`Could not read CFBundleIdentifier from ${infoPlistPath}.`
);
}
return applicationIdentifier;
}
@@ -0,0 +1,110 @@
import { UserError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { minBy } from 'lodash';
import {
type IosSimulatorName,
IosSimulatorUtils,
type IosSimulatorUuid,
} from '../../utils/IosSimulatorUtils';
import {
resolveLocalEgressServeSimBootEnvironmentAsync,
startLocalEgressGuardRelayAsync,
} from './localEgressGuard';
import { readIosApplicationIdentifierAsync } from './iosAppArtifact';
import {
type ServeSimLaunchOptions,
parseServeSimLaunchInputs,
validateServeSimLaunchOptions,
} from './remoteDeviceRunSession';
export type IosSessionStartup = {
iosSimulatorUdid: string;
installAppPath?: string;
bootEnv?: Record<string, string>;
};
export async function resolveIosSessionStartupAsync({
runtimePlatform,
bootSimulator,
deviceIdentifier,
installAppPath,
launchAppIdentifier,
launchArgs,
openUrl,
env,
logger,
signal,
}: {
runtimePlatform: BuildRuntimePlatform;
bootSimulator?: boolean;
deviceIdentifier?: string;
installAppPath?: string;
launchAppIdentifier?: unknown;
launchArgs?: unknown;
openUrl?: unknown;
env: BuildStepEnv;
logger: bunyan;
signal?: AbortSignal;
}): Promise<{ launch: ServeSimLaunchOptions; iosStartup?: IosSessionStartup }> {
signal?.throwIfAborted();
if ((bootSimulator || installAppPath) && runtimePlatform !== BuildRuntimePlatform.DARWIN) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
'install_app_path is only supported for iOS Simulator sessions.'
);
}
const applicationIdentifier =
launchAppIdentifier ??
(installAppPath
? await readIosApplicationIdentifierAsync({ artifactPath: installAppPath, env })
: undefined);
const launch = parseServeSimLaunchInputs(
{ launchAppIdentifier: applicationIdentifier, launchArgs, openUrl },
{ runtimePlatform }
);
if (
runtimePlatform !== BuildRuntimePlatform.DARWIN ||
(!bootSimulator && !deviceIdentifier && !installAppPath)
) {
return { launch };
}
validateServeSimLaunchOptions(launch);
signal?.throwIfAborted();
let selectedIdentifier = (deviceIdentifier ?? undefined) as
| IosSimulatorUuid
| IosSimulatorName
| undefined;
if (selectedIdentifier === undefined) {
const devices = await IosSimulatorUtils.getAvailableDevicesAsync({ env, filter: 'available' });
selectedIdentifier = minBy(
devices.filter(device => device.name.startsWith('iPhone')),
device => device.name.length
)?.udid;
}
if (!selectedIdentifier) {
throw new Error('Could not find an iPhone among available simulator devices.');
}
const iosSimulatorUdid = (
await IosSimulatorUtils.resolveUdidAsync({ deviceIdentifier: selectedIdentifier, env })
).toUpperCase() as IosSimulatorUuid;
const bootEnv = await resolveLocalEgressServeSimBootEnvironmentAsync();
signal?.throwIfAborted();
if (bootEnv) {
const device = await IosSimulatorUtils.getDeviceAsync({ udid: iosSimulatorUdid, env });
signal?.throwIfAborted();
if (device && device.state !== 'Shutdown') {
logger.info(`Shutting down ${iosSimulatorUdid} to apply the local egress guard at boot.`);
await spawn('xcrun', ['simctl', 'shutdown', iosSimulatorUdid], { env, logger, signal });
}
signal?.throwIfAborted();
await startLocalEgressGuardRelayAsync({ logger });
}
signal?.throwIfAborted();
return {
launch,
iosStartup: { iosSimulatorUdid, installAppPath, bootEnv: bootEnv ?? undefined },
};
}
@@ -191,12 +191,14 @@ export async function startChiselServerAsync({
controlPort,
authfilePath,
env,
logger,
signal,
}: {
chiselPath: string;
controlPort: number;
authfilePath: string;
env: BuildStepEnv;
logger: bunyan;
signal?: AbortSignal;
}): Promise<{ process: DetachedProcessHandle; fingerprint: string }> {
const server = spawnDetached({
@@ -244,7 +246,11 @@ export async function startChiselServerAsync({
}`
);
} catch (error) {
await server.stopAsync();
await server
.stopAsync()
.catch(err =>
logger.warn({ err }, 'Could not stop the reverse tunnel server after it failed to start.')
);
throw error;
}
}
@@ -110,6 +110,18 @@ export async function resolveEgressGuardCheckAsync(
return await resolvePackagedFileAsync(binDir, EGRESS_GUARD_CHECK_FILE);
}
async function requireEgressGuardCheckAsync(checkPath?: string | null): Promise<string> {
const resolvedCheck = checkPath === undefined ? await resolveEgressGuardCheckAsync() : checkPath;
if (!resolvedCheck) {
throw new SystemError(
'The local egress guard self-check is not available on this device host, so this session cannot ' +
'verify that the guard is in effect. The device host image is missing bin/egress-guard-check; ' +
'this is a service problem, please contact support.'
);
}
return resolvedCheck;
}
/**
* Turns guard events into session log lines: one line the first time a
* process reaches a destination through a given call, counts after that, and
@@ -278,6 +290,14 @@ export class GuardLogTailer {
}
}
type LocalEgressBootEnvironmentOptions = {
handoffPath?: string;
/** Explicit library path, `null` for "not available"; resolved from the package when omitted. */
libraryPath?: string | null;
logPath?: string;
mode?: EgressGuardMode;
};
/**
* The environment the simulator's launchd must have from its first process:
* the guard and the proxy variables. Pass it to `IosSimulatorUtils.bootAsync`,
@@ -291,13 +311,7 @@ export async function resolveLocalEgressBootEnvironmentAsync({
libraryPath,
logPath = LOCAL_EGRESS_GUARD_LOG_PATH,
mode = 'block',
}: {
handoffPath?: string;
/** Explicit library path, `null` for "not available"; resolved from the package when omitted. */
libraryPath?: string | null;
logPath?: string;
mode?: EgressGuardMode;
} = {}): Promise<Record<string, string> | null> {
}: LocalEgressBootEnvironmentOptions = {}): Promise<Record<string, string> | null> {
const handoff = await readLocalEgressHandoffAsync(handoffPath);
if (!handoff) {
return null;
@@ -317,6 +331,27 @@ export async function resolveLocalEgressBootEnvironmentAsync({
};
}
/** Supply the guard at boot and require its self-check before starting serve-sim. */
export async function resolveLocalEgressServeSimBootEnvironmentAsync({
checkPath,
...options
}: LocalEgressBootEnvironmentOptions & {
checkPath?: string | null;
} = {}): Promise<Record<string, string> | null> {
const environment = await resolveLocalEgressBootEnvironmentAsync(options);
if (!environment) {
return null;
}
await requireEgressGuardCheckAsync(checkPath);
const { DYLD_INSERT_LIBRARIES, ...childEnvironment } = environment;
return {
SERVE_SIM_ADDITIONAL_DYLIBS: DYLD_INSERT_LIBRARIES,
...Object.fromEntries(
Object.entries(childEnvironment).map(([name, value]) => [`SIMCTL_CHILD_${name}`, value])
),
};
}
type ActiveRelay = {
source: { stopAsync: () => Promise<void> };
relay: GuardEventRelay;
@@ -325,6 +360,44 @@ type ActiveRelay = {
};
const activeRelays = new Map<string, ActiveRelay>();
/** Start reporting guard events without changing the Simulator's launchd environment. */
export async function startLocalEgressGuardRelayAsync({
logger,
logPath = LOCAL_EGRESS_GUARD_LOG_PATH,
tailIntervalMs,
}: {
logger: bunyan;
logPath?: string;
tailIntervalMs?: number;
}): Promise<void> {
try {
await fs.promises.mkdir(path.dirname(logPath), { recursive: true });
await fs.promises.appendFile(logPath, '');
} catch (err) {
logger.warn(
{ err },
`Local egress guard: could not create ${logPath}, so refused connections will not be reported in this log. They are still refused.`
);
return;
}
if (activeRelays.has(logPath)) {
return;
}
const relay = new GuardEventRelay(logger);
const tailer = new GuardLogTailer({
path: logPath,
intervalMs: tailIntervalMs,
onLine: line => {
const event = parseGuardLogLine(line);
if (event) {
relay.handle(event);
}
},
});
tailer.start();
activeRelays.set(logPath, { source: tailer, relay });
}
/**
* Report events from another source, such as the Android emulator's packet
* fence, through the same session log lines, step rebinding and summary as the
@@ -396,17 +469,7 @@ export async function installLocalEgressGuardAsync({
await logAlreadyRunningProcessesAsync({ env, logger });
let logWritable = true;
try {
await fs.promises.mkdir(path.dirname(logPath), { recursive: true });
await fs.promises.appendFile(logPath, '');
} catch (err) {
logWritable = false;
logger.warn(
{ err },
`Local egress guard: could not create ${logPath}, so refused connections will not be reported in this log. They are still refused.`
);
}
await startLocalEgressGuardRelayAsync({ logger, logPath, tailIntervalMs });
try {
await IosSimulatorUtils.setLaunchdEnvironmentAsync({
@@ -423,22 +486,6 @@ export async function installLocalEgressGuardAsync({
);
}
if (logWritable && !activeRelays.has(logPath)) {
const relay = new GuardEventRelay(logger);
const tailer = new GuardLogTailer({
path: logPath,
intervalMs: tailIntervalMs,
onLine: line => {
const event = parseGuardLogLine(line);
if (event) {
relay.handle(event);
}
},
});
tailer.start();
activeRelays.set(logPath, { source: tailer, relay });
}
logger.info(
`Local egress guard installed in the Simulator (mode ${mode}): connections that bypass the system proxy are ${
mode === 'block' ? 'refused' : 'observed'
@@ -658,14 +705,7 @@ export async function verifyLocalEgressGuardAsync({
/** Explicit check binary path, `null` for "not available"; resolved from the package when omitted. */
checkPath?: string | null;
}): Promise<void> {
const resolvedCheck = checkPath === undefined ? await resolveEgressGuardCheckAsync() : checkPath;
if (!resolvedCheck) {
throw new SystemError(
'The local egress guard self-check is not available on this device host, so this session cannot ' +
'verify that the guard is in effect. The device host image is missing bin/egress-guard-check; ' +
'this is a service problem, please contact support.'
);
}
const resolvedCheck = await requireEgressGuardCheckAsync(checkPath);
let output: string;
try {
const result = await spawn('xcrun', ['simctl', 'spawn', udid, resolvedCheck, '--mode', mode], {
@@ -0,0 +1,76 @@
import { type bunyan } from '@expo/logger';
import { type BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import path from 'node:path';
import { killProcessGroup } from '../../utils/processes';
export async function preparePreinstalledWdaAsync({
runtimeIdentifier,
appiumHome,
appiumBinPath,
appiumEnv,
logger,
}: {
runtimeIdentifier: string | undefined;
appiumHome: string;
appiumBinPath: string;
appiumEnv: BuildStepEnv;
logger: bunyan;
}): Promise<Record<string, unknown>> {
const iosMajor = /^com\.apple\.CoreSimulator\.SimRuntime\.iOS-(\d+)(?:-|$)/.exec(
runtimeIdentifier ?? ''
)?.[1];
// WDA v13+ supports preinstalled launch on iOS 17 and newer.
if (!iosMajor || Number(iosMajor) < 17) {
return {};
}
const outdir = path.join(appiumHome, 'preinstalled-wda');
const runner = path.join(outdir, 'WebDriverAgentRunner-Runner.app');
try {
// The driver selects the release matching its installed WDA and the host architecture.
// Keep the Simulator runner intact; removing its XCTest frameworks breaks launch.
const download = spawn(
appiumBinPath,
[
'driver',
'run',
'xcuitest',
'download-wda',
'--',
'--outdir',
outdir,
'--platform',
'iOS',
'--kind',
'sim',
],
{ env: appiumEnv, logger, detached: true }
);
// Appium's downloader inherits its pipes; killing only Appium leaves spawn's
// close event pending. Kill the owned group, then await close before cleanup.
const timeout = setTimeout(() => killProcessGroup(download.child, 'SIGKILL'), 60_000);
try {
await download;
} finally {
clearTimeout(timeout);
}
if (!(await fs.promises.stat(runner)).isDirectory()) {
throw new Error('The downloaded WebDriverAgent runner is not an app directory.');
}
logger.info('Using preinstalled WebDriverAgent to avoid xcodebuild during session creation.');
return {
'appium:usePreinstalledWDA': true,
'appium:prebuiltWDAPath': runner,
};
} catch (err) {
await fs.promises.rm(outdir, { recursive: true, force: true });
logger.warn(
{ err },
'Could not prepare preinstalled WebDriverAgent; using the default xcodebuild startup.'
);
return {};
}
}
Loaded 100 of 238 files, more files were not shown because too many files have changed in this diff. Show more