Compare commits

...
25 Commits
Author SHA1 Message Date
Expo CI 14e9f1af44 v27.9.0 2026-11-12 12:16:18 +00:00
Szymon Dziedzic 291ec6f2c8 [eas-cli] Warn about EAS Simulator outages in simulator:start (#4873)
* [eas-cli] warn about EAS Simulator outages in simulator:start

* [eas-cli] name the simulator status page service Expo Simulators
2026-11-12 13:13:42 +01:00
Expo CI a4edee8178 bump STAGING and PRODUCTION to 27.8.0 in cli-versions.json 2026-11-12 11:11:46 +00:00
Expo CI 6fa3f47f91 v27.8.0 2026-11-12 11:04:19 +00:00
Szymon DziedzicandClaude Opus 5.5 71338cbaa2 [eas-cli] Remove Android in-development warning from eas simulator (#4870)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-12 11:48:16 +01:00
Szymon DziedzicandClaude Opus 5.5 36f83dee90 [eas-cli] Drop the experimental label from simulator commands except local egress (#4869)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-12 11:48:01 +01:00
Szymon Dziedzic 352c3b68f0 [eas-cli] Point gated simulator accounts at paid plans instead of the waitlist (#4866)
* [eas-cli] Point gated simulator accounts at paid plans instead of the waitlist

* [eas-cli] Tell annual Enterprise accounts to contact their account executive

The server does not include annual Enterprise prices at simulator GA. The CLI cannot see the plan, so the gated message covers this case too.
2026-11-12 11:47:36 +01:00
Gabe DebesandCodex 5dafefd484 [eas-cli] Upload local Simulator app bundles (#4684)
* [eas-cli] Upload local Simulator app bundles

* [eas-cli] Reject unsupported Simulator install archives before upload

* [eas-cli] Keep upload folder guidance specific to the action

* [eas-cli] Clean interrupted app archives and serialize packaging

Start progress before packaging, require archive support for local archives, and keep unknown capability metadata from rejecting the action catalog. Add child-process interruption and packaging coverage.

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

---------

Co-authored-by: Codex <[email protected]>
2026-11-11 21:02:10 -07:00
Vojtech NovakandClaude Fable 5.2 b7ff9b10d8 [eas-build-job][build-tools][eas-cli] Share one formatBytes helper and fix sizes of 102.4 GB and larger (#4854)
* [build-tools] Fix formatBytes for sizes of 102.4 GB and larger

The last branch divided by 1024 instead of by the GB multiplier, so
200 GB was formatted as "209715200 GB". Add table tests for the helper.

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

* [eas-build-job][build-tools][eas-cli] Share one formatBytes helper

build-tools and eas-cli each had the same formatBytes copy. Move it to
@expo/eas-build-job, migrate all callers, and delete both copies.
eas-cli now also gets the fix for sizes of 102.4 GB and larger.

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

* [build-tools] Import formatBytes from eas-build-job in androidEmulatorLogs

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

* Merge the two formatBytes changesets

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

---------

Co-authored-by: Claude Fable 5.2 <[email protected]>
2026-11-11 20:41:11 -07:00
SJ Kim 55d1e2942d [build-tools] Point Android device sessions at the scrcpy server copy (#4848) 2026-11-11 05:34:51 -05:00
Ash Wu a7f9742a03 [build-tools] report maestro-runner flow tags from report.json (#4853) 2026-11-11 16:10:16 +08:00
Hirbod f615fc5ba3 [build-tools] Send session preview size metadata (#4864)
* [build-tools] Send session preview size metadata

* [build-tools] Store a new preview size only after its image uploads
2026-11-10 23:45:44 +00:00
Hirbod 00ef775b2b [build-tools] Send screenshot size metadata (#4858)
* [build-tools] Send screenshot size metadata

* [build-tools] Keep only the screenshot metadata fields that are set

* [build-tools] Send screenshot size metadata for Argent screenshots

* [build-tools] Send screenshot size metadata for Argent screenshot diffs
2026-11-10 22:32:39 +00:00
Gabe Debes e914819052 [build-tools] Fix iOS session thumbnails on multi-display Simulators (#4777)
* [build-tools] Capture iOS session thumbnails through serve-sim

Edited-by: Codex

* [build-tools] Mark thumbnail captures as automation

Edited-by: Codex

* [build-tools] Keep the default screenshot Desktop copy

* [build-tools] Clarify session preview errors

* [build-tools] Capture session previews without probing the catalog

Edited-by: Codex

* [build-tools] Check automated screenshot support before previews
2026-11-10 10:52:09 -07:00
Expo CI ab32f0c241 bump STAGING and PRODUCTION to 27.7.2 in cli-versions.json 2026-11-10 17:07:58 +00:00
Expo CI 951ccba62c v27.7.2 2026-11-10 17:00:49 +00:00
Tomasz Mazur 8c0e01d64c [eas-cli] Show the ID argument and examples in workflow:ssh help (#4829)
* [eas-cli] Fix workflow SSH help and error text

* [eas-cli] Revert worker wording in workflow:run SSH text

* [eas-cli] Revert workflow:ssh error text
2026-11-10 17:36:59 +01:00
Vojtech NovakandClaude Fable 5.1 539acb8c23 [eas-cli] Add --log-source to eas simulator (#4823)
* [eas-cli] Add --android-log-source to eas simulator

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

* [eas-cli] Drop the retention period from the --android-log-source help

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

* [eas-cli] Shorten the --android-log-source changeset

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

* [eas-cli] Rename --android-log-source to --log-source

The flag is unprefixed like the step input it feeds. Passing it without
--platform android fails at runtime.

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

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-11-10 17:18:53 +01:00
Expo CI 18d85d571a bump STAGING and PRODUCTION to 27.7.1 in cli-versions.json 2026-11-10 13:36:49 +00:00
Tomasz Mazur f2cf409b21 [build-tools] Start SSH session shells in the build directory (#4843) 2026-11-10 13:40:14 +01:00
Tomasz Mazur 0cf8cd014f [eas-cli] Show the ended message when workflow:ssh is given a finished build ID (#4842) 2026-11-10 13:38:46 +01:00
Expo CI 8df11a04ee v27.7.1 2026-11-10 11:28:19 +00:00
Tomek Czajecki c9d198a894 [eas-cli] update dependencies with security advisories (#4847)
* [eas-cli] update dependencies with security advisories

* [eas-cli] remove unrelated lockfile dedupe
2026-11-10 13:03:43 +02:00
Szymon Świerk 4b70eb14fb [build-tools] Strip the repository access token from the git remote before the sandbox agent starts (#4826)
* [build-tools] Strip the repository access token from the git remote before the sandbox agent starts

* [build-tools] improve the comment explaining why the repository token is stripped before the sandbox starts
2026-11-10 11:25:41 +01:00
Expo CI c224ba58ff bump STAGING and PRODUCTION to 27.7.0 in cli-versions.json 2026-11-10 08:32:43 +00:00
120 changed files with 3634 additions and 798 deletions

No files matched your search

+46
View File
@@ -2,6 +2,52 @@
This is the log of notable changes to EAS CLI and related packages.
## [27.9.0](https://github.com/expo/eas-cli/releases/tag/v27.9.0) - 2026-11-12
### 🎉 New features
- [eas-cli] Warn about Expo Simulators and EAS Workflows outages from status.expo.dev before `eas simulator:start` creates a session, and describe degraded performance and maintenance correctly in all outage warnings. ([#4873](https://github.com/expo/eas-cli/pull/4873) by [@szdziedzic](https://github.com/szdziedzic))
## [27.8.0](https://github.com/expo/eas-cli/releases/tag/v27.8.0) - 2026-11-12
### 🎉 New features
- [eas-cli] Tell accounts without EAS Simulator access that it is available on the Starter, Production, and Enterprise plans and link to the billing page, instead of pointing at the waitlist. `simulator:availability --json` now returns `billingUrl` instead of `waitlistUrl`. ([#4866](https://github.com/expo/eas-cli/pull/4866) by [@szdziedzic](https://github.com/szdziedzic))
### 🐛 Bug fixes
- [build-tools] Point expo-device-hub at the scrcpy server copy in the `turtle-v2` bucket in Android device sessions, so a failed GitHub release download no longer fails the session. expo-device-hub falls back to GitHub when the copy is missing. ([#4848](https://github.com/expo/eas-cli/pull/4848) by [@sjkim-expo](https://github.com/sjkim-expo))
- [build-tools] Fix black iOS session preview artifacts on multi-display Simulators by capturing the active display through serve-sim. ([#4777](https://github.com/expo/eas-cli/pull/4777) by [@gwdp](https://github.com/gwdp))
Skip automatic previews on older serve-sim versions that cannot exclude them from idle activity.
- [build-tools] Report flow tags with the Maestro test results of jobs that run `maestro-runner`. The tags are read from the runner's `report.json`. ([#4853](https://github.com/expo/eas-cli/pull/4853) by [@hSATAC](https://github.com/hSATAC))
- [build-tools] Send the session preview size to `preview-upload-session` and refresh it when the size changes. ([#4864](https://github.com/expo/eas-cli/pull/4864) by [@hirbod](https://github.com/hirbod))
- [eas-cli] Remove the "Android emulator support is still in development" warning from `eas simulator`. ([#4870](https://github.com/expo/eas-cli/pull/4870) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Send the width, height and source of simulator session screenshots (manual, agent-device and Argent, including Argent screenshot diffs) as artifact metadata, read from the image header. ([#4858](https://github.com/expo/eas-cli/pull/4858) by [@hirbod](https://github.com/hirbod))
- [eas-cli][build-tools][eas-build-job] Fix sizes of 102.4 GB and larger being printed with a value about a million times too large, and share one `formatBytes` helper between `eas-cli` and `build-tools`. ([#4854](https://github.com/expo/eas-cli/pull/4854) by [@vonovak](https://github.com/vonovak))
- [eas-cli] Package local Simulator app bundles for upload when the host supports gzip tar archives, and reject unsupported IPA and ZIP installs before uploading. ([#4684](https://github.com/expo/eas-cli/pull/4684) by [@gwdp](https://github.com/gwdp))
Fix packaging stalls for app bundles containing hard links and clean temporary archives when interrupted. Local app directories and `.tar.gz`/`.tgz` files require a host advertising archive support.
- [eas-cli] Remove the `[EXPERIMENTAL]` label from `eas simulator` commands, except `eas simulator:egress` and the `--egress` flag. ([#4869](https://github.com/expo/eas-cli/pull/4869) by [@szdziedzic](https://github.com/szdziedzic))
## [27.7.2](https://github.com/expo/eas-cli/releases/tag/v27.7.2) - 2026-11-10
### 🐛 Bug fixes
- [eas-cli] Add `--log-source` to `eas simulator` to select which Android logs a session records. ([#4823](https://github.com/expo/eas-cli/pull/4823) by [@vonovak](https://github.com/vonovak))
- [build-tools] Start SSH session shells in the job's build directory instead of the worker's directory. ([#4843](https://github.com/expo/eas-cli/pull/4843) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- [eas-cli] Show that the ssh session has ended, instead of saying SSH was not enabled, when `eas workflow:ssh` is given the ID of a finished build. ([#4842](https://github.com/expo/eas-cli/pull/4842) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- [eas-cli] Show the ID argument and examples in `eas workflow:ssh --help`. ([#4829](https://github.com/expo/eas-cli/pull/4829) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
## [27.7.1](https://github.com/expo/eas-cli/releases/tag/v27.7.1) - 2026-11-10
### 🐛 Bug fixes
- [eas-cli][eas-json][build-tools][eas-build-job][steps][local-build-plugin][worker][simulator-launcher] Update `node-forge`, `tar`, `nanoid`, `minimatch`, `joi`, `ajv`, `yaml`, and `zod` to versions with fixes for published security advisories. ([#4847](https://github.com/expo/eas-cli/pull/4847) by [@tchayen](https://github.com/tchayen))
- [build-tools] Strip the GitHub repository access token from the git remote before a sandbox agent starts. ([#4826](https://github.com/expo/eas-cli/pull/4826) by [@sswrk](https://github.com/sswrk))
## [27.7.0](https://github.com/expo/eas-cli/releases/tag/v27.7.0) - 2026-11-10
### 🎉 New features
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "27.6.0",
"PRODUCTION": "27.6.0"
"STAGING": "27.8.0",
"PRODUCTION": "27.8.0"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "27.7.0",
"version": "27.9.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+31
View File
@@ -1,5 +1,36 @@
# @expo/build-tools
## 27.9.0
No changes in this release.
## 27.8.0
### Patch Changes
- Point expo-device-hub at the scrcpy server copy in the `turtle-v2` bucket in Android device sessions, so a failed GitHub release download no longer fails the session. expo-device-hub falls back to GitHub when the copy is missing. ([#4848](https://github.com/expo/eas-cli/pull/4848) by [@sjkim-expo](https://github.com/sjkim-expo))
- Fix black iOS session preview artifacts on multi-display Simulators by capturing the active display through serve-sim. ([#4777](https://github.com/expo/eas-cli/pull/4777) by [@gwdp](https://github.com/gwdp))
Skip automatic previews on older serve-sim versions that cannot exclude them from idle activity.
- Report flow tags with the Maestro test results of jobs that run `maestro-runner`. The tags are read from the runner's `report.json`. ([#4853](https://github.com/expo/eas-cli/pull/4853) by [@hSATAC](https://github.com/hSATAC))
- Send the session preview size to `preview-upload-session` and refresh it when the size changes. ([#4864](https://github.com/expo/eas-cli/pull/4864) by [@hirbod](https://github.com/hirbod))
- Send the width, height and source of simulator session screenshots (manual, agent-device and Argent, including Argent screenshot diffs) as artifact metadata, read from the image header. ([#4858](https://github.com/expo/eas-cli/pull/4858) by [@hirbod](https://github.com/hirbod))
- Fix sizes of 102.4 GB and larger being printed with a value about a million times too large, and share one `formatBytes` helper between `eas-cli` and `build-tools`. ([#4854](https://github.com/expo/eas-cli/pull/4854) by [@vonovak](https://github.com/vonovak))
## 27.7.2
### Patch Changes
- Start SSH session shells in the job's build directory instead of the worker's directory. ([#4843](https://github.com/expo/eas-cli/pull/4843) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
## 27.7.1
### Patch Changes
- Update `node-forge`, `tar`, `nanoid`, `minimatch`, `joi`, `ajv`, `yaml`, and `zod` to versions with fixes for published security advisories. ([#4847](https://github.com/expo/eas-cli/pull/4847) by [@tchayen](https://github.com/tchayen))
- Strip the GitHub repository access token from the git remote before a sandbox agent starts. ([#4826](https://github.com/expo/eas-cli/pull/4826) by [@sswrk](https://github.com/sswrk))
## 27.7.0
### Patch Changes
+12 -12
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "27.7.0",
"version": "27.9.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -42,17 +42,17 @@
"dependencies": {
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "27.7.0",
"@expo/eas-build-job": "27.7.0",
"@expo/downloader": "27.9.0",
"@expo/eas-build-job": "27.9.0",
"@expo/env": "^0.4.0",
"@expo/logger": "27.7.0",
"@expo/logger": "27.9.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.3.5",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "27.7.0",
"@expo/template-file": "27.7.0",
"@expo/turtle-spawn": "27.7.0",
"@expo/steps": "27.9.0",
"@expo/template-file": "27.9.0",
"@expo/turtle-spawn": "27.9.0",
"@expo/xcpretty": "^4.4.7",
"@ngrok/ngrok": "1.7.0",
"@sentry/node": "7.77.0",
@@ -63,11 +63,11 @@
"fast-xml-parser": "^4.4.1",
"fs-extra": "^11.2.0",
"gql.tada": "^1.11.3",
"joi": "^17.13.1",
"joi": "^17.13.9",
"jose": "5.10.0",
"lodash": "^4.18.1",
"node-fetch": "^2.7.0",
"node-forge": "^1.3.1",
"node-forge": "^1.4.3",
"node-pty": "1.2.0-beta.15",
"node-stream-zip": "1.15.0",
"nullthrows": "^1.1.1",
@@ -77,11 +77,11 @@
"resolve-from": "^5.0.0",
"retry": "^0.13.1",
"semver": "^7.6.2",
"tar": "7.5.19",
"tar": "7.5.22",
"uuid": "9.0.1",
"ws": "8.21.1",
"yaml": "^2.8.1",
"zod": "^4.3.5"
"yaml": "^2.9.1",
"zod": "^4.6.7"
},
"devDependencies": {
"@expo/repack-app": "~0.9.0",
@@ -1,8 +1,8 @@
import { UserError } from '@expo/eas-build-job';
import { SystemError, UserError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import { fetchAndCheckoutRefAsync } from '../git';
import { fetchAndCheckoutRefAsync, stripRepositoryCredentialsAsync } from '../git';
jest.mock('@expo/turtle-spawn');
@@ -147,3 +147,64 @@ describe(fetchAndCheckoutRefAsync, () => {
expect(error.message).not.toContain('ghs_secret123');
});
});
describe(stripRepositoryCredentialsAsync, () => {
const token = 'ghs_secret123';
function mockRemoteUrl(url: string): void {
jest.mocked(spawn).mockResolvedValueOnce({ stdout: `${url}\n` } as never);
}
it('strips the token from the origin remote', async () => {
mockRemoteUrl(`https://x-access-token:${token}@github.com/expo/app.git`);
await stripRepositoryCredentialsAsync({ repositoryDirectory });
expect(spawn).toHaveBeenNthCalledWith(1, 'git', ['remote', 'get-url', 'origin'], {
cwd: repositoryDirectory,
});
expect(spawn).toHaveBeenNthCalledWith(
2,
'git',
['remote', 'set-url', 'origin', 'https://github.com/expo/app.git'],
{ cwd: repositoryDirectory }
);
expect(spawn).toHaveBeenCalledTimes(2);
});
it('keeps the host and port of a GitHub Enterprise remote', async () => {
mockRemoteUrl(`https://x-access-token:${token}@ghe.example.com:8443/org/app.git`);
await stripRepositoryCredentialsAsync({ repositoryDirectory });
expect(spawn).toHaveBeenNthCalledWith(
2,
'git',
['remote', 'set-url', 'origin', 'https://ghe.example.com:8443/org/app.git'],
{ cwd: repositoryDirectory }
);
});
it('does nothing when the origin remote carries no credentials', async () => {
mockRemoteUrl('https://github.com/expo/app.git');
await stripRepositoryCredentialsAsync({ repositoryDirectory });
expect(spawn).toHaveBeenCalledTimes(1);
});
it('throws when the remote URL cannot be rewritten', async () => {
mockRemoteUrl(`https://x-access-token:${token}@github.com/expo/app.git`);
const gitError = Object.assign(new Error('git exited with non-zero code: 128'), {
stderr: "fatal: No such remote 'origin'",
});
jest.mocked(spawn).mockRejectedValueOnce(gitError);
const promise = stripRepositoryCredentialsAsync({ repositoryDirectory });
await expect(promise).rejects.toBeInstanceOf(SystemError);
await expect(promise).rejects.toMatchObject({ cause: gitError });
const error = await promise.catch(err => err);
expect(error.message).not.toContain(token);
});
});
+40 -1
View File
@@ -1,4 +1,4 @@
import { ArchiveSource, ArchiveSourceType, UserError } from '@expo/eas-build-job';
import { ArchiveSource, ArchiveSourceType, SystemError, UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import spawn from '@expo/turtle-spawn';
import fs from 'fs-extra';
@@ -99,6 +99,45 @@ export async function fetchAndCheckoutRefAsync({
}
}
export async function stripRepositoryCredentialsAsync({
repositoryDirectory,
}: {
repositoryDirectory: string;
}): Promise<void> {
let remoteUrl: URL;
try {
const { stdout } = await spawn('git', ['remote', 'get-url', 'origin'], {
cwd: repositoryDirectory,
});
remoteUrl = new URL(stdout.trim());
} catch (err) {
throw new SystemError('Failed to read the origin remote URL of the project repository.', {
cause: err,
});
}
if (!remoteUrl.username && !remoteUrl.password) {
return;
}
try {
await spawn('git', ['remote', 'set-url', 'origin', stripCredentialsFromGitUrl(remoteUrl)], {
cwd: repositoryDirectory,
});
} catch (err) {
throw new SystemError('Failed to strip credentials from the origin remote URL.', {
cause: err,
});
}
}
function stripCredentialsFromGitUrl(url: URL): string {
const strippedUrl = new URL(url.toString());
strippedUrl.username = '';
strippedUrl.password = '';
return strippedUrl.toString();
}
function getSanitizedGitUrl(maybeGitUrl: string): string | null {
try {
const url = new URL(maybeGitUrl);
@@ -15,6 +15,7 @@ import {
parseMaestroResults,
parseMaestroResultsFromFileAttrs,
parseMaestroRunnerReport,
parseMaestroRunnerResults,
} from '../maestroResultParser';
describe(parseFailedFlowsFromMaestroRunnerReport, () => {
@@ -483,6 +484,271 @@ describe(parseMaestroResultsFromFileAttrs, () => {
});
});
describe(parseMaestroRunnerResults, () => {
function runnerJUnit(testcases: { name: string; file: string; failed?: boolean }[]): string {
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<testsuites>',
' <testsuite name="maestro-runner">',
...testcases.flatMap(({ name, file, failed }) => [
` <testcase id="${name}" name="${name}" classname="${name}" time="1.000" status="${failed ? 'ERROR' : 'SUCCESS'}">`,
' <properties>',
` <property name="file" value="${file}"/>`,
' <property name="device.platform" value="android"/>',
' </properties>',
...(failed ? [' <failure message="Element not found">tapOn</failure>'] : []),
' </testcase>',
]),
' </testsuite>',
'</testsuites>',
].join('\n');
}
it('adds the tags of each flow from the report.json of its attempt', async () => {
vol.fromJSON({
'/junit/android-maestro-junit-attempt-0.xml': runnerJUnit([
{ name: 'sign-in', file: 'flows/auth/sign-in.yaml', failed: true },
{ name: 'settings', file: 'flows/settings.yaml' },
]),
'/tests/android-maestro-runner-attempt-0/report.json': JSON.stringify({
flows: [
{
name: 'sign-in',
sourceFile: 'flows/auth/sign-in.yaml',
status: 'failed',
tags: ['auth', 'smoke'],
},
{ name: 'settings', sourceFile: 'flows/settings.yaml', status: 'passed' },
],
}),
'/junit/android-maestro-junit-attempt-1.xml': runnerJUnit([
{ name: 'sign-in', file: 'flows/auth/sign-in.yaml' },
]),
'/tests/android-maestro-runner-attempt-1/report.json': JSON.stringify({
flows: [
{
name: 'sign-in',
sourceFile: 'flows/auth/sign-in.yaml',
status: 'passed',
tags: ['auth'],
},
],
}),
});
const results = await parseMaestroRunnerResults({
junitDirectory: '/junit',
testsDirectory: '/tests',
workingDirectory: '/project',
});
expect(results).toEqual([
{
name: 'sign-in',
path: 'flows/auth/sign-in.yaml',
status: 'failed',
errorMessage: 'Element not found',
duration: 1000,
retryCount: 0,
tags: ['auth', 'smoke'],
properties: { file: 'flows/auth/sign-in.yaml', 'device.platform': 'android' },
},
expect.objectContaining({ path: 'flows/auth/sign-in.yaml', retryCount: 1, tags: ['auth'] }),
expect.objectContaining({ path: 'flows/settings.yaml', retryCount: 0, tags: [] }),
]);
});
it('matches an absolute sourceFile with a JUnit path relative to the working directory', async () => {
vol.fromJSON({
'/junit/ios-maestro-junit-attempt-0.xml': runnerJUnit([
{ name: 'sign-in', file: 'flows/sign-in.yaml' },
]),
'/tests/ios-maestro-runner-attempt-0/report.json': JSON.stringify({
flows: [
{
name: 'sign-in',
sourceFile: '/project/flows/sign-in.yaml',
status: 'passed',
tags: ['auth'],
},
],
}),
});
const results = await parseMaestroRunnerResults({
junitDirectory: '/junit',
testsDirectory: '/tests',
workingDirectory: '/project',
});
expect(results).toEqual([
expect.objectContaining({ path: 'flows/sign-in.yaml', tags: ['auth'] }),
]);
});
it('matches a sourceFile under a symlinked directory with the JUnit path of its target', async () => {
vol.fromJSON({
'/project/flows/sign-in.yaml': '',
'/junit/ios-maestro-junit-attempt-0.xml': runnerJUnit([
{ name: 'sign-in', file: 'flows/sign-in.yaml' },
]),
'/tests/ios-maestro-runner-attempt-0/report.json': JSON.stringify({
flows: [
{ name: 'sign-in', sourceFile: 'linked/sign-in.yaml', status: 'passed', tags: ['auth'] },
],
}),
});
vol.symlinkSync('/project/flows', '/project/linked');
const results = await parseMaestroRunnerResults({
junitDirectory: '/junit',
testsDirectory: '/tests',
workingDirectory: '/project',
});
expect(results).toEqual([
expect.objectContaining({ path: 'flows/sign-in.yaml', tags: ['auth'] }),
]);
});
it('matches a sourceFile whose symlink leaves the working directory by the path as given', async () => {
vol.fromJSON({
'/project/.keep': '',
'/shared/sign-in.yaml': '',
'/junit/ios-maestro-junit-attempt-0.xml': runnerJUnit([
{ name: 'sign-in', file: 'linked/sign-in.yaml' },
]),
'/tests/ios-maestro-runner-attempt-0/report.json': JSON.stringify({
flows: [
{ name: 'sign-in', sourceFile: 'linked/sign-in.yaml', status: 'passed', tags: ['auth'] },
],
}),
});
vol.symlinkSync('/shared', '/project/linked');
const results = await parseMaestroRunnerResults({
junitDirectory: '/junit',
testsDirectory: '/tests',
workingDirectory: '/project',
});
expect(results).toEqual([
expect.objectContaining({ path: 'linked/sign-in.yaml', tags: ['auth'] }),
]);
});
it('does not follow a symlink from the bare file name that maestro-runner <= 1.1.17 writes', async () => {
vol.fromJSON({
'/project/flows/sign-in.yaml': '',
'/project/settings.yaml': '',
'/junit/ios-maestro-junit-attempt-0.xml': runnerJUnit([
{ name: 'sign-in', file: 'sign-in.yaml' },
{ name: 'settings', file: 'settings.yaml' },
]),
'/tests/ios-maestro-runner-attempt-0/report.json': JSON.stringify({
flows: [
{ name: 'sign-in', sourceFile: 'flows/sign-in.yaml', status: 'passed', tags: ['auth'] },
{ name: 'settings', sourceFile: 'settings.yaml', status: 'passed', tags: ['settings'] },
],
}),
});
vol.symlinkSync('/project/settings.yaml', '/project/sign-in.yaml');
const results = await parseMaestroRunnerResults({
junitDirectory: '/junit',
testsDirectory: '/tests',
workingDirectory: '/project',
});
expect(results).toEqual([
expect.objectContaining({ path: 'sign-in.yaml', tags: ['auth'] }),
expect.objectContaining({ path: 'settings.yaml', tags: ['settings'] }),
]);
});
it('matches by file name when JUnit has only the file name and it is unique', async () => {
vol.fromJSON({
'/junit/ios-maestro-junit-attempt-0.xml': runnerJUnit([
{ name: 'sign-in', file: 'sign-in.yaml' },
]),
'/tests/ios-maestro-runner-attempt-0/report.json': JSON.stringify({
flows: [
{
name: 'sign-in',
sourceFile: 'flows/auth/sign-in.yaml',
status: 'passed',
tags: ['auth'],
},
{
name: 'settings',
sourceFile: 'flows/settings.yaml',
status: 'passed',
tags: ['settings'],
},
],
}),
});
const results = await parseMaestroRunnerResults({
junitDirectory: '/junit',
testsDirectory: '/tests',
workingDirectory: '/project',
});
expect(results).toEqual([expect.objectContaining({ path: 'sign-in.yaml', tags: ['auth'] })]);
});
it('adds no tags when two flows of the attempt have the same file name', async () => {
vol.fromJSON({
'/junit/ios-maestro-junit-attempt-0.xml': runnerJUnit([
{ name: 'sign-in', file: 'sign-in.yaml' },
]),
'/tests/ios-maestro-runner-attempt-0/report.json': JSON.stringify({
flows: [
{ name: 'a', sourceFile: 'flows/a/sign-in.yaml', status: 'passed', tags: ['a'] },
{ name: 'b', sourceFile: 'flows/b/sign-in.yaml', status: 'passed', tags: ['b'] },
],
}),
});
const results = await parseMaestroRunnerResults({
junitDirectory: '/junit',
testsDirectory: '/tests',
workingDirectory: '/project',
});
expect(results).toEqual([expect.objectContaining({ path: 'sign-in.yaml', tags: [] })]);
});
it.each([
{ name: 'is not valid JSON', content: '{' },
{ name: 'has an unknown shape', content: JSON.stringify({ flows: [{ name: 'sign-in' }] }) },
{
name: 'does not have the flow',
content: JSON.stringify({
flows: [{ name: 'other', sourceFile: 'flows/other.yaml', status: 'passed', tags: ['x'] }],
}),
},
])('keeps the JUnit result without tags when report.json $name', async ({ content }) => {
vol.fromJSON({
'/junit/android-maestro-junit-attempt-0.xml': runnerJUnit([
{ name: 'sign-in', file: 'flows/sign-in.yaml' },
]),
'/tests/android-maestro-runner-attempt-0/report.json': content,
});
const results = await parseMaestroRunnerResults({
junitDirectory: '/junit',
testsDirectory: '/tests',
workingDirectory: '/project',
});
expect(results).toEqual([
expect.objectContaining({ name: 'sign-in', path: 'flows/sign-in.yaml', tags: [] }),
]);
});
});
describe('junitFileHasFileAttrs', () => {
it('returns true when every testcase has a non-empty file= attribute', async () => {
vol.fromJSON({
@@ -358,6 +358,51 @@ describe(createReportMaestroTestResultsFunction, () => {
);
});
it('reports maestro-runner results with the tags from report.json', async () => {
vol.fromJSON({
'/junit/android-maestro-junit-attempt-0.xml': [
'<testsuites><testsuite name="maestro-runner">',
' <testcase id="sign-in" name="sign-in" classname="sign-in" time="2.000" status="SUCCESS">',
' <properties><property name="file" value="flows/sign-in.yaml"/></properties>',
' </testcase>',
'</testsuite></testsuites>',
].join('\n'),
'/tests/android-maestro-runner-attempt-0/report.json': JSON.stringify({
flows: [
{
name: 'sign-in',
sourceFile: 'flows/sign-in.yaml',
status: 'passed',
tags: ['auth', 'smoke'],
},
],
}),
});
mockMutationFn.mockResolvedValue({
data: {
workflowDeviceTestCaseResult: {
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }],
},
},
});
await createStep().executeAsync();
const variables = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0][1];
expect(variables.input.testCaseResults).toEqual([
{
name: 'sign-in',
path: 'flows/sign-in.yaml',
status: 'PASSED',
errorMessage: null,
duration: 2000,
retryCount: 0,
tags: ['auth', 'smoke'],
properties: { file: 'flows/sign-in.yaml' },
},
]);
});
it('uses default directories when inputs are not provided', async () => {
vol.fromJSON({
'/home/expo/.maestro/tests/report.xml': JUNIT_PASS,
@@ -1,14 +1,30 @@
import { EnvironmentSecretType, SystemError, UserError } from '@expo/eas-build-job';
import {
ArchiveSourceType,
EnvironmentSecretType,
SystemError,
UserError,
} from '@expo/eas-build-job';
import fetch from 'node-fetch';
import { stripRepositoryCredentialsAsync } from '../../../common/git';
import * as sandboxDaemon from '../../utils/sandboxDaemon';
import { SandboxWorkerApi } from '../../utils/sandboxWorkerApi';
import { createStartSandboxBuildFunction } from '../startSandbox';
const { AbortError, Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
jest.mock('../../../common/git');
const checkedOutStepCtx = { logger: {}, global: { wasCheckedOut: () => true } } as any;
const notCheckedOutStepCtx = { logger: {}, global: { wasCheckedOut: () => false } } as any;
const URL_JOB = {
projectArchive: { type: ArchiveSourceType.URL, url: 'https://example.com/project.tar.gz' },
};
const context = {
job: {
...URL_JOB,
secrets: {
environmentSecrets: [
{
@@ -183,6 +199,97 @@ describe('sandbox build functions', () => {
expect(stopAsync).toHaveBeenCalledTimes(1);
});
it('strips the repository credentials before starting the daemon for git sources', async () => {
const startupError = new Error('stop after checking options');
const start = jest
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
.mockRejectedValue(startupError);
const fn = createStartSandboxBuildFunction({
...context,
job: {
...context.job,
projectArchive: {
type: ArchiveSourceType.GIT,
gitRef: 'refs/heads/main',
gitCommitHash: '0123456789abcdef0123456789abcdef01234567',
},
},
projectTargetDirectory: '/workingdir/build',
} as any);
await expect(fn.fn!(checkedOutStepCtx, { inputs, outputs: {}, env: {} })).rejects.toBe(
startupError
);
expect(stripRepositoryCredentialsAsync).toHaveBeenCalledWith({
repositoryDirectory: '/workingdir/build',
});
expect(jest.mocked(stripRepositoryCredentialsAsync).mock.invocationCallOrder[0]).toBeLessThan(
start.mock.invocationCallOrder[0]
);
});
it('strips the repository credentials from the source directory before eas/checkout', async () => {
const startupError = new Error('stop after checking options');
jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync').mockRejectedValue(startupError);
const fn = createStartSandboxBuildFunction({
...context,
job: {
...context.job,
projectArchive: {
type: ArchiveSourceType.GIT,
gitRef: null,
gitCommitHash: '0123456789abcdef0123456789abcdef01234567',
},
},
projectSourceDirectory: '/workingdir/temporary-custom-build',
projectTargetDirectory: '/workingdir/build',
} as any);
await expect(fn.fn!(notCheckedOutStepCtx, { inputs, outputs: {}, env: {} })).rejects.toBe(
startupError
);
expect(stripRepositoryCredentialsAsync).toHaveBeenCalledWith({
repositoryDirectory: '/workingdir/temporary-custom-build',
});
});
it('does not start the daemon when stripping the repository credentials fails', async () => {
const stripError = new SystemError('Failed to strip credentials from the origin remote URL.');
jest.mocked(stripRepositoryCredentialsAsync).mockRejectedValueOnce(stripError);
const start = jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync');
const fn = createStartSandboxBuildFunction({
...context,
job: {
...context.job,
projectArchive: {
type: ArchiveSourceType.GIT,
gitRef: null,
gitCommitHash: '0123456789abcdef0123456789abcdef01234567',
},
},
projectTargetDirectory: '/workingdir/build',
} as any);
await expect(fn.fn!(checkedOutStepCtx, { inputs, outputs: {}, env: {} })).rejects.toBe(
stripError
);
expect(start).not.toHaveBeenCalled();
});
it('leaves the remote alone for non-git sources', async () => {
const startupError = new Error('stop after checking options');
jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync').mockRejectedValue(startupError);
const fn = createStartSandboxBuildFunction({
...context,
projectTargetDirectory: '/workingdir/build',
} as any);
await expect(fn.fn!({ logger: {} } as any, { inputs, outputs: {}, env: {} })).rejects.toBe(
startupError
);
expect(stripRepositoryCredentialsAsync).not.toHaveBeenCalled();
});
it.each([
{
missing: 'the sandbox worker token',
@@ -1,4 +1,4 @@
import { SystemError } from '@expo/eas-build-job';
import { SystemError, formatBytes } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import {
@@ -15,7 +15,6 @@ import stream from 'stream';
import { promisify } from 'util';
import { z } from 'zod';
import { formatBytes } from '../../utils/artifacts';
import { decompressTarAsync, isFileTarGzAsync } from '../../utils/files';
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
@@ -1,4 +1,4 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import { SystemError, UserError, formatBytes } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import {
@@ -23,7 +23,6 @@ import bplistParser from 'bplist-parser';
import plist from 'plist';
import { CustomBuildContext } from '../../customBuildContext';
import { formatBytes } from '../../utils/artifacts';
import { decompressTarAsync, isFileTarGzAsync } from '../../utils/files';
import { graphqlAbortContext } from '../../utils/graphqlAbort';
import {
@@ -211,7 +211,9 @@ const MaestroRunnerRecordedFlowSchema = z.object({
name: z.string().min(1),
sourceFile: z.string().min(1),
status: z.enum(['passed', 'failed']),
tags: z.array(z.string()).optional(),
});
type MaestroRunnerRecordedFlow = z.infer<typeof MaestroRunnerRecordedFlowSchema>;
const MaestroRunnerReportSchema = z.object({
flows: z
.array(
@@ -379,6 +381,111 @@ export async function parseFailedFlowsFromFileAttrs(args: {
return paths;
}
const MAESTRO_RUNNER_ATTEMPT_DIRECTORY_PATTERN = /-maestro-runner-attempt-(\d+)$/;
export function maestroRunnerAttemptDirectoryName(args: {
platform: string;
attempt: number;
}): string {
return `${args.platform}-maestro-runner-attempt-${args.attempt}`;
}
async function listMaestroRunnerAttemptDirectories(
testsDirectory: string
): Promise<{ attempt: number; reportDirectory: string }[]> {
let entries: string[];
try {
entries = await fs.readdir(testsDirectory);
} catch {
return [];
}
return entries.flatMap(entry => {
const match = entry.match(MAESTRO_RUNNER_ATTEMPT_DIRECTORY_PATTERN);
return match
? [{ attempt: parseInt(match[1], 10), reportDirectory: path.join(testsDirectory, entry) }]
: [];
});
}
export async function hasMaestroRunnerAttemptDirectories(testsDirectory: string): Promise<boolean> {
return (await listMaestroRunnerAttemptDirectories(testsDirectory)).length > 0;
}
export async function parseMaestroRunnerResults(args: {
junitDirectory: string;
testsDirectory: string;
workingDirectory: string;
}): Promise<MaestroFlowResult[]> {
const results = await parseMaestroResultsFromFileAttrs(args.junitDirectory);
const attempts = await listMaestroRunnerAttemptDirectories(args.testsDirectory);
const flows = await Promise.all(
attempts.map(({ reportDirectory }) =>
parseMaestroRunnerFlowsWithPaths({
reportDirectory,
workingDirectory: args.workingDirectory,
})
)
);
const flowsByAttempt = new Map(attempts.map(({ attempt }, index) => [attempt, flows[index]]));
const realWorkingDirectory = await resolveRealPath(args.workingDirectory);
return results.map(result => {
const tags = findMaestroRunnerFlowTags({
flows: flowsByAttempt.get(result.retryCount) ?? [],
junitFlowPath: result.path,
workingDirectory: args.workingDirectory,
realWorkingDirectory,
});
return tags ? { ...result, tags } : result;
});
}
type MaestroRunnerFlowWithPaths = MaestroRunnerRecordedFlow & {
resolvedPath: string;
realPath: string;
};
// maestro-runner >= 1.1.18 resolves symlinks in the JUnit path, but not in report.json.
async function parseMaestroRunnerFlowsWithPaths(args: {
reportDirectory: string;
workingDirectory: string;
}): Promise<MaestroRunnerFlowWithPaths[]> {
const report = await parseMaestroRunnerReport(args.reportDirectory);
return await Promise.all(
(report?.flows ?? []).map(async flow => {
const resolvedPath = path.resolve(args.workingDirectory, flow.sourceFile);
return { ...flow, resolvedPath, realPath: await resolveRealPath(resolvedPath) };
})
);
}
async function resolveRealPath(filePath: string): Promise<string> {
const realPathResult = await asyncResult(fs.realpath(filePath));
return realPathResult.ok ? realPathResult.value : filePath;
}
function findMaestroRunnerFlowTags(args: {
flows: MaestroRunnerFlowWithPaths[];
junitFlowPath: string;
workingDirectory: string;
realWorkingDirectory: string;
}): string[] | undefined {
const junitPath = path.resolve(args.workingDirectory, args.junitFlowPath);
const junitRealPath = path.resolve(args.realWorkingDirectory, args.junitFlowPath);
const samePath = args.flows.find(
flow => flow.resolvedPath === junitPath || flow.realPath === junitRealPath
);
if (samePath) {
return samePath.tags;
}
// maestro-runner <= 1.1.17 writes only the file name to JUnit.
const sameFileName = args.flows.filter(
flow => path.basename(flow.sourceFile) === args.junitFlowPath
);
return sameFileName.length === 1 ? sameFileName[0].tags : undefined;
}
export async function parseMaestroResults(
junitDirectory: string,
nameToPath: Map<string, string> | null
@@ -18,6 +18,7 @@ import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
import {
copyLatestAttemptXml,
junitFileHasFileAttrs,
maestroRunnerAttemptDirectoryName,
mergeJUnitReports,
parseFailedFlowNamesFromJUnitFile,
parseFailedFlowsFromFileAttrs,
@@ -353,7 +354,7 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
// maestro-runner writes all report formats and screenshot metadata here.
const runnerOutputDirectory = path.join(
testsDirectory,
`${platform}-maestro-runner-attempt-${attempt}`
maestroRunnerAttemptDirectoryName({ platform, attempt })
);
const outputPath = mayHaveJUnitReports
? path.join(junitReportDirectory, `${platform}-maestro-junit-attempt-${attempt}.xml`)
@@ -5,10 +5,12 @@ import { z } from 'zod';
import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
import {
MaestroFlowResult,
hasMaestroRunnerAttemptDirectories,
isFileAttrRun,
parseJUnitTestCases,
parseMaestroResults,
parseMaestroResultsFromFileAttrs,
parseMaestroRunnerResults,
} from './maestroResultParser';
import { CustomBuildContext } from '../../customBuildContext';
@@ -73,8 +75,15 @@ export function createReportMaestroTestResultsFunction(ctx: CustomBuildContext):
// map testcase names back to paths.
const usedFileAttrs = isFileAttrRun(await parseJUnitTestCases(junitDirectory));
const testsDirectory = (inputs.tests_directory.value as string | undefined) ?? '';
let flowResults: MaestroFlowResult[];
if (usedFileAttrs) {
if (usedFileAttrs && (await hasMaestroRunnerAttemptDirectories(testsDirectory))) {
flowResults = await parseMaestroRunnerResults({
junitDirectory,
testsDirectory,
workingDirectory: stepsCtx.workingDirectory,
});
} else if (usedFileAttrs) {
flowResults = await parseMaestroResultsFromFileAttrs(junitDirectory);
} else {
// Legacy (Maestro < 2.6.0) — DELETE this arm once the fleet is on >= 2.6.0.
@@ -1,4 +1,4 @@
import { Platform } from '@expo/eas-build-job';
import { Platform, formatBytes } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import {
@@ -17,7 +17,6 @@ import * as tar from 'tar';
import { promisify } from 'util';
import z from 'zod';
import { formatBytes } from '../../utils/artifacts';
import { PUBLIC_CACHE_KEY_PREFIX_BY_PLATFORM } from '../../utils/cacheKey';
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
import { TurtleFetchError, turtleFetch } from '../../utils/turtleFetch';
@@ -1,4 +1,4 @@
import { Platform } from '@expo/eas-build-job';
import { Platform, formatBytes } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import {
@@ -14,7 +14,6 @@ import os from 'os';
import path from 'path';
import { compressCacheAsync, uploadCacheAsync } from './saveCache';
import { formatBytes } from '../../utils/artifacts';
import {
CcacheBuildTarget,
generateDefaultBuildCacheKeyAsync,
@@ -1,4 +1,4 @@
import { Platform } from '@expo/eas-build-job';
import { Platform, formatBytes } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
@@ -12,7 +12,6 @@ import * as tar from 'tar';
import z from 'zod';
import { uploadWithSignedUrl } from '../../storage/uploadWithSignedUrl';
import { formatBytes } from '../../utils/artifacts';
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
import { getCacheVersion } from '../utils/cache';
@@ -1,6 +1,7 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import { ArchiveSourceType, SystemError, UserError } from '@expo/eas-build-job';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import { stripRepositoryCredentialsAsync } from '../../common/git';
import { CustomBuildContext } from '../../customBuildContext';
import { withLogPhaseAsync } from '../../utils/logPhase';
import { startSandboxDaemonAsync } from '../utils/sandboxDaemon';
@@ -45,6 +46,15 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
`Invalid sandbox maximum duration: ${String(maxDurationSeconds)}. Provide a finite number greater than zero, or omit max_duration_seconds.`
);
}
if (ctx.job.projectArchive.type === ArchiveSourceType.GIT) {
// Scripts and commands inside the sandbox could read the token from .git/config.
await stripRepositoryCredentialsAsync({
repositoryDirectory: stepCtx.global.wasCheckedOut()
? ctx.projectTargetDirectory
: ctx.projectSourceDirectory,
});
}
const daemon = await startSandboxDaemonAsync({
credential: workerApi.token,
serverUrl: mcpServerUrl,
@@ -217,8 +217,12 @@ describe(uploadAgentDeviceArtifactAsync, () => {
);
});
it('uploads a screenshot display rotation as artifact metadata', async () => {
const data = Buffer.from('png-data');
it('uploads the screenshot size, source and display rotation as artifact metadata', async () => {
const data = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aF9sAAAAASUVORK5CYII=',
'base64'
);
const logger = createLoggerMock();
const ctx = {} as unknown as CustomBuildContext;
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
@@ -232,7 +236,7 @@ describe(uploadAgentDeviceArtifactAsync, () => {
deviceRunSessionId: 'drs-id',
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'daemon-token',
logger: createLoggerMock(),
logger,
artifact: {
id: 'artifact-id',
artifactType: 'screenshot',
@@ -245,12 +249,48 @@ describe(uploadAgentDeviceArtifactAsync, () => {
ctx,
expect.objectContaining({
kind: 'screenshot',
metadata: { displayRotation: 'landscape-right' },
metadata: {
source: 'agent-device',
displayRotation: 'landscape-right',
width: 1,
height: 1,
},
})
);
expect(logger.warn).not.toHaveBeenCalled();
});
it('uploads a screenshot without its size and warns when its header cannot be read', async () => {
const data = Buffer.from('not-an-image');
const logger = createLoggerMock();
const ctx = {} as unknown as CustomBuildContext;
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementationOnce(async (_ctx, { openStream }) => {
await readStreamAsync(openStream());
});
await uploadAgentDeviceArtifactAsync(ctx, {
deviceRunSessionId: 'drs-id',
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'daemon-token',
logger,
artifact: { id: 'artifact-id', artifactType: 'screenshot', filename: 'shot.png' },
});
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ kind: 'screenshot', metadata: { source: 'agent-device' } })
);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ file: expect.stringMatching(/shot\.png$/) }),
expect.stringContaining('without its width and height')
);
});
it('uploads no metadata when the artifact has no display rotation', async () => {
it('uploads no metadata for a non-screenshot artifact without a display rotation', async () => {
const data = Buffer.from('artifact-data');
const ctx = {} as unknown as CustomBuildContext;
@@ -266,7 +306,7 @@ describe(uploadAgentDeviceArtifactAsync, () => {
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'daemon-token',
logger: createLoggerMock(),
artifact: { id: 'artifact-id', artifactType: 'screenshot', filename: 'shot.png' },
artifact: { id: 'artifact-id', artifactType: 'screen-recording', filename: 'session.mp4' },
});
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync).mock.calls[0][1]).not.toHaveProperty(
@@ -155,17 +155,18 @@ describe(uploadArgentArtifactAsync, () => {
});
});
async function uploadAsync(artifact: {
id: string;
kind?: string;
filename: string;
mimeType: string;
isDirectory?: boolean;
}): Promise<CustomBuildContext> {
async function uploadAsync(
artifact: {
id: string;
kind?: string;
filename: string;
mimeType: string;
isDirectory?: boolean;
},
data = Buffer.from('artifact-data')
): Promise<CustomBuildContext> {
const ctx = {} as unknown as CustomBuildContext;
jest
.mocked(fetch)
.mockResolvedValueOnce(new Response(Readable.from([Buffer.from('artifact-data')])));
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementationOnce(async (_ctx, { openStream }) => {
@@ -200,6 +201,45 @@ describe(uploadArgentArtifactAsync, () => {
);
});
it.each([
{ kind: undefined, expectedKind: 'screenshot' },
{ kind: 'screenshot-diff', expectedKind: 'screenshot-diff' },
])('sends the size of a $expectedKind read from its header', async ({ kind, expectedKind }) => {
const png = Buffer.alloc(33);
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(png);
png.writeUInt32BE(13, 8);
png.write('IHDR', 12, 'latin1');
png.writeUInt32BE(1179, 16);
png.writeUInt32BE(2556, 20);
const ctx = await uploadAsync(
{ id: 'artifact-id', kind, filename: 'screen.png', mimeType: 'image/png' },
png
);
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
kind: expectedKind,
metadata: { source: 'argent', width: 1179, height: 2556 },
})
);
});
it('sends no screenshot metadata for other artifacts', async () => {
await uploadAsync({ id: 'artifact-id', filename: 'session.mp4', mimeType: 'video/mp4' });
await uploadAsync({
id: 'artifact-id',
kind: 'screenshot',
filename: 'screenshots',
mimeType: 'image/png',
isDirectory: true,
});
for (const [, input] of jest.mocked(uploadDeviceRunSessionArtifactAsync).mock.calls) {
expect(input).not.toHaveProperty('metadata');
}
});
it('uses the semantic kind reported by Argent', async () => {
const ctx = await uploadAsync({
id: 'artifact-id',
@@ -45,6 +45,17 @@ function createCtx(): CustomBuildContext {
} as unknown as CustomBuildContext;
}
function webpPreview(width: number, height: number): Buffer {
const image = Buffer.alloc(30);
image.write('RIFF', 0, 'latin1');
image.writeUInt32LE(22, 4);
image.write('WEBPVP8X', 8, 'latin1');
image.writeUInt32LE(10, 16);
image.writeUIntLE(width - 1, 24, 3);
image.writeUIntLE(height - 1, 27, 3);
return image;
}
function uploadSessionResponse(): InstanceType<typeof Response> {
return new Response(JSON.stringify({ data: { uploadSession } }), {
headers: { 'content-type': 'application/json' },
@@ -226,6 +237,89 @@ describe('session preview upload session from the worker API', () => {
expect(fetch).toHaveBeenCalledTimes(1);
});
it('sends no metadata when the preview size cannot be read', async () => {
start();
await jest.advanceTimersByTimeAsync(0);
expect(turtleFetch).toHaveBeenCalledWith(
PREVIEW_UPLOAD_SESSION_URL,
'POST',
expect.not.objectContaining({ json: expect.anything() })
);
expect(fetch).toHaveBeenCalledTimes(1);
});
it('sends the preview size and refreshes it only when the size changes', async () => {
const captureAsync = jest
.fn<Promise<Buffer>, [AbortSignal]>()
.mockResolvedValueOnce(webpPreview(148, 320))
.mockResolvedValueOnce(webpPreview(148, 320))
.mockResolvedValue(webpPreview(320, 148));
preview = startDeviceRunSessionPreview({
ctx: createCtx(),
deviceRunSessionId: 'session-id',
captureAsync,
logger,
});
await jest.advanceTimersByTimeAsync(120_000);
expect(captureAsync).toHaveBeenCalledTimes(3);
expect(fetch).toHaveBeenCalledTimes(3);
expect(jest.mocked(turtleFetch).mock.calls.map(([, , options]) => options.json)).toEqual([
{ metadata: { source: 'preview', width: 148, height: 320 } },
{ metadata: { source: 'preview', width: 320, height: 148 } },
]);
expect(jest.mocked(turtleFetch).mock.invocationCallOrder[1]).toBeGreaterThan(
jest.mocked(fetch).mock.invocationCallOrder[2]
);
});
it('keeps the stored size when the upload of a resized preview fails', async () => {
const captureAsync = jest
.fn<Promise<Buffer>, [AbortSignal]>()
.mockResolvedValueOnce(webpPreview(148, 320))
.mockResolvedValue(webpPreview(320, 148));
jest
.mocked(fetch)
.mockResolvedValueOnce(new Response('', { status: 200 }))
.mockResolvedValueOnce(new Response('', { status: 503 }));
preview = startDeviceRunSessionPreview({
ctx: createCtx(),
deviceRunSessionId: 'session-id',
captureAsync,
logger,
});
await jest.advanceTimersByTimeAsync(60_000);
await preview.stopAsync();
expect(fetch).toHaveBeenCalledTimes(2);
expect(jest.mocked(turtleFetch).mock.calls.map(([, , options]) => options.json)).toEqual([
{ metadata: { source: 'preview', width: 148, height: 320 } },
]);
});
it('sends the size again after a failed size refresh', async () => {
const captureAsync = jest
.fn<Promise<Buffer>, [AbortSignal]>()
.mockResolvedValueOnce(webpPreview(148, 320))
.mockResolvedValueOnce(webpPreview(320, 148))
.mockResolvedValue(webpPreview(148, 320));
jest
.mocked(turtleFetch)
.mockImplementationOnce(async () => uploadSessionResponse())
.mockRejectedValueOnce(new Error('response lost'));
preview = startDeviceRunSessionPreview({
ctx: createCtx(),
deviceRunSessionId: 'session-id',
captureAsync,
logger,
});
await jest.advanceTimersByTimeAsync(120_000);
expect(jest.mocked(turtleFetch).mock.calls.map(([, , options]) => options.json)).toEqual([
{ metadata: { source: 'preview', width: 148, height: 320 } },
{ metadata: { source: 'preview', width: 320, height: 148 } },
{ metadata: { source: 'preview', width: 148, height: 320 } },
]);
expect(fetch).toHaveBeenCalledTimes(3);
});
it('logs a failure and uploads nothing when the job has no device run session token', async () => {
start({
env: {},
@@ -315,10 +409,8 @@ describe(captureDeviceRunSessionPreviewAsync, () => {
}) as ReturnType<typeof spawn>;
}
return (async () => {
if (command === 'xcrun') {
expect(args.slice(0, 4)).toEqual(['simctl', 'io', 'selected-device', 'screenshot']);
await writeFile(args[4], Uint8Array.from(png));
} else if (command === 'sips') {
expect(command).not.toBe('xcrun');
if (command === 'sips') {
expect(args.slice(0, 2)).toEqual(['-Z', '320']);
expect(await readFile(args[2])).toEqual(png);
await writeFile(args[args.indexOf('--out') + 1], Uint8Array.from(resized));
@@ -340,6 +432,10 @@ describe(captureDeviceRunSessionPreviewAsync, () => {
await captureDeviceRunSessionPreviewAsync({
runtimePlatform,
device,
captureIosScreenshotAsync: async (file, signal) => {
signal.throwIfAborted();
await writeFile(file, Uint8Array.from(png));
},
env: {},
signal: new AbortController().signal,
})
@@ -372,6 +468,9 @@ describe(captureDeviceRunSessionPreviewAsync, () => {
captureDeviceRunSessionPreviewAsync({
runtimePlatform: BuildRuntimePlatform.DARWIN,
device: 'device',
captureIosScreenshotAsync: async () => {
throw new Error('capture failed');
},
env: {},
signal: new AbortController().signal,
})
@@ -0,0 +1,205 @@
import type { bunyan } from '@expo/logger';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
createScreenshotMetadataAsync,
readImageSize,
} from '../deviceRunSessionScreenshotMetadata';
function png(width: number, height: number): Buffer {
const header = Buffer.alloc(33);
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(header);
header.writeUInt32BE(13, 8);
header.write('IHDR', 12, 'latin1');
header.writeUInt32BE(width, 16);
header.writeUInt32BE(height, 20);
return header;
}
function webp(chunk: string, data: Buffer): Buffer {
const header = Buffer.alloc(20);
header.write('RIFF', 0, 'latin1');
header.writeUInt32LE(12 + data.length, 4);
header.write('WEBP', 8, 'latin1');
header.write(chunk, 12, 'latin1');
header.writeUInt32LE(data.length, 16);
return Buffer.concat([header, data]);
}
function vp8(width: number, height: number): Buffer {
const data = Buffer.alloc(10);
Buffer.from([0x9d, 0x01, 0x2a]).copy(data, 3);
data.writeUInt16LE(width, 6);
data.writeUInt16LE(height, 8);
return webp('VP8 ', data);
}
function vp8l(width: number, height: number): Buffer {
const data = Buffer.alloc(10);
data[0] = 0x2f;
data.writeUInt32LE((width - 1) | ((height - 1) << 14), 1);
return webp('VP8L', data);
}
function vp8x(width: number, height: number): Buffer {
const data = Buffer.alloc(10);
data.writeUIntLE(width - 1, 4, 3);
data.writeUIntLE(height - 1, 7, 3);
return webp('VP8X', data);
}
function jpegSegment(marker: number, payload: Buffer): Buffer {
const header = Buffer.from([0xff, marker, 0, 0]);
header.writeUInt16BE(payload.length + 2, 2);
return Buffer.concat([header, payload]);
}
function jpegFrame(marker: number, width: number, height: number, components = 3): Buffer {
const payload = Buffer.alloc(6 + 3 * components);
payload[0] = 8;
payload.writeUInt16BE(height, 1);
payload.writeUInt16BE(width, 3);
payload[5] = components;
return jpegSegment(marker, payload);
}
const JPEG_START = Buffer.from([0xff, 0xd8]);
describe(readImageSize, () => {
it('reads the PNG IHDR size', () => {
expect(readImageSize(png(1179, 2556))).toEqual({ width: 1179, height: 2556 });
});
it('reads the lossy WebP frame size without its scale bits', () => {
expect(readImageSize(vp8(0x4000 | 1179, 0xc000 | 2556))).toEqual({
width: 1179,
height: 2556,
});
});
it('reads the lossless WebP size', () => {
expect(readImageSize(vp8l(1179, 2556))).toEqual({ width: 1179, height: 2556 });
});
it('reads the size of a minimal lossless WebP shorter than other WebP headers', () => {
const minimalLossless = Buffer.from(
'5249464614000000574542505650384c080000002f0fc00310888808',
'hex'
);
expect(readImageSize(minimalLossless)).toEqual({ width: 16, height: 16 });
});
it('reads the extended WebP canvas size', () => {
expect(readImageSize(vp8x(1179, 2556))).toEqual({ width: 1179, height: 2556 });
});
it('reads the JPEG frame size after other segments', () => {
const jpeg = Buffer.concat([
JPEG_START,
jpegSegment(0xe1, Buffer.alloc(300)),
Buffer.from([0xff]),
jpegSegment(0xc4, Buffer.alloc(20)),
jpegFrame(0xc2, 1179, 2556),
]);
expect(readImageSize(jpeg)).toEqual({ width: 1179, height: 2556 });
});
it.each([
['an empty buffer', Buffer.alloc(0)],
['a truncated PNG', png(1179, 2556).subarray(0, 20)],
['a PNG without IHDR first', Buffer.concat([png(1, 1).subarray(0, 12), Buffer.alloc(21)])],
['a zero-width PNG', png(0, 2556)],
['a truncated WebP', vp8x(1179, 2556).subarray(0, 26)],
['a WebP with an unknown chunk', webp('ALPH', Buffer.alloc(10))],
['a lossy WebP without a start code', webp('VP8 ', Buffer.alloc(10))],
[
'a JPEG truncated in its frame header',
Buffer.concat([JPEG_START, jpegFrame(0xc0, 1179, 2556)]).subarray(0, 8),
],
[
'a JPEG truncated before its frame',
Buffer.concat([JPEG_START, jpegSegment(0xe1, Buffer.alloc(300))]),
],
[
'a JPEG with scan data before a frame',
Buffer.concat([JPEG_START, jpegSegment(0xda, Buffer.alloc(10)), jpegFrame(0xc0, 1, 1)]),
],
[
'a JPEG frame without components',
Buffer.concat([JPEG_START, jpegFrame(0xc0, 1179, 2556, 0)]),
],
['a JPEG frame shorter than its header', Buffer.from('ffd8ffc0000208049b09fc', 'hex')],
['a JPEG frame longer than the read bytes', Buffer.from('ffd8ffc0ffff08049b09fc', 'hex')],
[
'a JPEG frame whose length does not match its components',
Buffer.concat([
JPEG_START,
jpegSegment(
0xc0,
Buffer.concat([Buffer.from([8, 0x09, 0xfc, 0x04, 0x9b, 3]), Buffer.alloc(6)])
),
]),
],
['a GIF', Buffer.from('GIF89a\x01\x00\x01\x00\x00\x00\x00', 'latin1')],
['a log file', Buffer.from('2026-11-10T12:00:00Z app started\n'.repeat(4))],
])('returns undefined for %s', (_description, header) => {
expect(readImageSize(header)).toBeUndefined();
});
});
describe(createScreenshotMetadataAsync, () => {
let directory: string;
let logger: bunyan;
beforeEach(async () => {
directory = await mkdtemp(path.join(os.tmpdir(), 'screenshot-metadata-test-'));
logger = { warn: jest.fn() } as unknown as bunyan;
});
afterEach(async () => {
await rm(directory, { recursive: true, force: true });
});
it('adds the image size to the screenshot metadata', async () => {
const file = path.join(directory, 'shot.png');
await writeFile(file, Buffer.concat([png(1179, 2556), Buffer.alloc(128 * 1024)]));
await expect(
createScreenshotMetadataAsync(file, {
source: 'agent-device',
displayRotation: 'landscape-left',
logger,
})
).resolves.toEqual({
source: 'agent-device',
displayRotation: 'landscape-left',
width: 1179,
height: 2556,
});
expect(logger.warn).not.toHaveBeenCalled();
});
it('warns and leaves out the size when the header is not a supported image', async () => {
const file = path.join(directory, 'shot.png');
await writeFile(file, 'not-an-image');
await expect(
createScreenshotMetadataAsync(file, { source: 'manual', logger })
).resolves.toEqual({ source: 'manual' });
expect(logger.warn).toHaveBeenCalledWith(
{ file },
'Uploading the screenshot without its width and height because it does not start with a complete PNG, WebP or JPEG header.'
);
});
it('warns and leaves out the size when the file cannot be read', async () => {
const file = path.join(directory, 'missing.png');
await expect(
createScreenshotMetadataAsync(file, { source: 'manual', logger })
).resolves.toEqual({ source: 'manual' });
expect(logger.warn).toHaveBeenCalledWith(
{ err: expect.objectContaining({ code: 'ENOENT' }), file },
'Uploading the screenshot without its width and height because its header could not be read.'
);
});
});
@@ -8,12 +8,14 @@ import path from 'node:path';
import type { CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
import { createScreenshotMetadataAsync } from '../deviceRunSessionScreenshotMetadata';
import {
startDeviceRunSessionScreenshotsAsync,
uploadDeviceRunSessionScreenshotsAsync,
} from '../deviceRunSessionScreenshots';
jest.mock('../deviceRunSessionArtifacts');
jest.mock('../deviceRunSessionScreenshotMetadata');
jest.mock('../../../sentry');
const ctx = {} as CustomBuildContext;
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
@@ -27,6 +29,9 @@ beforeEach(async () => {
directory = await mkdtemp(path.join(os.tmpdir(), 'screenshots-test-'));
uploaded = [];
failedUploads = new Map();
jest
.mocked(createScreenshotMetadataAsync)
.mockResolvedValue({ source: 'manual', width: 1179, height: 2556 });
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementation(async (_ctx, { openStream }) => {
@@ -68,9 +73,13 @@ it('uploads completed captures with their bytes and stable IDs, ignoring partial
filename: 'screenshot-2026-09-24T08-45-59-123Z.png',
kind: 'screenshot',
size: 11,
metadata: { __eas_type: 'screenshot' },
metadata: { __eas_type: 'screenshot', source: 'manual', width: 1179, height: 2556 },
})
);
expect(createScreenshotMetadataAsync).toHaveBeenCalledWith(path.join(directory, filename), {
source: 'manual',
logger,
});
expect(await readdir(directory)).not.toContain(filename);
expect((await lstat(path.join(directory, link))).isSymbolicLink()).toBe(true);
});
@@ -152,6 +152,9 @@ it('publishes a manual preview PNG through the worker API and HTTP upload path',
platform: 'ios',
buildId: 'installed-build',
gitCommitHash: installedCommit,
source: 'manual',
width: 1,
height: 1,
},
});
await expect(stat(path.join(directory, filename))).rejects.toMatchObject({ code: 'ENOENT' });
@@ -176,6 +176,18 @@ describe(DeviceRunSessionWorkerApi, () => {
data: { uploadSession: { url: 'https://uploads.test/preview', headers: {} } },
expected: { url: 'https://uploads.test/preview', headers: {} },
},
{
name: 'createPreviewUploadSessionAsync with metadata',
call: (api: DeviceRunSessionWorkerApi) =>
api.createPreviewUploadSessionAsync({
metadata: { source: 'preview', width: 148, height: 320 },
}),
method: 'POST',
route: 'preview-upload-session',
json: { metadata: { source: 'preview', width: 148, height: 320 } },
data: { uploadSession: { url: 'https://uploads.test/preview', headers: {} } },
expected: { url: 'https://uploads.test/preview', headers: {} },
},
{
name: 'fetchTurnIceServersAsync',
call: (api: DeviceRunSessionWorkerApi) => api.fetchTurnIceServersAsync(),
@@ -40,9 +40,14 @@ import {
spawnDetached,
startNgrokTunnelAsync,
} from '../remoteDeviceRunSession';
import { runServeSimActionAsync, stageServeSimAppAsync } from '../serveSimActions';
import {
runServeSimActionAsync,
runServeSimActionWithResultAsync,
stageServeSimAppAsync,
} from '../serveSimActions';
import { startServeSimActivityPolling } from '../serveSimActivity';
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
import * as previewCapture from '../serveSimPreviewCapture';
jest.mock('@ngrok/ngrok');
jest.mock('@expo/turtle-spawn');
@@ -165,6 +170,12 @@ beforeEach(() => {
.mockImplementation(async ({ artifactPath }) => installedBundleIds[artifactPath]);
jest.mocked(verifyLocalEgressGuardAsync).mockReset().mockResolvedValue(undefined);
jest.mocked(readLocalEgressHandoffAsync).mockReset().mockResolvedValue(null);
jest
.mocked(runServeSimActionWithResultAsync)
.mockReset()
.mockResolvedValue(
JSON.stringify({ 'screenshot.capture': { properties: { automation: { type: 'boolean' } } } })
);
stopSessionPreview.mockResolvedValue(undefined);
jest.mocked(startDeviceRunSessionPreview).mockReturnValue({ stopAsync: stopSessionPreview });
jest.mocked(ensureFfmpegInstalledOnceAsync).mockResolvedValue(undefined);
@@ -1116,6 +1127,26 @@ it.each([
await host.finishAsync();
});
it.each([
['the turtle-v2 copy by default', undefined, 'https://storage.googleapis.com/turtle-v2/scrcpy'],
['the step env value when it is set', 'https://mirror.test/scrcpy', 'https://mirror.test/scrcpy'],
])(
'points expo-device-hub at %s for the scrcpy server',
async (_description, override, expected) => {
const host = await startDeviceSessionHostAsync(ctx, {
preview: { baseDomain, authtoken },
runtimePlatform: BuildRuntimePlatform.LINUX,
env: override ? { ...env, SERVE_EMU_SCRCPY_BASE_URL: override } : env,
logger,
separateLogPhase: true,
timeoutMs: 10_000,
});
const options = jest.mocked(spawnDetached).mock.calls[0][0];
expect(options.env.SERVE_EMU_SCRCPY_BASE_URL).toBe(expected);
await host.finishAsync();
}
);
it('lets the session add frame ancestors and CORS origins after the website origins', async () => {
const host = await startDeviceSessionHostAsync(ctx, {
preview: { baseDomain, authtoken },
@@ -1479,6 +1510,119 @@ it.each([BuildRuntimePlatform.LINUX, BuildRuntimePlatform.DARWIN])(
}
);
it.each(['missing automation', 'invalid catalog', 'unavailable catalog'])(
'skips automated thumbnails with %s without stopping the iOS session',
async failure => {
if (failure === 'unavailable catalog') {
jest
.mocked(runServeSimActionWithResultAsync)
.mockRejectedValueOnce(new Error('unknown action'));
} else {
jest
.mocked(runServeSimActionWithResultAsync)
.mockResolvedValueOnce(
failure === 'invalid catalog'
? 'invalid JSON'
: JSON.stringify({ 'screenshot.capture': { properties: {} } })
);
}
const host = await startDeviceSessionHostAsync(ctx, {
runtimePlatform: BuildRuntimePlatform.DARWIN,
env,
logger,
timeoutMs: 10_000,
preview: { baseDomain, authtoken },
});
await new Promise(resolve => setImmediate(resolve));
expect(runServeSimActionWithResultAsync).toHaveBeenCalledTimes(1);
expect(startDeviceRunSessionPreview).not.toHaveBeenCalled();
expect(ensureMacosPreviewEncoderInstalledAsync).not.toHaveBeenCalled();
expect(stopServer).not.toHaveBeenCalled();
await host.finishAsync();
}
);
it('cancels catalog inspection during shutdown without sending a thumbnail capture', async () => {
let inspectionSignal: AbortSignal | undefined;
jest.mocked(runServeSimActionWithResultAsync).mockImplementationOnce(async ({ signal }) => {
inspectionSignal = signal;
return await new Promise<string>((_resolve, reject) => {
signal?.addEventListener('abort', () => reject(signal.reason), { once: true });
});
});
const host = await startDeviceSessionHostAsync(ctx, {
runtimePlatform: BuildRuntimePlatform.DARWIN,
env,
logger,
timeoutMs: 10_000,
preview: { baseDomain, authtoken },
});
expect(inspectionSignal?.aborted).toBe(false);
await host.finishAsync();
expect(inspectionSignal?.aborted).toBe(true);
expect(startDeviceRunSessionPreview).not.toHaveBeenCalled();
expect(ensureMacosPreviewEncoderInstalledAsync).not.toHaveBeenCalled();
expect(stopServer).toHaveBeenCalledTimes(1);
});
it('finishes manual screenshots even when thumbnail cleanup fails', async () => {
const finishScreenshots = jest.fn().mockResolvedValue(undefined);
const collector = jest
.spyOn(screenshotCollector, 'startDeviceRunSessionScreenshotsAsync')
.mockResolvedValueOnce({
directory: '/tmp/manual-screenshots',
finishAsync: finishScreenshots,
});
const cleanup = jest.fn().mockRejectedValue(new Error('unlink refused'));
const factory = jest.spyOn(previewCapture, 'createServeSimPreviewCapture').mockReturnValueOnce({
isSupportedAsync: jest.fn().mockResolvedValue(true),
captureAsync: jest.fn(),
cleanupAfterHostStoppedAsync: cleanup,
});
try {
const host = await startDeviceSessionHostAsync(ctx, {
runtimePlatform: BuildRuntimePlatform.DARWIN,
env,
logger,
timeoutMs: 10_000,
preview: { baseDomain, authtoken },
});
await new Promise(resolve => setImmediate(resolve));
await host.finishAsync();
expect(cleanup).toHaveBeenCalledTimes(1);
expect(finishScreenshots).toHaveBeenCalledWith(true);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Could not remove session preview capture files.'
);
} finally {
factory.mockRestore();
collector.mockRestore();
}
});
it('drains thumbnail capture before stopping the iOS host', async () => {
const stopped = deferred<void>();
stopSessionPreview.mockReturnValueOnce(stopped.promise);
const host = await startDeviceSessionHostAsync(ctx, {
runtimePlatform: BuildRuntimePlatform.DARWIN,
env,
logger,
timeoutMs: 10_000,
preview: { baseDomain, authtoken },
});
await new Promise(resolve => setImmediate(resolve));
const finishing = host.finishAsync();
try {
await new Promise(resolve => setImmediate(resolve));
expect(stopServer).not.toHaveBeenCalled();
} finally {
stopped.resolve();
await finishing;
}
expect(stopServer).toHaveBeenCalledTimes(1);
});
it.each(['success', 'failure'])(
'keeps macOS readiness nonblocking and drains encoder setup %s before ending the phase',
async result => {
@@ -16,7 +16,11 @@ import os from 'node:os';
import path from 'node:path';
import { WebSocketServer } from 'ws';
import { runServeSimActionAsync, stageServeSimAppAsync } from '../serveSimActions';
import {
runServeSimActionAsync,
runServeSimActionWithResultAsync,
stageServeSimAppAsync,
} from '../serveSimActions';
jest.unmock('fs');
jest.unmock('node:fs');
@@ -67,6 +71,18 @@ it('authenticates the upgrade, waits for ready, and sends the action once', asyn
expect(actions).toEqual([{ id: 1, action: 'app.install', params: request().params }]);
});
it('returns stdout from an authenticated action for catalog inspection', async () => {
server.on('connection', socket => {
socket.on('message', () =>
socket.send(JSON.stringify({ id: 1, stdout: '{"actions":[]}', exitCode: 0 }))
);
socket.send(JSON.stringify({ ready: true }));
});
await expect(
runServeSimActionWithResultAsync({ ...request(), action: 'actions.describe', params: {} })
).resolves.toBe('{"actions":[]}');
});
it.each([
[{ id: 1, exitCode: 1, stderr: 'install refused' }, 'install refused'],
[{ id: 1, error: 'unknown action app.install' }, 'unknown action'],
@@ -137,6 +153,55 @@ it('aborts an in-flight action without resending it', async () => {
expect(attempts).toBe(1);
});
it('cancels before dispatch while waiting for ready in drain mode', async () => {
const controller = new AbortController();
const actions = jest.fn();
server.on('connection', socket => {
socket.on('message', actions);
controller.abort(new Error('stopped before ready'));
socket.send(JSON.stringify({ ready: true }));
});
await expect(
runServeSimActionAsync({
...request(),
signal: controller.signal,
drainAfterDispatch: true,
})
).rejects.toThrow('stopped before ready');
expect(actions).not.toHaveBeenCalled();
});
it('drains dispatched actions after cancellation in drain mode', async () => {
const controller = new AbortController();
let reply!: () => void;
let dispatched!: () => void;
const ready = new Promise<void>(resolve => {
dispatched = resolve;
});
server.on('connection', socket => {
socket.send(JSON.stringify({ ready: true }));
socket.on('message', () => {
reply = () => socket.send(JSON.stringify({ id: 1, stdout: 'completed', exitCode: 0 }));
dispatched();
});
});
let settled = false;
const action = runServeSimActionAsync({
...request(),
signal: controller.signal,
drainAfterDispatch: true,
});
void action.finally(() => {
settled = true;
});
await ready;
controller.abort(new Error('stopped after dispatch'));
await new Promise(resolve => setImmediate(resolve));
expect(settled).toBe(false);
reply();
await expect(action).resolves.toBeUndefined();
});
it('does not connect when already aborted', async () => {
const connected = jest.fn();
server.on('connection', connected);
@@ -0,0 +1,153 @@
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { runServeSimActionAsync, runServeSimActionWithResultAsync } from '../serveSimActions';
import { createServeSimPreviewCapture } from '../serveSimPreviewCapture';
jest.mock('../serveSimActions');
const signal = new AbortController().signal;
const tmpdir = path.join(os.tmpdir(), 'host-tmp');
const staging = path.join(tmpdir, 'serve-sim-screenshots');
const destination = path.join(os.tmpdir(), 'screen.png');
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0xff, 0x00]);
const options = { port: 1234, token: 'host-token', device: 'selected-udid', tmpdir };
beforeEach(async () => {
await mkdir(staging, { recursive: true });
jest.mocked(runServeSimActionAsync).mockReset();
});
it('captures the selected active display and removes only its own staging', async () => {
await writeFile(path.join(staging, 'unrelated.png'), 'keep');
jest.mocked(runServeSimActionAsync).mockImplementation(async request => {
expect(request).toEqual({
port: 1234,
token: 'host-token',
action: 'screenshot.capture',
params: {
udid: 'selected-udid',
fileName: expect.stringMatching(/^serve-sim-screenshot-preview-.*\.png$/),
automation: true,
},
timeoutMs: 30_000,
signal,
drainAfterDispatch: true,
});
await writeFile(path.join(staging, request.params.fileName as string), png);
return;
});
const capture = createServeSimPreviewCapture(options);
await capture.captureAsync(destination, signal);
expect(await readFile(destination)).toEqual(png);
expect(await readdir(staging)).toEqual(['unrelated.png']);
});
it('drains an already sent action before honoring abort and deleting its output', async () => {
const controller = new AbortController();
let complete!: () => void;
const pending = new Promise<void>(resolve => {
complete = resolve;
});
let staged!: string;
jest.mocked(runServeSimActionAsync).mockImplementation(async request => {
staged = path.join(staging, request.params.fileName as string);
await pending;
await writeFile(staged, png);
return;
});
const capture = createServeSimPreviewCapture(options);
let settled = false;
const capturing = capture.captureAsync(destination, controller.signal);
void capturing.then(
() => {
settled = true;
},
() => {
settled = true;
}
);
controller.abort(new Error('session stopped'));
await Promise.resolve();
expect(settled).toBe(false);
complete();
await expect(capturing).rejects.toThrow('session stopped');
expect(await readdir(staging)).toEqual([]);
});
it.each(['timed out', 'connection closed'])(
'keeps a possibly writable file after %s until the host stops',
async message => {
let staged!: string;
jest.mocked(runServeSimActionAsync).mockImplementation(async request => {
staged = path.join(staging, request.params.fileName as string);
await writeFile(staged, 'reservation');
throw new Error(message);
});
const capture = createServeSimPreviewCapture(options);
await expect(capture.captureAsync(destination, signal)).rejects.toThrow(message);
expect(await readFile(staged, 'utf8')).toBe('reservation');
// The server may still write after the client's deadline or disconnect.
await writeFile(staged, png);
await capture.cleanupAfterHostStoppedAsync();
expect(await readdir(staging)).toEqual([]);
}
);
it('attempts every owned cleanup when one file cannot be removed', async () => {
let blocked!: string;
let count = 0;
jest.mocked(runServeSimActionAsync).mockImplementation(async request => {
const staged = path.join(staging, request.params.fileName as string);
if (count++ === 0) {
blocked = staged;
await mkdir(staged);
} else {
await writeFile(staged, png);
}
throw new Error('capture disconnected');
});
const capture = createServeSimPreviewCapture(options);
await expect(capture.captureAsync(destination, signal)).rejects.toThrow('disconnected');
await expect(capture.captureAsync(destination, signal)).rejects.toThrow('disconnected');
await expect(capture.cleanupAfterHostStoppedAsync()).rejects.toThrow(
'Could not remove preview captures'
);
expect(await readdir(staging)).toEqual([path.basename(blocked)]);
});
it('does not send a capture when already aborted', async () => {
const capture = createServeSimPreviewCapture(options);
await expect(
capture.captureAsync(destination, AbortSignal.abort(new Error('stopped')))
).rejects.toThrow('stopped');
expect(runServeSimActionAsync).not.toHaveBeenCalled();
});
it.each([
[
{ 'screenshot.capture': { properties: { automation: { type: 'boolean', default: false } } } },
true,
],
[{ 'screenshot.capture': { properties: {} } }, false],
[{ 'screenshot.capture': { properties: { automation: { type: 'string' } } } }, false],
[{}, false],
[null, false],
])(
'checks the catalog automation field before enabling captures (%j)',
async (catalog, supported) => {
jest.mocked(runServeSimActionWithResultAsync).mockResolvedValueOnce(JSON.stringify(catalog));
const capture = createServeSimPreviewCapture(options);
await expect(capture.isSupportedAsync(signal)).resolves.toBe(supported);
expect(runServeSimActionWithResultAsync).toHaveBeenCalledWith({
port: options.port,
token: options.token,
action: 'actions.describe',
params: {},
timeoutMs: 30_000,
signal,
});
expect(runServeSimActionAsync).not.toHaveBeenCalled();
}
);
@@ -1,4 +1,4 @@
import { SystemError } from '@expo/eas-build-job';
import { SystemError, formatBytes } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { createReadStream, createWriteStream } from 'node:fs';
import { mkdtemp, rm, stat } from 'node:fs/promises';
@@ -11,8 +11,8 @@ import { z } from 'zod';
import { CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import { formatBytes } from '../../utils/artifacts';
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
import { createScreenshotMetadataAsync } from './deviceRunSessionScreenshotMetadata';
const AGENT_DEVICE_ARTIFACT_UPLOAD_POLL_INTERVAL_MS = 5_000;
const AGENT_DEVICE_ARTIFACT_CLEANUP_TIMEOUT_MS = 30_000;
@@ -216,6 +216,17 @@ export async function uploadAgentDeviceArtifactAsync(
signal,
});
const { size } = await stat(temporaryArtifactPath);
const display = artifact.displayRotation
? { displayRotation: artifact.displayRotation }
: undefined;
const metadata =
artifact.artifactType === 'screenshot'
? await createScreenshotMetadataAsync(temporaryArtifactPath, {
source: 'agent-device',
...display,
logger,
})
: display;
logger.info(`Uploading artifact ${artifact.filename} (${formatBytes(size)}).`);
await uploadDeviceRunSessionArtifactAsync(ctx, {
deviceRunSessionId,
@@ -223,9 +234,7 @@ export async function uploadAgentDeviceArtifactAsync(
name: `${artifact.filename} (${artifact.id})`,
filename: artifact.filename,
kind: artifact.artifactType ?? undefined,
...(artifact.displayRotation
? { metadata: { displayRotation: artifact.displayRotation } }
: {}),
...(metadata ? { metadata } : {}),
size,
openStream: () => createReadStream(temporaryArtifactPath),
...(signal ? { signal } : {}),
@@ -1,3 +1,4 @@
import { formatBytes } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { type BuildStepGlobalContext } from '@expo/steps';
import path from 'node:path';
@@ -10,7 +11,6 @@ import {
} from '../../utils/androidEmulatorLogCapture';
import { AndroidEmulatorLogFiles } from '../../utils/androidEmulatorLogFiles';
import { type AndroidEmulatorLogSource } from '../../utils/androidEmulatorLogSources';
import { formatBytes } from '../../utils/artifacts';
import { uploadDeviceRunSessionFileAsync } from './uploadDeviceRunSessionFile';
const UPLOAD_TIMEOUT_MS = 30_000;
@@ -1,4 +1,4 @@
import { SystemError } from '@expo/eas-build-job';
import { SystemError, formatBytes } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { createReadStream, createWriteStream } from 'node:fs';
import { mkdtemp, rm, stat } from 'node:fs/promises';
@@ -11,8 +11,8 @@ import { z } from 'zod';
import { CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import { formatBytes } from '../../utils/artifacts';
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
import { createScreenshotMetadataAsync } from './deviceRunSessionScreenshotMetadata';
const ARGENT_ARTIFACT_UPLOAD_POLL_INTERVAL_MS = 5_000;
const ARGENT_ARTIFACT_UPLOAD_CLEANUP_TIMEOUT_MS = 30_000;
@@ -208,13 +208,19 @@ export async function uploadArgentArtifactAsync(
destinationPath: temporaryArtifactPath,
});
const { size } = await stat(temporaryArtifactPath);
const kind = getArgentArtifactKind(artifact);
const metadata =
(kind === 'screenshot' || kind === 'screenshot-diff') && !artifact.isDirectory
? await createScreenshotMetadataAsync(temporaryArtifactPath, { source: 'argent', logger })
: undefined;
logger.info(`Uploading artifact ${filename} (${formatBytes(size)}).`);
await uploadDeviceRunSessionArtifactAsync(ctx, {
deviceRunSessionId,
artifactId: artifact.id,
name: `${filename} (${artifact.id})`,
filename,
kind: getArgentArtifactKind(artifact),
kind,
...(metadata ? { metadata } : {}),
size,
openStream: () => createReadStream(temporaryArtifactPath),
});
@@ -11,6 +11,11 @@ import path from 'node:path';
import { type CustomBuildContext } from '../../customBuildContext';
import { uploadContentWithSignedUrl } from '../../storage/uploadWithSignedUrl';
import { killProcessGroup } from '../../utils/processes';
import {
type ImageSize,
type ScreenshotMetadata,
readImageSize,
} from './deviceRunSessionScreenshotMetadata';
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
const PREVIEW_INTERVAL_MS = 60_000;
@@ -20,7 +25,6 @@ const MAX_PREVIEW_SIZE_BYTES = 5 * 1024 * 1024;
export function startDeviceRunSessionPreview({
ctx,
deviceRunSessionId,
captureAsync,
logger,
}: {
@@ -32,6 +36,7 @@ export function startDeviceRunSessionPreview({
const controller = new AbortController();
let timer: NodeJS.Timeout | undefined;
let uploadSession: { url: string; headers: Record<string, string> } | undefined;
let storedSize: ImageSize | undefined;
let pending: Promise<void>;
const uploadAsync = async (): Promise<void> => {
@@ -44,8 +49,10 @@ export function startDeviceRunSessionPreview({
if (image.length === 0 || image.length > MAX_PREVIEW_SIZE_BYTES) {
throw new Error('Session preview is empty or exceeds the 5 MiB upload limit.');
}
const size = readImageSize(image);
if (!uploadSession) {
uploadSession = await createPreviewUploadSessionAsync(ctx, deviceRunSessionId, signal);
uploadSession = await createPreviewUploadSessionAsync(ctx, size, signal);
storedSize = size;
}
if (controller.signal.aborted) {
return;
@@ -54,6 +61,16 @@ export function startDeviceRunSessionPreview({
{ signedUrl: uploadSession, content: image },
{ signal, retries: 0, stallTimeoutMs: 30_000 }
);
if (
controller.signal.aborted ||
!size ||
(size.width === storedSize?.width && size.height === storedSize.height)
) {
return;
}
storedSize = undefined;
uploadSession = await createPreviewUploadSessionAsync(ctx, size, signal);
storedSize = size;
} catch (err) {
if (!controller.signal.aborted) {
logger.warn({ err }, 'Could not refresh the session preview; retrying in 60 seconds.');
@@ -80,10 +97,16 @@ export function startDeviceRunSessionPreview({
async function createPreviewUploadSessionAsync(
ctx: CustomBuildContext,
deviceRunSessionId: string,
size: ImageSize | undefined,
signal: AbortSignal
): Promise<{ url: string; headers: Record<string, string> }> {
return await requireDeviceRunSessionWorkerApi(ctx).createPreviewUploadSessionAsync({ signal });
const metadata: ScreenshotMetadata | undefined = size
? { source: 'preview', ...size }
: undefined;
return await requireDeviceRunSessionWorkerApi(ctx).createPreviewUploadSessionAsync(
{ metadata },
{ signal }
);
}
/**
@@ -141,18 +164,25 @@ export async function captureDeviceRunSessionPreviewAsync({
device,
env,
signal,
captureIosScreenshotAsync,
}: {
runtimePlatform: BuildRuntimePlatform;
device: string;
env: BuildStepEnv;
signal: AbortSignal;
captureIosScreenshotAsync?: (file: string, signal: AbortSignal) => Promise<void>;
}): Promise<Buffer> {
const directory = await mkdtemp(path.join(os.tmpdir(), 'session-preview-'));
try {
const options = { env, signal, timeout: 30_000, stdio: 'pipe' as const };
const preview = path.join(directory, 'preview.webp');
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await captureIosPreviewAsync({ device, directory, preview, options });
if (!captureIosScreenshotAsync) {
throw new Error(
'Cannot capture the iOS session preview: serve-sim screenshot capture is not configured.'
);
}
await captureIosPreviewAsync({ captureIosScreenshotAsync, directory, preview, options });
} else {
await captureAndroidPreviewAsync({ device, directory, preview, options });
}
@@ -170,19 +200,20 @@ type CaptureOptions = {
};
async function captureIosPreviewAsync({
device,
captureIosScreenshotAsync,
directory,
preview,
options,
}: {
device: string;
captureIosScreenshotAsync: (file: string, signal: AbortSignal) => Promise<void>;
directory: string;
preview: string;
options: CaptureOptions;
}): Promise<void> {
const screenshot = path.join(directory, 'screen.png');
const resized = path.join(directory, 'resized.png');
await spawn('xcrun', ['simctl', 'io', device, 'screenshot', screenshot], options);
await captureIosScreenshotAsync(screenshot, options.signal);
options.signal.throwIfAborted();
await spawn('sips', ['-Z', String(PREVIEW_MAX_DIMENSION), screenshot, '--out', resized], options);
await spawn('cwebp', ['-quiet', '-q', String(PREVIEW_QUALITY), resized, '-o', preview], options);
}
@@ -1,4 +1,4 @@
import { SystemError } from '@expo/eas-build-job';
import { SystemError, formatBytes } from '@expo/eas-build-job';
import type { bunyan } from '@expo/logger';
import type { BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
@@ -11,7 +11,6 @@ import { z } from 'zod';
import { type CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import { formatBytes } from '../../utils/artifacts';
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
const RecordingsSchema = z.array(
@@ -0,0 +1,149 @@
import type { bunyan } from '@expo/logger';
import { open } from 'node:fs/promises';
const IMAGE_HEADER_MAX_BYTES = 64 * 1024;
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
export type ScreenshotSource = 'manual' | 'agent-device' | 'argent' | 'preview';
export type ScreenshotMetadata = {
source: ScreenshotSource;
width?: number;
height?: number;
displayRotation?: string;
};
export type ImageSize = { width: number; height: number };
export async function createScreenshotMetadataAsync(
file: string,
{ logger, ...metadata }: Omit<ScreenshotMetadata, 'width' | 'height'> & { logger: bunyan }
): Promise<ScreenshotMetadata> {
try {
const size = await readImageFileSizeAsync(file);
if (size) {
return { ...metadata, ...size };
}
logger.warn(
{ file },
'Uploading the screenshot without its width and height because it does not start with a complete PNG, WebP or JPEG header.'
);
} catch (err) {
logger.warn(
{ err, file },
'Uploading the screenshot without its width and height because its header could not be read.'
);
}
return metadata;
}
async function readImageFileSizeAsync(file: string): Promise<ImageSize | undefined> {
const handle = await open(file, 'r');
try {
const { buffer, bytesRead } = await handle.read(
Buffer.alloc(IMAGE_HEADER_MAX_BYTES),
0,
IMAGE_HEADER_MAX_BYTES,
0
);
return readImageSize(buffer.subarray(0, bytesRead));
} finally {
await handle.close();
}
}
export function readImageSize(header: Buffer): ImageSize | undefined {
const size = readPngSize(header) ?? readWebpSize(header) ?? readJpegSize(header);
return size && size.width > 0 && size.height > 0 ? size : undefined;
}
function readPngSize(header: Buffer): ImageSize | undefined {
if (
header.length < 24 ||
!header.subarray(0, 8).equals(PNG_SIGNATURE) ||
header.toString('latin1', 12, 16) !== 'IHDR'
) {
return undefined;
}
return { width: header.readUInt32BE(16), height: header.readUInt32BE(20) };
}
function readWebpSize(header: Buffer): ImageSize | undefined {
if (
header.length < 16 ||
header.toString('latin1', 0, 4) !== 'RIFF' ||
header.toString('latin1', 8, 12) !== 'WEBP'
) {
return undefined;
}
switch (header.toString('latin1', 12, 16)) {
case 'VP8 ':
if (header.length < 30 || header.readUIntBE(23, 3) !== 0x9d012a) {
return undefined;
}
return {
width: header.readUInt16LE(26) & 0x3fff,
height: header.readUInt16LE(28) & 0x3fff,
};
case 'VP8L': {
if (header.length < 25 || header[20] !== 0x2f) {
return undefined;
}
const bits = header.readUInt32LE(21);
return { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 };
}
case 'VP8X':
if (header.length < 30) {
return undefined;
}
return { width: header.readUIntLE(24, 3) + 1, height: header.readUIntLE(27, 3) + 1 };
default:
return undefined;
}
}
function readJpegSize(header: Buffer): ImageSize | undefined {
if (header.length < 4 || header[0] !== 0xff || header[1] !== 0xd8) {
return undefined;
}
let offset = 2;
while (offset + 4 <= header.length) {
if (header[offset] !== 0xff) {
return undefined;
}
const marker = header[offset + 1];
if (marker === 0xff) {
offset += 1;
continue;
}
if (marker === 0xd9 || marker === 0xda) {
return undefined;
}
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
offset += 2;
continue;
}
const segmentEnd = offset + 2 + header.readUInt16BE(offset + 2);
if (segmentEnd < offset + 4) {
return undefined;
}
if (isJpegStartOfFrame(marker)) {
return segmentEnd <= header.length
? readJpegFrameSize(header.subarray(offset + 4, segmentEnd))
: undefined;
}
offset = segmentEnd;
}
return undefined;
}
function readJpegFrameSize(frame: Buffer): ImageSize | undefined {
if (frame.length < 6 || frame[5] === 0 || frame.length !== 6 + 3 * frame[5]) {
return undefined;
}
return { width: frame.readUInt16BE(3), height: frame.readUInt16BE(1) };
}
function isJpegStartOfFrame(marker: number): boolean {
return marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
}
@@ -1,3 +1,4 @@
import { formatBytes } from '@expo/eas-build-job';
import type { bunyan } from '@expo/logger';
import { type FSWatcher, createReadStream, watch } from 'node:fs';
import { mkdtemp, readFile, readdir, rm, rmdir, stat } from 'node:fs/promises';
@@ -7,8 +8,8 @@ import { z } from 'zod';
import type { CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import { formatBytes } from '../../utils/artifacts';
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
import { createScreenshotMetadataAsync } from './deviceRunSessionScreenshotMetadata';
import {
type ScreenshotSession,
loadScreenshotSessionAsync,
@@ -93,7 +94,10 @@ async function uploadScreenshotAsync(
artifactId: name.slice(0, -4),
name: artifactDetails.name,
filename: artifactDetails.filename,
metadata: artifactDetails.metadata,
metadata: {
...artifactDetails.metadata,
...(await createScreenshotMetadataAsync(file, { source: 'manual', logger })),
},
kind: 'screenshot',
size,
openStream: () => createReadStream(file),
@@ -126,13 +126,14 @@ export class DeviceRunSessionWorkerApi {
}
public async createPreviewUploadSessionAsync(
input: { metadata?: Record<string, unknown> } = {},
options: WorkerApiRequestOptions = {}
): Promise<DeviceRunSessionUploadSession> {
const { uploadSession } = await this.client.requestAsync(
'POST',
'preview-upload-session',
UploadSessionSchema,
options
input.metadata ? { ...options, json: { metadata: input.metadata } } : options
);
return uploadSession;
}
@@ -54,6 +54,7 @@ import {
isPreinstalledExpoGoAsync,
readIosApplicationIdentifierAsync,
} from './iosAppArtifact';
import { createServeSimPreviewCapture } from './serveSimPreviewCapture';
import { type ServeSimActivityPolling, startServeSimActivityPolling } from './serveSimActivity';
import { verifyLocalEgressGuardAsync } from './localEgressGuard';
import { type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
@@ -72,6 +73,10 @@ 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 downloads the scrcpy server on first start. This copy keeps Android sessions
// working when the GitHub release download fails; expo-device-hub falls back to GitHub when the
// copy is missing.
const SCRCPY_SERVER_MIRROR_BASE_URL = 'https://storage.googleapis.com/turtle-v2/scrcpy';
// 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,
@@ -605,6 +610,7 @@ async function startDeviceSessionHostInternalAsync(
const previewRetirementErrors: unknown[] = [];
let finishTask: Promise<void> | null = null;
let hostReady = false;
let iosPreviewCapture: ReturnType<typeof createServeSimPreviewCapture> | undefined;
let sessionPreview: ReturnType<typeof startDeviceRunSessionPreview> | null = null;
const previewSetupController = new AbortController();
let previewSetupTask: Promise<void> | undefined;
@@ -771,6 +777,12 @@ async function startDeviceSessionHostInternalAsync(
...(recording ? { EXPO_DEVICE_HUB_RECORDING_CONTROL_TOKEN: recording.controlToken } : {}),
// The Hub must write its token in the directory read by this host.
...(isAndroid ? { EXPO_DEVICE_HUB_STATE_DIR } : {}),
...(isAndroid
? {
SERVE_EMU_SCRCPY_BASE_URL:
env.SERVE_EMU_SCRCPY_BASE_URL || SCRCPY_SERVER_MIRROR_BASE_URL,
}
: {}),
},
logger,
};
@@ -827,6 +839,7 @@ async function startDeviceSessionHostInternalAsync(
await sessionPreview?.stopAsync();
await previewActivity?.stopAsync();
},
cleanupPreviewCaptureAsync: async () => iosPreviewCapture?.cleanupAfterHostStoppedAsync(),
previewServer,
screenshots,
serverName,
@@ -992,6 +1005,18 @@ async function startDeviceSessionHostInternalAsync(
// readiness on macOS, so it starts after its encoder is installed there.
previewSetupTask = (async () => {
if (!isAndroid) {
iosPreviewCapture = createServeSimPreviewCapture({
port,
token: previewToken!,
device,
tmpdir: serveSimTmpdir,
});
if (!(await iosPreviewCapture.isSupportedAsync(previewSetupController.signal))) {
logger.warn(
'This serve-sim version does not support automated screenshots; skipping session previews.'
);
return;
}
await ensureMacosPreviewEncoderInstalledAsync({
env,
logger,
@@ -1005,6 +1030,7 @@ async function startDeviceSessionHostInternalAsync(
logger,
captureAsync: signal =>
captureDeviceRunSessionPreviewAsync({
captureIosScreenshotAsync: iosPreviewCapture?.captureAsync,
runtimePlatform,
device: previewDevice ?? device,
env,
@@ -1080,6 +1106,7 @@ async function finishDeviceSessionHostAsync(
{
previewRetirement,
stopSessionPreviewAsync,
cleanupPreviewCaptureAsync,
previewServer,
screenshots,
serverName,
@@ -1091,6 +1118,7 @@ async function finishDeviceSessionHostAsync(
}: {
previewRetirement: Promise<boolean>;
stopSessionPreviewAsync: () => Promise<void>;
cleanupPreviewCaptureAsync: () => Promise<void>;
previewServer: DetachedProcessHandle;
screenshots: { finishAsync(hostStopped: boolean): Promise<void> };
serverName: string;
@@ -1124,6 +1152,13 @@ async function finishDeviceSessionHostAsync(
} catch (err) {
logger.warn({ err }, `Could not stop the ${serverName} session host.`);
}
if (hostStopped) {
try {
await cleanupPreviewCaptureAsync();
} catch (err) {
logger.warn({ err }, 'Could not remove session preview capture files.');
}
}
const previewRetired = await previewRetirement;
onStopped(hostStopped && !hostExited && previewRetired);
await screenshots.finishAsync(hostStopped);
@@ -30,13 +30,20 @@ export async function stageServeSimAppAsync(
}
}
export async function runServeSimActionAsync({
export async function runServeSimActionAsync(
options: Parameters<typeof runServeSimActionWithResultAsync>[0]
): Promise<void> {
await runServeSimActionWithResultAsync(options);
}
export async function runServeSimActionWithResultAsync({
port,
token,
action,
params,
timeoutMs,
signal,
drainAfterDispatch = false,
}: {
port: number;
token: string;
@@ -44,13 +51,15 @@ export async function runServeSimActionAsync({
params: Record<string, unknown>;
timeoutMs: number;
signal?: AbortSignal;
}): Promise<void> {
/** Abort before dispatch; once sent, wait for its reply or deadline because exec-ws cannot cancel it. */
drainAfterDispatch?: boolean;
}): Promise<string> {
signal?.throwIfAborted();
await new Promise<void>((resolve, reject) => {
return await new Promise<string>((resolve, reject) => {
const socket = new WebSocket(`ws://127.0.0.1:${port}/exec-ws`, [`serve-sim.token.${token}`]);
let settled = false;
let sent = false;
const finish = (error?: unknown) => {
const finish = (error?: unknown, stdout = '') => {
if (settled) {
return;
}
@@ -61,10 +70,14 @@ export async function runServeSimActionAsync({
if (error) {
reject(error);
} else {
resolve();
resolve(stdout);
}
};
const aborted = () => {
if (!sent || !drainAfterDispatch) {
finish(signal?.reason ?? new Error('Serve-sim action aborted.'));
}
};
const aborted = () => finish(signal?.reason ?? new Error('Serve-sim action aborted.'));
const timer = setTimeout(
() => finish(new SystemError(`serve-sim ${action} timed out.`)),
timeoutMs
@@ -75,6 +88,9 @@ export async function runServeSimActionAsync({
finish(new SystemError(`serve-sim ${action} connection closed before completion.`))
);
socket.on('message', data => {
if (settled) {
return;
}
let reply;
try {
reply = JSON.parse(data.toString());
@@ -90,7 +106,8 @@ export async function runServeSimActionAsync({
? undefined
: new SystemError(
`serve-sim ${action} failed: ${(typeof reply.error === 'string' && reply.error) || (typeof reply.stderr === 'string' && reply.stderr) || 'invalid response'}`
)
),
typeof reply.stdout === 'string' ? reply.stdout : ''
);
}
});
@@ -1,10 +1,10 @@
import { formatBytes } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import { formatBytes } from '../../utils/artifacts';
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
const METRICS_ARTIFACT_FILENAME = 'metrics.ndjson';
@@ -0,0 +1,74 @@
import { randomUUID } from 'node:crypto';
import { copyFile, rm } from 'node:fs/promises';
import path from 'node:path';
import { runServeSimActionAsync, runServeSimActionWithResultAsync } from './serveSimActions';
export function createServeSimPreviewCapture({
port,
token,
device,
tmpdir,
}: {
port: number;
token: string;
device: string;
tmpdir: string;
}) {
const ownedFiles = new Set<string>();
return {
async isSupportedAsync(signal: AbortSignal): Promise<boolean> {
const result = await runServeSimActionWithResultAsync({
port,
token,
action: 'actions.describe',
params: {},
timeoutMs: 30_000,
signal,
});
const catalog: {
'screenshot.capture'?: { properties?: { automation?: { type?: unknown } } };
} | null = JSON.parse(result);
return catalog?.['screenshot.capture']?.properties?.automation?.type === 'boolean';
},
async captureAsync(destination: string, signal: AbortSignal): Promise<void> {
signal.throwIfAborted();
const fileName = `serve-sim-screenshot-preview-${randomUUID()}.png`;
const staged = path.join(tmpdir, 'serve-sim-screenshots', fileName);
ownedFiles.add(staged);
// Closing exec-ws does not cancel its host action. Drain a sent capture before honoring
// cancellation; on timeout/disconnect, leave its file alone until the host has stopped.
await runServeSimActionAsync({
port,
token,
action: 'screenshot.capture',
params: { udid: device, fileName, automation: true },
timeoutMs: 30_000,
signal,
drainAfterDispatch: true,
});
try {
signal.throwIfAborted();
await copyFile(staged, destination);
} finally {
await rm(staged, { force: true });
ownedFiles.delete(staged);
}
},
/** Call only after the host process group has stopped, including timed-out actions. */
async cleanupAfterHostStoppedAsync(): Promise<void> {
const results = await Promise.allSettled(
[...ownedFiles].map(async file => {
await rm(file, { force: true });
ownedFiles.delete(file);
})
);
const errors = results
.filter(result => result.status === 'rejected')
.map(result => result.reason);
if (errors.length) {
throw new AggregateError(errors, 'Could not remove preview captures.');
}
},
};
}
@@ -1,7 +1,10 @@
import { SystemError } from '@expo/eas-build-job';
import downloadFile from '@expo/downloader';
import spawn from '@expo/turtle-spawn';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
@@ -164,10 +167,14 @@ describe(startUptermHostAsync, () => {
};
}
function makeCtx(env: Record<string, string> = {}): BuildContext {
function makeCtx(
env: Record<string, string> = {},
buildDirectory = '/workingdir/build'
): BuildContext {
return {
logger: createMockLogger(),
env,
buildDirectory,
} as unknown as BuildContext;
}
@@ -227,6 +234,33 @@ describe(startUptermHostAsync, () => {
);
});
it('starts each client shell in the build directory', async () => {
const realFs = jest.requireActual<typeof import('node:fs')>('node:fs');
const tmpDir = realFs.mkdtempSync(path.join(os.tmpdir(), 'eas-upterm-join-'));
try {
const buildDirectory = path.join(tmpDir, "it's $HOME `pwd`", 'build');
realFs.mkdirSync(buildDirectory, { recursive: true });
await startUptermHostAsync(makeCtx({}, buildDirectory), { relayServerUrl: 'wss://r' });
const joinScriptCall = mockedFs.writeFile.mock.calls.find(
([file]) => file === '/tmp/eas-ssh-1/join.sh'
);
const joinScriptPath = path.join(tmpDir, 'join.sh');
realFs.writeFileSync(joinScriptPath, String(joinScriptCall?.[1]));
const result = spawnSync('bash', [joinScriptPath], {
cwd: tmpDir,
env: { PATH: process.env.PATH, HOME: tmpDir },
input: 'pwd\n',
encoding: 'utf8',
});
expect(result.stdout.trim()).toBe(buildDirectory);
} finally {
realFs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('downloads from GCS into a fresh temp dir when upterm is not on PATH', async () => {
uptermOnPath = false;
+1 -32
View File
@@ -1,4 +1,4 @@
import { BuildJob, Job, ManagedArtifactType } from '@expo/eas-build-job';
import { BuildJob, Job, ManagedArtifactType, formatBytes } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import fg from 'fast-glob';
import fs from 'fs-extra';
@@ -175,34 +175,3 @@ async function getArtifactSize(artifact: string): Promise<number | undefined> {
return undefined;
}
}
// same as in
// https://github.com/expo/eas-cli/blob/f0e3b648a1634266e7d723bd49a84866ab9b5801/packages/eas-cli/src/utils/files.ts#L33-L60
export function formatBytes(bytes: number): string {
if (bytes === 0) {
return `0`;
}
let multiplier = 1;
if (bytes < 1024 * multiplier) {
return `${Math.floor(bytes)} B`;
}
multiplier *= 1024;
if (bytes < 102.4 * multiplier) {
return `${(bytes / multiplier).toFixed(1)} KB`;
}
if (bytes < 1024 * multiplier) {
return `${Math.floor(bytes / 1024)} KB`;
}
multiplier *= 1024;
if (bytes < 102.4 * multiplier) {
return `${(bytes / multiplier).toFixed(1)} MB`;
}
if (bytes < 1024 * multiplier) {
return `${Math.floor(bytes / multiplier)} MB`;
}
multiplier *= 1024;
if (bytes < 102.4 * multiplier) {
return `${(bytes / multiplier).toFixed(1)} GB`;
}
return `${Math.floor(bytes / 1024)} GB`;
}
+6 -1
View File
@@ -202,7 +202,12 @@ export async function startUptermHostAsync(
await spawn('ssh-keygen', ['-t', 'ed25519', '-N', '', '-f', hostKeyPath, '-q'], {
logger: ctx.logger,
});
await fs.writeFile(forceCommandPath, '#!/usr/bin/env bash\nexec bash -l\n', { mode: 0o755 });
const quotedBuildDirectory = `'${ctx.buildDirectory.replaceAll("'", "'\\''")}'`;
await fs.writeFile(
forceCommandPath,
`#!/usr/bin/env bash\ncd -- ${quotedBuildDirectory}\nexec bash -l\n`,
{ mode: 0o755 }
);
let currentProcess: SpawnPromise<SpawnResult> | null = null;
@@ -1,5 +1,21 @@
# create-eas-build-function
## 27.9.0
No changes in this release.
## 27.8.0
No changes in this release.
## 27.7.2
No changes in this release.
## 27.7.1
No changes in this release.
## 27.7.0
No changes in this release.
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "27.7.0",
"version": "27.9.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "27.7.0"
"@expo/steps": "27.9.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+16
View File
@@ -1,5 +1,21 @@
# @expo/downloader
## 27.9.0
No changes in this release.
## 27.8.0
No changes in this release.
## 27.7.2
No changes in this release.
## 27.7.1
No changes in this release.
## 27.7.0
No changes in this release.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/downloader",
"version": "27.7.0",
"version": "27.9.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -1,5 +1,21 @@
# eas-build-cache-provider
## 27.9.0
No changes in this release.
## 27.8.0
No changes in this release.
## 27.7.2
No changes in this release.
## 27.7.1
No changes in this release.
## 27.7.0
No changes in this release.
@@ -1,6 +1,6 @@
{
"name": "eas-build-cache-provider",
"version": "27.7.0",
"version": "27.9.0",
"description": "A build cache provider plugin for the Expo CLI",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
+20
View File
@@ -1,5 +1,25 @@
# @expo/eas-build-job
## 27.9.0
No changes in this release.
## 27.8.0
### Patch Changes
- Fix sizes of 102.4 GB and larger being printed with a value about a million times too large, and share one `formatBytes` helper between `eas-cli` and `build-tools`. ([#4854](https://github.com/expo/eas-cli/pull/4854) by [@vonovak](https://github.com/vonovak))
## 27.7.2
No changes in this release.
## 27.7.1
### Patch Changes
- Update `node-forge`, `tar`, `nanoid`, `minimatch`, `joi`, `ajv`, `yaml`, and `zod` to versions with fixes for published security advisories. ([#4847](https://github.com/expo/eas-cli/pull/4847) by [@tchayen](https://github.com/tchayen))
## 27.7.0
No changes in this release.
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "27.7.0",
"version": "27.9.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -25,12 +25,12 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "27.7.0",
"@expo/logger": "27.9.0",
"@expo/results": "1.0.0",
"@expo/turtle-spawn": "27.7.0",
"joi": "^17.13.1",
"@expo/turtle-spawn": "27.9.0",
"joi": "^17.13.9",
"semver": "^7.6.2",
"zod": "^4.3.5"
"zod": "^4.6.7"
},
"devDependencies": {
"@types/jest": "^29.5.12",
@@ -0,0 +1,31 @@
import { formatBytes } from '../formatBytes';
describe(formatBytes, () => {
const KB = 1024;
const MB = 1024 * KB;
const GB = 1024 * MB;
it.each([
[0, '0'],
[1, '1 B'],
[512.7, '512 B'],
[1023, '1023 B'],
[KB, '1.0 KB'],
[1.5 * KB, '1.5 KB'],
[102.4 * KB, '102 KB'],
[500 * KB, '500 KB'],
[MB - 1, '1023 KB'],
[MB, '1.0 MB'],
[20 * MB, '20.0 MB'],
[102.4 * MB, '102 MB'],
[500 * MB, '500 MB'],
[GB - 1, '1023 MB'],
[GB, '1.0 GB'],
[1.5 * GB, '1.5 GB'],
[102.4 * GB, '102 GB'],
[200 * GB, '200 GB'],
[2048 * GB, '2048 GB'],
])('formats %d bytes as %s', (bytes, expected) => {
expect(formatBytes(bytes)).toBe(expected);
});
});
+28
View File
@@ -0,0 +1,28 @@
export function formatBytes(bytes: number): string {
if (bytes === 0) {
return `0`;
}
let multiplier = 1;
if (bytes < 1024 * multiplier) {
return `${Math.floor(bytes)} B`;
}
multiplier *= 1024;
if (bytes < 102.4 * multiplier) {
return `${(bytes / multiplier).toFixed(1)} KB`;
}
if (bytes < 1024 * multiplier) {
return `${Math.floor(bytes / 1024)} KB`;
}
multiplier *= 1024;
if (bytes < 102.4 * multiplier) {
return `${(bytes / multiplier).toFixed(1)} MB`;
}
if (bytes < 1024 * multiplier) {
return `${Math.floor(bytes / multiplier)} MB`;
}
multiplier *= 1024;
if (bytes < 102.4 * multiplier) {
return `${(bytes / multiplier).toFixed(1)} GB`;
}
return `${Math.floor(bytes / multiplier)} GB`;
}
+1
View File
@@ -35,6 +35,7 @@ export * from './submission-config';
export * from './projectPackage';
export * from './deviceRunSession';
export * from './sandbox';
export { formatBytes } from './formatBytes';
const version = require('../package.json').version;
export { version };
+36
View File
@@ -1,5 +1,41 @@
# eas-cli
## 27.9.0
### Minor Changes
- Warn about Expo Simulators and EAS Workflows outages from status.expo.dev before `eas simulator:start` creates a session, and describe degraded performance and maintenance correctly in all outage warnings. ([#4873](https://github.com/expo/eas-cli/pull/4873) by [@szdziedzic](https://github.com/szdziedzic))
## 27.8.0
### Minor Changes
- Tell accounts without EAS Simulator access that it is available on the Starter, Production, and Enterprise plans and link to the billing page, instead of pointing at the waitlist. `simulator:availability --json` now returns `billingUrl` instead of `waitlistUrl`. ([#4866](https://github.com/expo/eas-cli/pull/4866) by [@szdziedzic](https://github.com/szdziedzic))
### Patch Changes
- Remove the "Android emulator support is still in development" warning from `eas simulator`. ([#4870](https://github.com/expo/eas-cli/pull/4870) by [@szdziedzic](https://github.com/szdziedzic))
- Fix sizes of 102.4 GB and larger being printed with a value about a million times too large, and share one `formatBytes` helper between `eas-cli` and `build-tools`. ([#4854](https://github.com/expo/eas-cli/pull/4854) by [@vonovak](https://github.com/vonovak))
- Package local Simulator app bundles for upload when the host supports gzip tar archives, and reject unsupported IPA and ZIP installs before uploading. ([#4684](https://github.com/expo/eas-cli/pull/4684) by [@gwdp](https://github.com/gwdp))
Fix packaging stalls for app bundles containing hard links and clean temporary archives when interrupted. Local app directories and `.tar.gz`/`.tgz` files require a host advertising archive support.
- Remove the `[EXPERIMENTAL]` label from `eas simulator` commands, except `eas simulator:egress` and the `--egress` flag. ([#4869](https://github.com/expo/eas-cli/pull/4869) by [@szdziedzic](https://github.com/szdziedzic))
## 27.7.2
### Patch Changes
- Add `--log-source` to `eas simulator` to select which Android logs a session records. ([#4823](https://github.com/expo/eas-cli/pull/4823) by [@vonovak](https://github.com/vonovak))
- Show that the ssh session has ended, instead of saying SSH was not enabled, when `eas workflow:ssh` is given the ID of a finished build. ([#4842](https://github.com/expo/eas-cli/pull/4842) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- Show the ID argument and examples in `eas workflow:ssh --help`. ([#4829](https://github.com/expo/eas-cli/pull/4829) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
## 27.7.1
### Patch Changes
- Update `node-forge`, `tar`, `nanoid`, `minimatch`, `joi`, `ajv`, `yaml`, and `zod` to versions with fixes for published security advisories. ([#4847](https://github.com/expo/eas-cli/pull/4847) by [@tchayen](https://github.com/tchayen))
## 27.7.0
### Minor Changes
+252 -226
View File
File diff suppressed because it is too large. Load diff
+26
View File
@@ -47534,6 +47534,26 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "logSources",
"description": "Logs to record during the session and upload afterward: emulator-output and device-logcat. Defaults to\n[\"emulator-output\"]. An empty list disables recording and uploading both sources. Device-wide logcat can contain\ncredentials and personal data; include device-logcat only when you want to retain it.\nLogs are not redacted and use the existing 90-day artifact retention policy.",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "systemImagePackage",
"description": "Android SDK system image package to use for the emulator (e.g.\n\"system-images;android-35;google_apis;x86_64\").",
@@ -78767,6 +78787,12 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "EAS_SIMULATOR",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "EAS_SUBMIT",
"description": null,
+13 -13
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "27.7.0",
"version": "27.9.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -48,12 +48,12 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "27.7.0",
"@expo/eas-json": "27.7.0",
"@expo/eas-build-job": "27.9.0",
"@expo/eas-json": "27.9.0",
"@expo/env": "^1.0.0",
"@expo/experimentation": "0.0.5",
"@expo/json-file": "8.3.3",
"@expo/logger": "27.7.0",
"@expo/logger": "27.9.0",
"@expo/multipart-body-parser": "2.0.0",
"@expo/osascript": "2.1.4",
"@expo/package-manager": "1.9.10",
@@ -65,7 +65,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "27.7.0",
"@expo/steps": "27.9.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -74,7 +74,7 @@
"@urql/core": "4.0.11",
"@urql/exchange-retry": "1.2.0",
"agent-cli-detector": "0.1.7",
"ajv": "8.11.0",
"ajv": "8.20.0",
"ajv-formats": "2.1.1",
"better-opn": "3.0.2",
"bplist-parser": "^0.3.0",
@@ -101,15 +101,15 @@
"indent-string": "4.0.0",
"invariant": "^2.2.2",
"jks-js": "1.1.0",
"joi": "17.11.0",
"joi": "17.13.9",
"keychain": "1.5.0",
"log-symbols": "4.1.0",
"mime": "3.0.0",
"minimatch": "5.1.2",
"minimatch": "5.1.9",
"minizlib": "3.1.0",
"nanoid": "3.3.8",
"nanoid": "3.3.20",
"node-fetch": "2.6.7",
"node-forge": "1.4.0",
"node-forge": "1.4.3",
"node-stream-zip": "1.15.0",
"nullthrows": "1.1.1",
"open": "8.4.0",
@@ -126,7 +126,7 @@
"semver": "7.5.4",
"set-interval-async": "3.0.3",
"slash": "3.0.0",
"tar": "7.5.19",
"tar": "7.5.22",
"tar-stream": "3.1.7",
"terminal-link": "2.1.1",
"ts-deepmerge": "6.2.0",
@@ -136,8 +136,8 @@
"uuid": "11.1.1",
"wrap-ansi": "7.0.0",
"ws": "8.21.1",
"yaml": "2.6.0",
"zod": "^4.1.3"
"yaml": "2.9.1",
"zod": "^4.6.7"
},
"devDependencies": {
"@graphql-codegen/cli": "6.3.1",
+1 -8
View File
@@ -1,10 +1,4 @@
import {
ArchiveSource,
ArchiveSourceType,
BuildJob,
Metadata,
Platform,
} from '@expo/eas-build-job';
import { ArchiveSource, ArchiveSourceType, BuildJob, Metadata, Platform, formatBytes } from '@expo/eas-build-job';
import { CredentialsSource } from '@expo/eas-json';
import assert from 'assert';
import chalk from 'chalk';
@@ -86,7 +80,6 @@ import { maybeUploadFingerprintAsync } from '../project/maybeUploadFingerprintAs
import { resolveRuntimeVersionAsync } from '../project/resolveRuntimeVersionAsync';
import { uploadFileAtPathToGCSAsync } from '../uploads';
import { createRealtimeLogsClient } from '../utils/centrifuge';
import { formatBytes } from '../utils/files';
import { printJsonOnlyOutput } from '../utils/json';
import { createProgressTracker } from '../utils/progress';
import { sleepAsync } from '../utils/promise';
@@ -1,3 +1,4 @@
import { formatBytes } from '@expo/eas-build-job';
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
@@ -7,7 +8,6 @@ import { v4 as uuidv4 } from 'uuid';
import Log, { learnMore } from '../../log';
import { ora } from '../../ora';
import { confirmAsync, promptAsync } from '../../prompts';
import { formatBytes } from '../../utils/files';
import { getTmpDirectory } from '../../utils/paths';
import { endTimer, formatMilliseconds, startTimer } from '../../utils/timer';
import { Client } from '../../vcs/vcs';
@@ -1,4 +1,4 @@
import { Platform } from '@expo/eas-build-job';
import { Platform, formatBytes } from '@expo/eas-build-job';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import fs from 'fs-extra';
@@ -20,7 +20,6 @@ import {
downloadAndMaybeExtractAppAsync,
downloadFileWithProgressTrackerAsync,
} from '../../utils/download';
import { formatBytes } from '../../utils/files';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { getEasBuildRunCacheDirectoryPath } from '../../utils/paths';
@@ -3,7 +3,6 @@ import { Config } from '@oclif/core';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { DeviceRunSessionAvailabilityQuery } from '../../../graphql/queries/DeviceRunSessionAvailabilityQuery';
import Log from '../../../log';
import { EAS_SIMULATOR_WAITLIST_URL } from '../../../simulator/utils';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import SimulatorAvailability from '../availability';
@@ -79,7 +78,7 @@ describe(SimulatorAvailability, () => {
});
});
it('emits JSON with available false and the waitlist URL when not enabled', async () => {
it('emits JSON with available false and the billing URL when not enabled', async () => {
mockByAppIdAsync.mockResolvedValue({ accountName: 'testuser', available: false });
const command = createCommand(['--json']);
@@ -88,11 +87,11 @@ describe(SimulatorAvailability, () => {
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
available: false,
accountName: 'testuser',
waitlistUrl: EAS_SIMULATOR_WAITLIST_URL,
billingUrl: 'https://expo.dev/accounts/testuser/settings/billing',
});
});
it('omits the waitlist URL from JSON when enabled', async () => {
it('omits the billing URL from JSON when enabled', async () => {
mockByAppIdAsync.mockResolvedValue({ accountName: 'testuser', available: true });
const command = createCommand(['--json']);
@@ -117,7 +116,7 @@ describe(SimulatorAvailability, () => {
});
});
it('logs a graceful message pointing at the waitlist when not enabled', async () => {
it('logs a message pointing at the paid plans when not enabled', async () => {
mockByAppIdAsync.mockResolvedValue({ accountName: 'testuser', available: false });
const command = createCommand([]);
@@ -125,8 +124,9 @@ describe(SimulatorAvailability, () => {
expect(mockByAppIdAsync).toHaveBeenCalledWith(graphqlClient, projectId);
expect(mockLog).toHaveBeenCalledWith(
"EAS Simulator isn't available on testuser yet — it's coming soon.\n" +
`Join the waitlist to get access: ${EAS_SIMULATOR_WAITLIST_URL}`
"EAS Simulator isn't enabled for testuser. It's available on the Starter, Production, and Enterprise plans.\n" +
'Upgrade, or update billing if a payment is past due: https://expo.dev/accounts/testuser/settings/billing\n' +
'If your account is on an annual Enterprise plan, contact your Expo account executive.'
);
expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled();
});
@@ -2,6 +2,7 @@ import { Config } from '@oclif/core';
import * as fs from 'fs-extra';
import os from 'node:os';
import path from 'node:path';
import * as tar from 'tar';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import * as fetchModule from '../../../fetch';
@@ -31,6 +32,10 @@ import {
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import SimulatorControl from '../control';
jest.mock('tar', () => {
const actual = jest.requireActual<typeof import('tar')>('tar');
return { ...actual, create: jest.fn(actual.create) };
});
jest.mock('../../../graphql/queries/DeviceRunSessionQuery');
jest.mock('../../../log');
jest.mock('../../../simulator/controlConnection', () => ({
@@ -190,7 +195,7 @@ describe(SimulatorControl, () => {
return requestAsync.mock.calls.filter(([action]) => action !== 'actions.describe');
}
function reuseConnection(): BorrowedControlConnection {
function reuseConnection(actionCatalog = catalog): BorrowedControlConnection {
const borrowed: BorrowedControlConnection = {
connection: {
requestAsync,
@@ -198,7 +203,7 @@ describe(SimulatorControl, () => {
udid: 'simulator-udid',
signal: connectionController.signal,
},
catalog,
catalog: actionCatalog,
info: {
version: 3,
pid: 123,
@@ -298,7 +303,8 @@ describe(SimulatorControl, () => {
}
it('shows the local file being uploaded during an import', async () => {
const localFile = path.join(tmpDir, 'MyApp.ipa');
served = { ...catalog, 'app.install': { ...catalog['app.install'], uploadArchive: 'tar.gz' } };
const localFile = path.join(tmpDir, 'MyApp.tgz');
await fs.writeFile(localFile, 'app bytes');
const filePath = await writeImportAsync([
{ action: 'app.install', params: {}, file: localFile },
@@ -661,13 +667,184 @@ describe(SimulatorControl, () => {
expect(fetchHostActionDeviceAsync).not.toHaveBeenCalled();
});
it.each([false, true])(
'packages a local app bundle and cleans it after failure=%s',
async fails => {
const bundle = path.join(tmpDir, 'My App.app');
await fs.mkdir(bundle);
await fs.writeFile(path.join(bundle, 'Info.plist'), 'app plist');
served = {
...catalog,
'app.install': { ...catalog['app.install'], uploadArchive: 'tar.gz' },
};
const directories = await fs.readdir(os.tmpdir());
actionResult = fails ? { stdout: '', stderr: 'install rejected', exitCode: 1 } : ok('');
const command = createCommand([
'app.install',
`${bundle}/`,
'--id',
'session-123',
]).runAsync();
if (fails) {
await expect(command).rejects.toThrow('install rejected');
} else {
await command;
}
const requests = actionRequests();
expect(requests.map(([action]) => action)).toEqual([
'upload.append',
'app.install',
'upload.remove',
]);
const data = Buffer.concat(
requests
.filter(([action]) => action === 'upload.append')
.map(([, params]) => Buffer.from(String(params?.data), 'base64'))
);
const file = path.join(tmpDir, 'received.tgz');
await fs.writeFile(file, data);
const entries: string[] = [];
await tar.list({
file,
onReadEntry: entry => {
entries.push(entry.path);
},
});
expect(entries).toContain('My App.app/Info.plist');
const newDirectories = (await fs.readdir(os.tmpdir())).filter(
name => name.startsWith('eas-simulator-app-') && !directories.includes(name)
);
expect(newDirectories).toEqual([]);
}
);
it('packages the contents of an app bundle reached through a local symlink', async () => {
const actual = path.join(tmpDir, 'Actual');
const link = path.join(tmpDir, 'Linked.app');
await fs.mkdir(actual);
await fs.writeFile(path.join(actual, 'Info.plist'), 'app plist');
await fs.symlink(actual, link);
served = { ...catalog, 'app.install': { ...catalog['app.install'], uploadArchive: 'tar.gz' } };
await createCommand(['app.install', link, '--id', 'session-123']).runAsync();
const data = Buffer.concat(
actionRequests()
.filter(([action]) => action === 'upload.append')
.map(([, params]) => Buffer.from(String(params?.data), 'base64'))
);
const file = path.join(tmpDir, 'received.tgz');
await fs.writeFile(file, data);
const entries: string[] = [];
await tar.list({
file,
onReadEntry: entry => {
entries.push(entry.path);
},
});
expect(entries).toContain('Linked.app/Info.plist');
});
it('cleans a packaged bundle when uploading fails', async () => {
const bundle = path.join(tmpDir, 'Upload.app');
await fs.mkdir(bundle);
await fs.writeFile(path.join(bundle, 'Info.plist'), 'app plist');
served = { ...catalog, 'app.install': { ...catalog['app.install'], uploadArchive: 'tar.gz' } };
const before = await fs.readdir(os.tmpdir());
requestAsync
.mockResolvedValueOnce(ok(JSON.stringify(served)))
.mockResolvedValueOnce({ stdout: '', stderr: 'upload rejected', exitCode: 1 });
await expect(
createCommand(['app.install', bundle, '--id', 'session-123']).runAsync()
).rejects.toThrow('upload rejected');
expect(actionRequests().map(([action]) => action)).toEqual(['upload.append', 'upload.remove']);
expect(
(await fs.readdir(os.tmpdir())).filter(
name => name.startsWith('eas-simulator-app-') && !before.includes(name)
)
).toEqual([]);
});
it('removes the temporary archive directory when packaging fails', async () => {
const bundle = path.join(tmpDir, 'Broken.app');
await fs.mkdir(bundle);
served = { ...catalog, 'app.install': { ...catalog['app.install'], uploadArchive: 'tar.gz' } };
const before = await fs.readdir(os.tmpdir());
const packaging = jest.mocked(tar.create).mockRejectedValueOnce(new Error('packaging failed'));
try {
await expect(
createCommand(['app.install', bundle, '--id', 'session-123']).runAsync()
).rejects.toThrow('packaging failed');
expect(actionRequests()).toEqual([]);
expect(
(await fs.readdir(os.tmpdir())).filter(
name => name.startsWith('eas-simulator-app-') && !before.includes(name)
)
).toEqual([]);
} finally {
packaging
.mockReset()
.mockImplementation(jest.requireActual<typeof import('tar')>('tar').create);
}
});
it('refuses bundle upload to a host that does not advertise app archives', async () => {
const bundle = path.join(tmpDir, 'My.app');
await fs.mkdir(bundle);
await expect(
createCommand(['app.install', bundle, '--id', 'session-123']).runAsync()
).rejects.toThrow('does not support app archives');
expect(actionRequests()).toEqual([]);
});
it.each(['Old.tar.gz', 'Old.tgz'])(
'rejects an archive before upload when the host lacks support: %s',
async name => {
const archive = path.join(tmpDir, name);
await fs.writeFile(archive, 'archive bytes');
await expect(
createCommand(['app.install', archive, '--id', 'session-123']).runAsync()
).rejects.toThrow('does not support app archives');
expect(actionRequests()).toEqual([]);
}
);
it('continues rejecting unrelated folders', async () => {
await expect(
createCommand(['media.add', tmpDir, '--id', 'session-123']).runAsync()
).rejects.toThrow(`${tmpDir} is a folder. Pass the path of a file.`);
expect(actionRequests()).toEqual([]);
});
it('uploads a local file for an action that takes one and removes it after', async () => {
served = { ...catalog, 'app.install': { ...catalog['app.install'], uploadArchive: 'tar.gz' } };
const localFile = path.join(tmpDir, 'MyApp.tgz');
await fs.writeFile(localFile, 'app bytes');
actionResult = ok('');
await createCommand(['app.install', localFile, '--id', 'session-123']).runAsync();
const requests = actionRequests();
const uploadId = requests[0][1]?.uploadId;
expect(requests).toEqual([
[
'upload.append',
{ uploadId, data: Buffer.from('app bytes').toString('base64'), first: true },
],
['app.install', { path: `/tmp/serve-sim-uploads/${uploadId}` }],
['upload.remove', { uploadId }],
]);
});
it.each([false, true])(
'uploads a local file for an action that takes one and removes it after (reused: %s)',
async reused => {
served = {
...catalog,
'app.install': { ...catalog['app.install'], uploadArchive: 'tar.gz' },
};
if (reused) {
reuseConnection();
reuseConnection(served);
}
const localFile = path.join(tmpDir, 'MyApp.ipa');
const localFile = path.join(tmpDir, 'MyApp.tgz');
await fs.writeFile(localFile, 'app bytes');
actionResult = ok('');
@@ -686,6 +863,22 @@ describe(SimulatorControl, () => {
}
);
it.each(['Device.ipa', 'Device.IPA', 'Build.zip'])(
'rejects unsupported Simulator install input before uploading: %s',
async name => {
const file = path.join(tmpDir, name);
await fs.writeFile(file, 'unsupported archive');
await expect(
createCommand(['app.install', file, '--id', 'session-123']).runAsync()
).rejects.toThrow(
'Simulator installs require a Simulator .app bundle or .tar.gz/.tgz archive.'
);
expect(actionRequests()).toEqual([]);
expect(close).toHaveBeenCalledTimes(1);
}
);
it('sends the upload ID to an action that asks for it', async () => {
const localFile = path.join(tmpDir, 'session.json');
await fs.writeFile(localFile, '{}');
@@ -17,6 +17,7 @@ import {
DeviceRunSessionStatus,
DeviceRunSessionType,
JobRunStatus,
StatuspageServiceName,
} from '../../../graphql/generated';
import { DeviceRunSessionMutation } from '../../../graphql/mutations/DeviceRunSessionMutation';
import { DeviceRunSessionAvailabilityQuery } from '../../../graphql/queries/DeviceRunSessionAvailabilityQuery';
@@ -37,6 +38,7 @@ import { applyStartConfigAsync } from '../../../simulator/startSetup';
import { ControlConfigExecutionError } from '../../../simulator/controlConfig';
import * as promiseUtils from '../../../utils/promise';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import { maybeWarnAboutEasOutagesAsync } from '../../../utils/statuspageService';
import Simulator from '../index';
jest.mock('fs-extra');
@@ -71,6 +73,7 @@ jest.mock('../../../simulator/env', () => ({
}));
jest.mock('../../../simulator/expoGo');
jest.mock('../../../utils/json');
jest.mock('../../../utils/statuspageService');
jest.mock('../../../utils/promise', () => ({
...jest.requireActual('../../../utils/promise'),
sleepAsync: jest.fn().mockResolvedValue(undefined),
@@ -1040,18 +1043,35 @@ describe(Simulator, () => {
});
});
it('fails with the waitlist link without creating a session when the account is gated', async () => {
it('fails with the upgrade link without creating a session when the account is gated', async () => {
mockAvailabilityByAppIdAsync.mockResolvedValue({ accountName: 'testuser', available: false });
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
await expect(command.runAsync()).rejects.toThrow(
"EAS Simulator isn't available on testuser yet — it's coming soon.\n" +
'Join the waitlist to get access: https://expo.dev/services/simulators'
"EAS Simulator isn't enabled for testuser. It's available on the Starter, Production, and Enterprise plans.\n" +
'Upgrade, or update billing if a payment is past due: https://expo.dev/accounts/testuser/settings/billing\n' +
'If your account is on an annual Enterprise plan, contact your Expo account executive.'
);
expect(mockAvailabilityByAppIdAsync).toHaveBeenCalledWith(graphqlClient, 'project-123');
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
expect(mockPromptAsync).not.toHaveBeenCalled();
expect(maybeWarnAboutEasOutagesAsync).not.toHaveBeenCalled();
});
it('warns about EAS Simulator and EAS Workflows outages before creating the session', async () => {
mockCreateDeviceRunSessionAsync.mockImplementationOnce(async () => {
expect(maybeWarnAboutEasOutagesAsync).toHaveBeenCalledWith(graphqlClient, [
StatuspageServiceName.EasSimulator,
StatuspageServiceName.EasWorkflows,
]);
return makeCreatedDeviceRunSession();
});
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledTimes(1);
});
it('skips the gate check for Expo admins on a gated account', async () => {
@@ -1685,6 +1705,38 @@ describe(Simulator, () => {
);
});
it('accumulates repeated --log-source flags in order', async () => {
const { command } = createCommand([
'--platform',
'android',
'--non-interactive',
'--log-source',
'emulator-output',
'--log-source',
'device-logcat',
]);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ android: { logSources: ['emulator-output', 'device-logcat'] } })
);
});
it('leaves Android log sources omitted unless explicitly requested', async () => {
const { command } = createCommand(['--platform', 'android', '--non-interactive']);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync.mock.calls[0][1].android?.logSources).toBeUndefined();
});
it.each([
{ args: ['--platform', 'ios', '--log-source', 'device-logcat'] },
{ args: ['--platform', 'android', '--log-source', 'unknown'] },
])('rejects invalid Android logging options $args', async ({ args }) => {
const { command } = createCommand([...args, '--non-interactive']);
await expect(command.runAsync()).rejects.toThrow();
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
});
it('passes --network-capture to the createDeviceRunSession mutation', async () => {
const { command } = createCommand([
'--platform',
@@ -2122,19 +2174,6 @@ describe(Simulator, () => {
);
});
it('warns that Android emulator support is still in development before creating a session', async () => {
const { command } = createCommand(['--platform', 'android', '--non-interactive']);
await command.runAsync();
expect(Log.warn).toHaveBeenCalledWith(
'Android emulator support in EAS Simulator is still in development. Some features available on iOS may not work on Android yet. Full parity with iOS is coming soon.'
);
expect(jest.mocked(Log.warn).mock.invocationCallOrder[0]).toBeLessThan(
mockCreateDeviceRunSessionAsync.mock.invocationCallOrder[0]
);
});
it.each([
['ios', AppPlatform.Ios],
['android', AppPlatform.Android],
@@ -3,13 +3,11 @@ import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { getAccountBillingUrl } from '../../build/utils/url';
import { DeviceRunSessionAvailabilityQuery } from '../../graphql/queries/DeviceRunSessionAvailabilityQuery';
import Log from '../../log';
import { ora } from '../../ora';
import {
EAS_SIMULATOR_WAITLIST_URL,
formatSimulatorUnavailableMessage,
} from '../../simulator/utils';
import { formatSimulatorUnavailableMessage } from '../../simulator/utils';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import {
getSimulatorCommandAliases,
@@ -20,7 +18,7 @@ export default class SimulatorAvailability extends EasCommand {
static override aliases = getSimulatorCommandAliases('availability');
static override hiddenAliases = getSimulatorCommandHiddenAliases('availability');
static override description =
'[EXPERIMENTAL] check whether EAS Simulator is enabled for the current project account';
'check whether EAS Simulator is enabled for the current project account';
static override flags = {
...EasNonInteractiveAndJsonFlags,
@@ -65,8 +63,8 @@ export default class SimulatorAvailability extends EasCommand {
printJsonOnlyOutput({
available,
accountName,
// Only present when gated, so scripts and agents can pass the waitlist link on.
...(available ? {} : { waitlistUrl: EAS_SIMULATOR_WAITLIST_URL }),
// Only present when gated, so scripts and agents can pass the upgrade link on.
...(available ? {} : { billingUrl: getAccountBillingUrl(accountName) }),
});
return;
}
@@ -59,7 +59,7 @@ import { formatMilliseconds } from '../../utils/timer';
export default class SimulatorControl extends EasCommand {
static override aliases = getSimulatorCommandAliases('control');
static override hiddenAliases = getSimulatorCommandHiddenAliases('control');
static override description = `[EXPERIMENTAL] control a remote iOS simulator session on EAS
static override description = `control a remote iOS simulator session on EAS
Run without an action to list available actions. Use <action> --help for usage.
@@ -88,7 +88,7 @@ Existing output files are overwritten. When saving to a directory, an existing f
'$ eas simulator:control --import ./simulator-control.json',
'$ eas simulator:control hardwareKeyboard.set off',
'$ eas simulator:control permissions.set grant camera com.example.app',
'$ eas simulator:control app.install ./MyApp.ipa',
'$ eas simulator:control app.install ./MyApp.app',
'$ eas simulator:control screenshot.capture --output ./screenshot.png',
'$ eas simulator:control screenshot.capture --output ./screenshot.png --id <session-id>',
];
@@ -478,7 +478,16 @@ Existing output files are overwritten. When saving to a directory, an existing f
let output: string;
let savedFile: string | null;
try {
({ output, file: savedFile } = await executeControlActionAsync(connection, plan));
({ output, file: savedFile } = await executeControlActionAsync(connection, plan, {
onProgress: phase => {
spinner.text =
phase === 'packaging'
? 'Packaging app'
: phase === 'uploading'
? `Uploading ${plan.localFile!.path}`
: `Running ${action} on simulator session ${session.id}`;
},
}));
spinner.succeed(`Ran ${action} on simulator session ${session.id}`);
} catch (err) {
spinner.fail(`Failed to run ${action} on simulator session ${session.id}`);
@@ -35,8 +35,7 @@ const POST_STOP_REFRESH_COUNT = 2;
export default class SimulatorEvents extends EasCommand {
static override aliases = getSimulatorCommandAliases('events');
static override hiddenAliases = getSimulatorCommandHiddenAliases('events');
static override description =
'[EXPERIMENTAL] show activity events from a remote simulator session';
static override description = 'show activity events from a remote simulator session';
static override flags = {
id: Flags.string({
@@ -10,7 +10,7 @@ import {
export default class SimulatorExec extends EasCommand {
static override aliases = getSimulatorCommandAliases('exec');
static override hiddenAliases = getSimulatorCommandHiddenAliases('exec');
static override description = `[EXPERIMENTAL] execute a simulator command with ${SIMULATOR_DOTENV_FILE_NAME} environment loaded`;
static override description = `execute a simulator command with ${SIMULATOR_DOTENV_FILE_NAME} environment loaded`;
static override strict = false;
static override contextDefinition = {
@@ -1,3 +1,4 @@
import { formatBytes } from '@expo/eas-build-job';
import { Flags } from '@oclif/core';
import { getDeviceRunSessionPreviewUrl, getDeviceRunSessionUrl } from '../../build/utils/url';
@@ -29,7 +30,6 @@ import {
formatRemoteSessionInstructions,
sanitizeRemoteConfigForJson,
} from '../../simulator/utils';
import { formatBytes } from '../../utils/files';
import formatFields, { FormatFieldsItem } from '../../utils/formatFields';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import {
@@ -44,7 +44,7 @@ export default class SimulatorGet extends EasCommand {
static override aliases = getSimulatorCommandAliases('get');
static override hiddenAliases = getSimulatorCommandHiddenAliases('get');
static override description =
"[EXPERIMENTAL] get info about a remote simulator session on EAS by its simulator session ID. Preview links follow the session's saved sharing preference";
"get info about a remote simulator session on EAS by its simulator session ID. Preview links follow the session's saved sharing preference";
static override flags = {
id: Flags.string({
@@ -23,6 +23,7 @@ import {
DeviceRunSessionStatus,
DeviceRunSessionType,
JobRunStatus,
StatuspageServiceName,
} from '../../graphql/generated';
import { DeviceRunSessionMutation } from '../../graphql/mutations/DeviceRunSessionMutation';
import { DeviceRunSessionAvailabilityQuery } from '../../graphql/queries/DeviceRunSessionAvailabilityQuery';
@@ -77,6 +78,7 @@ import {
} from '../../simulator/utils';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { sleepAsync } from '../../utils/promise';
import { maybeWarnAboutEasOutagesAsync } from '../../utils/statuspageService';
// Allow the cold-cache client download (up to 60s) and a further minute to connect.
const EGRESS_SETUP_TIMEOUT_MS = 120_000;
@@ -101,7 +103,7 @@ export default class Simulator extends EasCommand {
...SIMULATOR_HIDDEN_TOPIC_ALIASES,
...getSimulatorCommandHiddenAliases('start'),
];
static override description = `[EXPERIMENTAL] start a remote simulator session on EAS
static override description = `start a remote simulator session on EAS
Preview links require sign-in by default.
@@ -190,6 +192,12 @@ With local egress, actions wait for the connection and require interactive mode.
description:
'Version of the package backing the simulator session (e.g. "0.1.3-alpha.3"). Defaults to "latest" when omitted.',
}),
'log-source': Flags.option({
description:
'Android logs to record during the session and upload afterward. Repeat to select both sources; replaces the default emulator-output selection. Device-wide device-logcat may contain credentials and personal data. Logs are not redacted.',
options: ['emulator-output', 'device-logcat'],
multiple: true,
})(),
'network-capture': Flags.boolean({
allowNo: true,
description:
@@ -218,7 +226,7 @@ With local egress, actions wait for the connection and require interactive mode.
})(),
egress: Flags.option({
description:
'With "local", the simulator\'s network traffic exits from this machine and fails while the egress client is disconnected. On iOS, the simulator system proxy points at this machine: HTTP(S) and WebSocket requests that honor it (WebKit, URLSession) and clients that read proxy environment variables (gRPC, libcurl) use it, and connections that ignore both are refused inside the simulator and listed, with the library that tried, in the Logs section of the session page on expo.dev. On Android, all TCP traffic from the emulator goes through its proxy to this machine; traffic that cannot (UDP, such as QUIC) is refused and listed in the same Logs section, and DNS for apps that ignore the system proxy resolves on the device host. The egress client must keep running for the life of the session.',
'[EXPERIMENTAL] With "local", the simulator\'s network traffic exits from this machine and fails while the egress client is disconnected. On iOS, the simulator system proxy points at this machine: HTTP(S) and WebSocket requests that honor it (WebKit, URLSession) and clients that read proxy environment variables (gRPC, libcurl) use it, and connections that ignore both are refused inside the simulator and listed, with the library that tried, in the Logs section of the session page on expo.dev. On Android, all TCP traffic from the emulator goes through its proxy to this machine; traffic that cannot (UDP, such as QUIC) is refused and listed in the same Logs section, and DNS for apps that ignore the system proxy resolves on the device host. The egress client must keep running for the life of the session.',
options: EGRESS_FLAG_VALUES,
})(),
'egress-allow': Flags.string({
@@ -282,7 +290,7 @@ With local egress, actions wait for the connection and require interactive mode.
});
// The server would reject the session anyway, but check the gate first so gated
// accounts get the waitlist link instead of a generic permission error. Expo admins
// accounts get the upgrade link instead of a generic permission error. Expo admins
// skip the check so they can work on any account and let the server decide.
if (!actor.isExpoAdmin) {
const { accountName, available } = await DeviceRunSessionAvailabilityQuery.byAppIdAsync(
@@ -300,6 +308,7 @@ With local egress, actions wait for the connection and require interactive mode.
const tags = flags.tag?.map(tag => tag.trim()).filter(tag => tag.length > 0);
const deviceIdentifier = flags.device?.trim() || undefined;
const systemImagePackage = flags['system-image']?.trim() || undefined;
const logSources = flags['log-source'];
const buildId = flags['build-id']?.trim() || undefined;
const buildFingerprint = flags['build-fingerprint']?.trim() || undefined;
const applicationArchiveUrlFromFlag = flags['application-archive-url']?.trim() || undefined;
@@ -346,6 +355,11 @@ With local egress, actions wait for the connection and require interactive mode.
const platform = await resolvePlatformAsync(flags.platform, nonInteractive);
const egress = flags.egress === 'local' ? DeviceRunSessionEgress.Local : undefined;
if (logSources && platform !== AppPlatform.Android) {
throw new EasCommandError(
'--log-source selects Android emulator logs. Pass --platform android.'
);
}
if (systemImagePackage && platform !== AppPlatform.Android) {
throw new EasCommandError('--system-image is only supported with --platform android.');
}
@@ -385,12 +399,6 @@ With local egress, actions wait for the connection and require interactive mode.
);
}
if (platform === AppPlatform.Android) {
Log.warn(
'Android emulator support in EAS Simulator is still in development. Some features available on iOS may not work on Android yet. Full parity with iOS is coming soon.'
);
Log.newLine();
}
const expoGoSdkVersion = flags['expo-go']
? await resolveExpoGoSdkVersionAsync({ projectDir, sdkVersion: sdkVersionFromFlag })
: undefined;
@@ -404,6 +412,12 @@ With local egress, actions wait for the connection and require interactive mode.
Log.newLine();
}
// Same services as the outage banner on the simulator pages of the website.
await maybeWarnAboutEasOutagesAsync(graphqlClient, [
StatuspageServiceName.EasSimulator,
StatuspageServiceName.EasWorkflows,
]);
const createSpinner = ora('🚀 Creating simulator session').start();
let deviceRunSessionId: string;
let deviceRunSessionUrl: string;
@@ -421,6 +435,19 @@ With local egress, actions wait for the connection and require interactive mode.
packageVersion: flags['package-version'],
nonInteractive,
});
const android = {
...(deviceIdentifier ? { deviceIdentifier } : {}),
...(systemImagePackage ? { systemImagePackage } : {}),
...(logSources ? { logSources } : {}),
};
const platformInput =
platform === AppPlatform.Ios
? deviceIdentifier
? { ios: { deviceIdentifier } }
: {}
: Object.keys(android).length
? { android }
: {};
const session = await withSimulatorRequestAnalyticsAsync(
analytics,
requestProperties,
@@ -436,18 +463,7 @@ With local egress, actions wait for the connection and require interactive mode.
packageVersion: flags['package-version'],
networkCapture: flags['network-capture'],
...(networkCaptureFields.length ? { networkCaptureFields } : {}),
...(platform === AppPlatform.Ios
? deviceIdentifier
? { ios: { deviceIdentifier } }
: {}
: deviceIdentifier || systemImagePackage
? {
android: {
...(deviceIdentifier ? { deviceIdentifier } : {}),
...(systemImagePackage ? { systemImagePackage } : {}),
},
}
: {}),
...platformInput,
...(buildId ? { buildId } : {}),
...(buildFingerprint ? { buildFingerprint } : {}),
...(applicationArchiveUrlFromFlag
@@ -57,8 +57,7 @@ const PLATFORM_BY_FLAG_VALUE = Object.fromEntries(
export default class SimulatorList extends EasCommand {
static override aliases = getSimulatorCommandAliases('list');
static override hiddenAliases = getSimulatorCommandHiddenAliases('list');
static override description =
'[EXPERIMENTAL] list remote simulator sessions for the current project';
static override description = 'list remote simulator sessions for the current project';
static override flags = {
status: Flags.option({
@@ -53,8 +53,7 @@ interface SimulatorLogsSnapshot {
export default class SimulatorLogs extends EasCommand {
static override aliases = getSimulatorCommandAliases('logs');
static override hiddenAliases = getSimulatorCommandHiddenAliases('logs');
static override description =
'[EXPERIMENTAL] show buffered logs from a remote iOS simulator session';
static override description = 'show buffered logs from a remote iOS simulator session';
static override examples = [
'<%= config.bin %> simulator:logs --follow',
@@ -23,7 +23,7 @@ export default class SimulatorStop extends EasCommand {
static override aliases = getSimulatorCommandAliases('stop');
static override hiddenAliases = getSimulatorCommandHiddenAliases('stop');
static override description =
'[EXPERIMENTAL] stop a remote simulator session on EAS by its simulator session ID';
'stop a remote simulator session on EAS by its simulator session ID';
static override flags = {
id: Flags.string({
@@ -1,3 +1,4 @@
import { formatBytes } from '@expo/eas-build-job';
import { Args, Errors } from '@oclif/core';
import chalk from 'chalk';
@@ -10,7 +11,6 @@ import {
} from '../../../graphql/queries/EmbeddedUpdateQuery';
import Log from '../../../log';
import { fromNow } from '../../../utils/date';
import { formatBytes } from '../../../utils/files';
import formatFields from '../../../utils/formatFields';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
@@ -105,6 +105,13 @@ export default class WorkflowSsh extends EasCommand {
static override description =
'[EXPERIMENTAL] open an ssh session on the worker running a workflow job';
static override usage = 'workflow:ssh [--show-connect] ID';
static override examples = [
'$ eas workflow:ssh <ID> \t # Open an SSH session on the worker. Use the ID in the job logs ("Start SSH session" step)',
'$ eas workflow:ssh --show-connect <ID> \t # Print the ssh command to connect, without opening the SSH session',
];
static override strict = false;
static override flags = {
+8
View File
@@ -6658,6 +6658,13 @@ export type CreateAndConfigureRepositoryInput = {
export type CreateAndroidDeviceRunSessionInput = {
/** AVD hardware profile id to use for the emulator (e.g. "pixel_9"). Defaults to "medium_phone". */
deviceIdentifier?: InputMaybe<Scalars['String']['input']>;
/**
* Logs to record during the session and upload afterward: emulator-output and device-logcat. Defaults to
* ["emulator-output"]. An empty list disables recording and uploading both sources. Device-wide logcat can contain
* credentials and personal data; include device-logcat only when you want to retain it.
* Logs are not redacted and use the existing 90-day artifact retention policy.
*/
logSources?: InputMaybe<Array<Scalars['String']['input']>>;
/**
* Android SDK system image package to use for the emulator (e.g.
* "system-images;android-35;google_apis;x86_64").
@@ -11207,6 +11214,7 @@ export type StatuspageService = {
/** Name of a service monitored by Expo status page. */
export enum StatuspageServiceName {
EasBuild = 'EAS_BUILD',
EasSimulator = 'EAS_SIMULATOR',
EasSubmit = 'EAS_SUBMIT',
EasUpdate = 'EAS_UPDATE',
EasWorkflows = 'EAS_WORKFLOWS',
@@ -215,9 +215,10 @@ export const WorkflowJobSshQuery = {
throw error;
}
const build = data.builds.byId;
const jobCompleted = FINAL_BUILD_STATUSES.has(build.status);
return {
sshRequested: build.sshSession !== null,
jobCompleted: FINAL_BUILD_STATUSES.has(build.status),
sshRequested: build.sshSession !== null || jobCompleted,
jobCompleted,
session: build.sshSession,
};
},
@@ -1,4 +1,5 @@
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { resolveSshConnectStatus, terminalSshStatusMessage } from '../../../commands/workflow/ssh';
import { GraphqlError } from '../../client';
import { BuildStatus, JobRunStatus, WorkflowJobStatus } from '../../generated';
import { WorkflowJobSshQuery } from '../WorkflowJobSshQuery';
@@ -227,6 +228,19 @@ describe(WorkflowJobSshQuery.connectInfoForBuildAsync.name, () => {
}
);
it.each([BuildStatus.Errored, BuildStatus.Finished, BuildStatus.Canceled])(
'reports the ssh session as ended for a %s build without an sshSession',
async status => {
const { graphqlClient } = makeClient({ id: 'build-1', status, sshSession: null });
const sshStatus = resolveSshConnectStatus(
await WorkflowJobSshQuery.connectInfoForBuildAsync(graphqlClient, 'build-1')
);
expect(sshStatus).toBe('ended');
expect(terminalSshStatusMessage(sshStatus, 'build-1')).toBe('This ssh session has ended.');
}
);
it.each([BuildStatus.New, BuildStatus.InQueue, BuildStatus.PendingCancel])(
'treats %s as not completed',
async status => {
@@ -1,3 +1,4 @@
import { formatBytes } from '@expo/eas-build-job';
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
@@ -6,7 +7,6 @@ import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGr
import { AssetMapSourceInput, AssetMapSourceType, UploadSessionType } from '../graphql/generated';
import Log from '../log';
import { uploadFileAtPathToGCSAsync } from '../uploads';
import { formatBytes } from '../utils/files';
import { createProgressTracker } from '../utils/progress';
export async function maybeUploadAssetMapAsync(
@@ -1,3 +1,4 @@
import { formatBytes } from '@expo/eas-build-job';
import chalk from 'chalk';
import glob from 'fast-glob';
import fs from 'fs-extra';
@@ -9,7 +10,6 @@ import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGr
import { SourceMapSourceInput, SourceMapSourceType, UploadSessionType } from '../graphql/generated';
import Log from '../log';
import { uploadFileAtPathToGCSAsync } from '../uploads';
import { formatBytes } from '../utils/files';
import { getTmpDirectory } from '../utils/paths';
import { createProgressTracker } from '../utils/progress';
@@ -1,3 +1,4 @@
import { formatBytes } from '@expo/eas-build-job';
import chalk from 'chalk';
import fs from 'node:fs';
import path from 'node:path';
@@ -5,7 +6,6 @@ import path from 'node:path';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { AccountUploadSessionType } from '../graphql/generated';
import { uploadAccountScopedFileAtPathToGCSAsync } from '../uploads';
import { formatBytes } from '../utils/files';
import { createProgressTracker } from '../utils/progress';
/**
@@ -1,3 +1,4 @@
import { formatBytes } from '@expo/eas-build-job';
import chalk from 'chalk';
import fs from 'node:fs';
@@ -10,7 +11,6 @@ import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGr
import { AccountUploadSessionType } from '../graphql/generated';
import Log, { learnMore } from '../log';
import { uploadAccountScopedFileAtPathToGCSAsync } from '../uploads';
import { formatBytes } from '../utils/files';
import { createProgressTracker } from '../utils/progress';
import { Client } from '../vcs/vcs';
@@ -0,0 +1,80 @@
import { SpawnSyncReturns, spawnSync } from 'child_process';
import { randomBytes } from 'crypto';
import * as fs from 'fs-extra';
import os from 'os';
import path from 'path';
import { createAppArchiveAsync } from '../appArchive';
describe(createAppArchiveAsync, () => {
let root: string;
let bundle: string;
beforeEach(async () => {
root = await fs.mkdtemp(path.join(os.tmpdir(), 'app-archive-test-'));
bundle = path.join(root, 'Fixture.app');
const framework = path.join(bundle, 'Frameworks/F.framework');
await fs.ensureDir(path.join(framework, 'Versions/A'));
await fs.writeFile(path.join(framework, 'Versions/A/F'), 'framework');
await fs.symlink('A', path.join(framework, 'Versions/Current'));
await fs.symlink('Versions/Current/F', path.join(framework, 'F'));
await fs.link(path.join(framework, 'Versions/A/F'), path.join(bundle, '00-source'));
await fs.writeFile(path.join(bundle, '01-unrelated'), 'ordinary file');
for (let i = 0; i < 20; i++) {
await fs.link(path.join(framework, 'Versions/A/F'), path.join(bundle, `02-hardlink-${i}`));
}
});
afterEach(async () => {
await fs.remove(root);
});
function child(mode: string): SpawnSyncReturns<string> {
return spawnSync(
process.execPath,
[
'-r',
require.resolve('ts-node/register/transpile-only'),
path.join(__dirname, 'fixtures/app-archive.cjs'),
mode,
bundle,
],
{
env: { ...process.env, TMPDIR: root, FORCE_COLOR: '0', NO_COLOR: undefined },
timeout: 10_000,
encoding: 'utf8',
}
);
}
it('finishes packaging hard links beside versioned framework symlinks', () => {
const result = child('pack');
expect(result.error).toBeUndefined();
expect(result.status).toBe(0);
expect(result.stderr).toBe('');
});
it.each(['interrupt-packaging', 'interrupt-upload'])('cleans its archive on %s', async mode => {
await fs.writeFile(path.join(bundle, 'asset'), randomBytes(8 * 1024 * 1024));
const result = child(mode);
expect(result.error).toBeUndefined();
expect(result.stdout).toContain(mode);
expect(result.status).toBe(130);
expect((await fs.readdir(root)).filter(name => name.startsWith('eas-simulator-app-'))).toEqual(
[]
);
});
it('removes its signal and exit handlers after normal cleanup', async () => {
const before = [
process.listeners('SIGINT'),
process.listeners('SIGTERM'),
process.listeners('exit'),
];
const archive = await createAppArchiveAsync(bundle);
await archive.cleanupAsync();
expect([
process.listeners('SIGINT'),
process.listeners('SIGTERM'),
process.listeners('exit'),
]).toEqual(before);
});
});
@@ -1,12 +1,23 @@
import fs from 'fs-extra';
import os from 'node:os';
import path from 'node:path';
import * as tar from 'tar';
import { executeControlConfigAsync, prepareControlConfigAsync } from '../controlConfig';
import { HostActionCatalog, HostActionConnection, HostActionResult } from '../hostActions';
jest.mock('tar', () => {
const actual = jest.requireActual<typeof import('tar')>('tar');
return { ...actual, create: jest.fn(actual.create) };
});
const itWithChmod = process.platform === 'win32' || process.geteuid?.() === 0 ? it.skip : it;
function catalogWithAppArchives(): HostActionCatalog {
const fixture: HostActionCatalog = require('./fixtures/hostActionCatalog.json');
return { ...fixture, 'app.install': { ...fixture['app.install'], uploadArchive: 'tar.gz' } };
}
const catalog: HostActionCatalog = {
'keyboard.set': {
type: 'object',
@@ -145,6 +156,44 @@ describe('simulator control configuration', () => {
expect(requestAsync).not.toHaveBeenCalled();
});
it.each([false, true])(
'packages an imported app only during execution (invalid later step: %s)',
async invalid => {
const bundle = path.join(folder, 'Imported.app');
await fs.ensureDir(bundle);
await fs.writeFile(path.join(bundle, 'Info.plist'), 'app plist');
const withApps: HostActionCatalog = {
...catalog,
'app.install': {
type: 'object',
properties: { udid: { type: 'string' }, path: { type: 'string' } },
required: ['udid', 'path'],
upload: 'path',
uploadArchive: 'tar.gz',
},
};
await writeConfig([
{ action: 'app.install', file: './Imported.app' },
...(invalid ? [{ action: 'keyboard.set', params: { value: 'invalid' } }] : []),
]);
const packaging = jest.mocked(tar.create);
const prepared = prepareControlConfigAsync(withApps, filePath, connection.udid);
if (invalid) {
await expect(prepared).rejects.toThrow(/step 2.*keyboard\.set/i);
expect(packaging).not.toHaveBeenCalled();
expect(requestAsync).not.toHaveBeenCalled();
} else {
const config = await prepared;
expect(packaging).not.toHaveBeenCalled();
await executeControlConfigAsync(connection, config);
expect(packaging).toHaveBeenCalledTimes(1);
const archive = packaging.mock.calls[0][0].file!;
expect(await fs.pathExists(path.dirname(archive))).toBe(false);
expect(requestAsync).toHaveBeenCalledWith('app.install', { path: '/remote/staged.txt' });
}
}
);
it('rejects unknown actions before any execution', async () => {
await writeConfig([{ action: 'echo' }, { action: 'missing' }]);
await expect(prepareControlConfigAsync(catalog, filePath, connection.udid)).rejects.toThrow(
@@ -278,9 +327,9 @@ describe('simulator control configuration', () => {
it.each(['app.install', 'media.add'])(
'rejects an explicit file combined with another source for %s',
async action => {
const realCatalog: HostActionCatalog = require('./fixtures/hostActionCatalog.json');
await fs.writeFile(path.join(folder, 'new.ipa'), 'new bytes');
await writeConfig([{ action, params: { uploadId: 'old.ipa' }, file: './new.ipa' }]);
const realCatalog = catalogWithAppArchives();
await fs.writeFile(path.join(folder, 'new.tgz'), 'new bytes');
await writeConfig([{ action, params: { uploadId: 'old.ipa' }, file: './new.tgz' }]);
await expect(
prepareControlConfigAsync(realCatalog, filePath, connection.udid)
).rejects.toThrow(/file.*source/i);
@@ -328,9 +377,9 @@ describe('simulator control configuration', () => {
});
it('accepts an explicit file as the only source of a union action', async () => {
const realCatalog: HostActionCatalog = require('./fixtures/hostActionCatalog.json');
await fs.writeFile(path.join(folder, 'new.ipa'), 'new bytes');
await writeConfig([{ action: 'app.install', file: './new.ipa' }]);
const realCatalog = catalogWithAppArchives();
await fs.writeFile(path.join(folder, 'new.tgz'), 'new bytes');
await writeConfig([{ action: 'app.install', file: './new.tgz' }]);
const config = await prepareControlConfigAsync(realCatalog, filePath, connection.udid);
await executeControlConfigAsync(connection, config);
expect(requestAsync).toHaveBeenCalledWith('app.install', { path: '/remote/staged.txt' });
@@ -0,0 +1,35 @@
const fs = require('fs');
const path = require('path');
const { createAppArchiveAsync } = require('../../appArchive');
async function main() {
const [mode, bundle] = process.argv.slice(2);
if (mode === 'pack') {
for (let i = 0; i < 5; i++) {
const archive = await createAppArchiveAsync(bundle);
await archive.cleanupAsync();
}
return;
}
let timer;
if (mode === 'interrupt-packaging') {
timer = setInterval(() => {
const directories = fs.readdirSync(process.env.TMPDIR);
if (directories.some(name => name.startsWith('eas-simulator-app-') &&
fs.existsSync(path.join(process.env.TMPDIR, name, 'app.tar.gz')))) {
clearInterval(timer);
console.log('interrupt-packaging');
process.kill(process.pid, 'SIGINT');
}
}, 1);
}
const archive = await createAppArchiveAsync(bundle);
if (mode === 'interrupt-upload') {
console.log('interrupt-upload');
process.kill(process.pid, 'SIGINT');
}
// Keep the process alive as the action transport does during upload.
setInterval(() => {}, 1000);
}
main().catch(error => { console.error(error); process.exitCode = 1; });
@@ -505,6 +505,25 @@ describe('host action connection', () => {
await expect(fetchHostActionCatalogAsync(hostActions)).resolves.toEqual(catalog);
});
it.each([42, null, {}])(
'keeps other actions usable with an unknown archive capability: %j',
async capability => {
const hostActions = await connectAsync();
reply = message => ({
id: message.id,
stdout: JSON.stringify({
...catalog,
'app.install': { ...catalog['app.install'], uploadArchive: capability },
}),
stderr: '',
exitCode: 0,
});
const described = await fetchHostActionCatalogAsync(hostActions);
expect(described['hardwareKeyboard.set']).toEqual(catalog['hardwareKeyboard.set']);
expect(described['app.install'].uploadArchive).toEqual(capability);
}
);
it('explains that an old serve-sim does not have the catalog', async () => {
reply = message => ({ id: message.id, error: 'unknown action actions.describe' });
const hostActions = await connectAsync();
@@ -134,6 +134,24 @@ describe('simulator start configuration', () => {
).rejects.toThrow('does not exist on this computer');
});
it('accepts a local app bundle during startup preflight without a live catalog', async () => {
await fs.ensureDir(path.join(folder, 'Startup.app'));
await fs.writeFile(path.join(folder, 'Startup.app', 'Info.plist'), 'app plist');
await expect(
resolve({ platform: 'ios' }, [], [{ action: 'app.install', file: './Startup.app' }])
).resolves.toBeDefined();
});
it.each(['Device.ipa', 'Device.zip'])(
'rejects unsupported installs before creating a session: %s',
async name => {
await fs.writeFile(path.join(folder, name), 'device archive');
await expect(
resolve({ platform: 'ios' }, [], [{ action: 'app.install', file: `./${name}` }])
).rejects.toThrow('Simulator installs require');
}
);
it('rejects invalid output parents and device overrides during local preflight', async () => {
await expect(
resolve({}, [], [{ action: 'screenshot.capture', output: './missing/shot.png' }])
@@ -16,7 +16,15 @@ describe(applyStartConfigAsync, () => {
let folder: string;
let apiUrl: string;
let actions: { action: string; params: Record<string, unknown> }[];
let onUpload: (() => void) | undefined;
const catalog = {
'app.install': {
type: 'object',
properties: { udid: { type: 'string' }, path: { type: 'string' } },
required: ['udid', 'path'],
upload: 'path',
uploadArchive: 'tar.gz',
},
set: {
type: 'object',
properties: { udid: { type: 'string' }, value: { type: 'string' } },
@@ -31,6 +39,7 @@ describe(applyStartConfigAsync, () => {
};
beforeEach(async () => {
actions = [];
onUpload = undefined;
folder = await fs.mkdtemp(path.join(os.tmpdir(), 'start-setup-'));
server = http.createServer((req, res) => {
if (req.url !== '/api' || req.headers.authorization !== 'Bearer token') {
@@ -51,6 +60,9 @@ describe(applyStartConfigAsync, () => {
socket.on('message', bytes => {
const message = JSON.parse(bytes.toString());
actions.push(message);
if (message.action === 'upload.append') {
onUpload?.();
}
const stdout =
message.action === 'actions.describe'
? JSON.stringify(catalog)
@@ -121,4 +133,50 @@ describe(applyStartConfigAsync, () => {
await closed;
expect(actions.map(action => action.action)).toEqual(['actions.describe']);
});
it('leaves Ctrl+C cleanup to startup when interrupted during an app upload', async () => {
const bundle = path.join(folder, 'Startup.app');
await fs.ensureDir(bundle);
await fs.writeFile(path.join(bundle, 'Info.plist'), 'app plist');
const file = path.join(folder, 'session.json');
await fs.writeJson(file, {
version: 1,
actions: [{ action: 'app.install', file: './Startup.app' }],
});
const config = await readSimulatorSessionConfigAsync(file);
const before = await fs.readdir(os.tmpdir());
const controller = new AbortController();
const interrupt = (): void => {
controller.abort();
};
const exit = jest.spyOn(process, 'exit').mockImplementation(code => {
throw new Error(`premature process.exit(${code})`);
});
process.once('SIGINT', interrupt);
onUpload = () => {
try {
process.emit('SIGINT');
} catch {
// Record a premature archive-helper exit without terminating this test process.
}
};
try {
await expect(
applyStartConfigAsync({ apiUrl, token: 'token' }, config, {
signal: controller.signal,
})
).rejects.toThrow();
expect(controller.signal.aborted).toBe(true);
expect(exit).not.toHaveBeenCalled();
expect(
(await fs.readdir(os.tmpdir())).filter(
name => name.startsWith('eas-simulator-app-') && !before.includes(name)
)
).toEqual([]);
expect(actions.some(({ action }) => action === 'app.install')).toBe(false);
} finally {
process.removeListener('SIGINT', interrupt);
exit.mockRestore();
}
});
});
@@ -9,7 +9,6 @@ import {
DEVICE_RUN_SESSION_TYPE_FLAG_OPTIONS,
DEVICE_RUN_SESSION_TYPE_FLAG_VALUES,
DeviceRunSessionRemoteConfig,
EAS_SIMULATOR_WAITLIST_URL,
deviceRunSessionTypeToFlagValue,
deviceRunSessionTypesForFlagValue,
formatEmbedPreviewUrl,
@@ -773,11 +772,13 @@ describe('dotenv instructions', () => {
});
describe(formatSimulatorUnavailableMessage, () => {
it('names the account and points at the waitlist', () => {
it('names the account, the paid plans, the billing page, and the account executive', () => {
const message = formatSimulatorUnavailableMessage('acme');
expect(message).toContain('acme');
expect(message).toContain(EAS_SIMULATOR_WAITLIST_URL);
expect(message).toContain('Starter, Production, and Enterprise plans');
expect(message).toContain('https://expo.dev/accounts/acme/settings/billing');
expect(message).toContain('annual Enterprise plan, contact your Expo account executive');
});
});
@@ -0,0 +1,61 @@
import * as fs from 'fs-extra';
import os from 'os';
import path from 'path';
import * as tar from 'tar';
export async function createAppArchiveAsync(
value: string,
{ handleInterrupt = true }: { handleInterrupt?: boolean } = {}
): Promise<{ path: string; cleanupAsync: () => Promise<void> }> {
const bundle = await fs.realpath(value);
// Create and register cleanup in one synchronous turn so an interrupt cannot
// leave a newly created directory without its exit handler.
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'eas-simulator-app-'));
const archive = path.join(directory, 'app.tar.gz');
const cleanupOnExit = (): void => {
try {
fs.removeSync(directory);
} catch {
// Exit cleanup is best effort; do not prevent the interrupted process from stopping.
}
};
const interrupt = (): never => {
cleanupOnExit();
process.exit(130);
};
const terminate = (): never => {
cleanupOnExit();
process.exit(143);
};
process.once('exit', cleanupOnExit);
if (handleInterrupt) {
process.once('SIGINT', interrupt);
}
process.once('SIGTERM', terminate);
const cleanupAsync = async (): Promise<void> => {
try {
await fs.remove(directory);
} finally {
process.removeListener('exit', cleanupOnExit);
process.removeListener('SIGINT', interrupt);
process.removeListener('SIGTERM', terminate);
}
};
try {
await tar.create(
{
cwd: bundle,
file: archive,
prefix: path.basename(path.resolve(value)),
gzip: true,
portable: true,
jobs: 1,
},
['.']
);
return { path: archive, cleanupAsync };
} catch (error) {
await cleanupAsync();
throw error;
}
}
+1 -1
View File
@@ -1,3 +1,4 @@
import { formatBytes } from '@expo/eas-build-job';
import path from 'node:path';
import { downloadSimulatorFileAsync } from './download';
@@ -6,7 +7,6 @@ import fetch from '../fetch';
import { DeviceRunSessionByIdQuery, DeviceRunSessionStatus } from '../graphql/generated';
import Log from '../log';
import { ora } from '../ora';
import { formatBytes } from '../utils/files';
import { promptAsync, selectAsync } from '../prompts';
import { printJsonOnlyOutput } from '../utils/json';
@@ -1,6 +1,7 @@
import * as fs from 'fs-extra';
import path from 'path';
import { createAppArchiveAsync } from './appArchive';
import {
HostActionCatalog,
HostActionCatalogEntry,
@@ -24,7 +25,7 @@ export type PreparedControlAction = Readonly<{
entry: HostActionCatalogEntry;
params: Readonly<Record<string, unknown>>;
outputPath?: string;
localFile?: Readonly<{ param: string; path: string }>;
localFile?: Readonly<{ param: string; path: string; appBundle?: true }>;
}>;
export function resolveControlActionPath(value: string, baseDir?: string): string {
@@ -67,8 +68,11 @@ export async function prepareControlActionAsync(
if (Object.hasOwn(params, param)) {
throw new Error(`${param} and file cannot both be supplied for ${action.action}.`);
}
localFile = { param, path: resolveControlActionPath(action.file, baseDir) };
await assertLocalFileAsync(localFile.path);
localFile = await prepareLocalFileAsync(
action.action,
entry,
resolveControlActionPath(action.file, baseDir)
);
} else if (localFileMode === 'auto' && param && applies && typeof params[param] === 'string') {
const value = params[param];
if (value.startsWith('host:')) {
@@ -97,8 +101,7 @@ export async function prepareControlActionAsync(
`${value} does not exist on this computer. Pass a local file, or prefix an absolute simulator-host path with host:.`
);
}
await assertLocalFileAsync(localPath, stat);
localFile = { param, path: localPath };
localFile = await prepareLocalFileAsync(action.action, entry, localPath, stat);
}
}
return {
@@ -110,40 +113,92 @@ export async function prepareControlActionAsync(
};
}
export async function assertLocalFileAsync(localPath: string, stats?: fs.Stats): Promise<void> {
async function prepareLocalFileAsync(
action: string,
entry: HostActionCatalogEntry,
localPath: string,
stats?: fs.Stats
): Promise<NonNullable<PreparedControlAction['localFile']>> {
const stat = await assertLocalFileAsync(localPath, { action, stats });
if (
action === 'app.install' &&
(stat.isDirectory() || /\.(?:tar\.gz|tgz)$/i.test(localPath)) &&
entry.uploadArchive !== 'tar.gz'
) {
throw new Error(
'This simulator host does not support app archives. Start a session with an updated serve-sim package.'
);
}
return {
param: entry.upload!,
path: localPath,
...(stat.isDirectory() ? { appBundle: true } : {}),
};
}
export async function assertLocalFileAsync(
localPath: string,
{ action, stats }: { action?: string; stats?: fs.Stats } = {}
): Promise<fs.Stats> {
const stat = stats ?? (await fs.stat(localPath).catch(() => null));
if (!stat) {
throw new Error(`${localPath} does not exist on this computer.`);
}
if (stat.isDirectory()) {
throw new Error(`${localPath} is a folder. Pass the path of a file.`);
}
if (!stat.isFile()) {
if (action !== 'app.install') {
throw new Error(`${localPath} is a folder. Pass the path of a file.`);
}
if (path.extname(path.resolve(localPath)) !== '.app') {
throw new Error(
`${localPath} is a folder. Pass a Simulator .app bundle or .tar.gz/.tgz archive.`
);
}
} else if (!stat.isFile()) {
throw new Error(`${localPath} is not a regular file.`);
}
if (stat.size === 0) {
} else if (action === 'app.install' && /\.(?:ipa|zip)$/i.test(localPath)) {
throw new Error(
'Simulator installs require a Simulator .app bundle or .tar.gz/.tgz archive. Device .ipa builds and .zip archives are not supported.'
);
} else if (stat.size === 0) {
throw new Error(`${localPath} is empty. Pass a file with content.`);
}
await fs.access(localPath, fs.constants.R_OK);
return stat;
}
export async function executeControlActionAsync(
connection: HostActionConnection,
plan: PreparedControlAction,
{ validateParams }: { validateParams?: (params: Record<string, unknown>) => void } = {}
{
validateParams,
onProgress,
handleInterrupt,
}: {
validateParams?: (params: Record<string, unknown>) => void;
onProgress?: (phase: 'packaging' | 'uploading' | 'running') => void;
handleInterrupt?: boolean;
} = {}
): Promise<ControlActionResult> {
connection.signal.throwIfAborted();
const params: Record<string, unknown> = { ...plan.params };
let uploadId: string | undefined;
let archive: Awaited<ReturnType<typeof createAppArchiveAsync>> | undefined;
let succeeded = false;
try {
if (plan.localFile) {
const upload = await uploadFileAsync(connection, plan.localFile.path);
if (plan.localFile.appBundle) {
onProgress?.('packaging');
archive = await createAppArchiveAsync(plan.localFile.path, { handleInterrupt });
}
connection.signal.throwIfAborted();
onProgress?.('uploading');
const upload = await uploadFileAsync(connection, archive?.path ?? plan.localFile.path);
uploadId = upload.uploadId;
params[plan.localFile.param] = plan.entry.uploadAs === 'id' ? upload.uploadId : upload.path;
}
connection.signal.throwIfAborted();
validateParams?.(params);
onProgress?.('running');
const result = await connection.requestAsync(plan.action, params);
if (result.exitCode !== 0) {
const detail = (result.stderr || result.stdout).trim() || `exit code ${result.exitCode}`;
@@ -161,6 +216,7 @@ export async function executeControlActionAsync(
if (uploadId && (!plan.entry.keepUpload || !succeeded)) {
await connection.requestAsync('upload.remove', { uploadId }).catch(() => {});
}
await archive?.cleanupAsync();
}
}
@@ -115,7 +115,9 @@ export async function preflightControlConfigFilesAsync(
throw new Error('The simulator is selected by the session; remove params.udid.');
}
if (action.file) {
await assertLocalFileAsync(path.resolve(path.dirname(config.path), action.file));
await assertLocalFileAsync(path.resolve(path.dirname(config.path), action.file), {
action: action.action,
});
}
if (action.output) {
await assertWritableOutputAsync(
@@ -181,7 +183,10 @@ function assertUnambiguousFileSource(
export async function executeControlConfigAsync(
connection: HostActionConnection,
config: PreparedControlConfig,
{ onProgress }: { onProgress?: (progress: ControlConfigProgress) => void } = {}
{
onProgress,
handleInterrupt,
}: { onProgress?: (progress: ControlConfigProgress) => void; handleInterrupt?: boolean } = {}
): Promise<ControlConfigResult[]> {
if (config.udid !== connection.udid) {
throw new Error('The simulator changed after this configuration was checked. Import it again.');
@@ -192,7 +197,10 @@ export async function executeControlConfigAsync(
onProgress?.({ ...progress, status: 'running' });
let result: ControlActionResult;
try {
result = await executeControlActionAsync(connection, plan, { validateParams });
result = await executeControlActionAsync(connection, plan, {
validateParams,
handleInterrupt,
});
} catch (error) {
throw new ControlConfigExecutionError(index + 1, plan.action, completed, error);
}
@@ -33,6 +33,7 @@ const CatalogEntry = SchemaNode.extend({
description: z.string().optional(),
args: z.array(z.string()).optional(),
upload: z.string().optional(),
uploadArchive: z.unknown().optional(),
uploadAs: z.enum(['path', 'id']).optional(),
uploadWhen: z.record(z.string(), z.unknown()).optional(),
keepUpload: z.boolean().optional(),
+5 -1
View File
@@ -21,7 +21,11 @@ export async function applyStartConfigAsync(
const catalog = await fetchHostActionCatalogAsync(connection);
const prepared = await prepareControlConfigAsync(catalog, config.path, connection.udid, config);
signal.throwIfAborted();
return await executeControlConfigAsync(connection, prepared, { onProgress });
// Startup must stop its newly created session before exiting on Ctrl+C.
return await executeControlConfigAsync(connection, prepared, {
onProgress,
handleInterrupt: false,
});
} finally {
connection.close();
}
+7 -6
View File
@@ -1,6 +1,7 @@
import chalk from 'chalk';
import { stripVTControlCharacters } from 'util';
import { getAccountBillingUrl } from '../build/utils/url';
import {
AppPlatform,
DeviceRunSessionByIdQuery,
@@ -20,17 +21,17 @@ import {
type DeviceRunSessionByIdResult = DeviceRunSessionByIdQuery['deviceRunSessions']['byId'];
export type DeviceRunSessionRemoteConfig = NonNullable<DeviceRunSessionByIdResult['remoteConfig']>;
/** Landing page where accounts without access can request it. */
export const EAS_SIMULATOR_WAITLIST_URL = 'https://expo.dev/services/simulators';
/**
* Message shown when EAS Simulator is not enabled for the account. Shared by every
* command that can hit the gate so users always get the same waitlist pointer.
* command that can hit the gate so users always get the same upgrade pointer. The gate
* result does not say whether the account is on the Free plan or is past due, so the
* message covers both.
*/
export function formatSimulatorUnavailableMessage(accountName: string): string {
return [
`EAS Simulator isn't available on ${accountName} yet — it's coming soon.`,
`Join the waitlist to get access: ${link(EAS_SIMULATOR_WAITLIST_URL)}`,
`EAS Simulator isn't enabled for ${accountName}. It's available on the Starter, Production, and Enterprise plans.`,
`Upgrade, or update billing if a payment is past due: ${link(getAccountBillingUrl(accountName))}`,
'If your account is on an annual Enterprise plan, contact your Expo account executive.',
].join('\n');
}
@@ -0,0 +1,110 @@
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import {
StatuspageIncidentImpact,
StatuspageIncidentStatus,
StatuspageServiceFragment,
StatuspageServiceName,
StatuspageServiceStatus,
} from '../../graphql/generated';
import { StatuspageServiceQuery } from '../../graphql/queries/StatuspageServiceQuery';
import Log from '../../log';
import { maybeWarnAboutEasOutagesAsync } from '../statuspageService';
jest.mock('../../graphql/queries/StatuspageServiceQuery');
jest.mock('../../log');
const graphqlClient = {} as ExpoGraphqlClient;
function makeService(
name: StatuspageServiceName,
status: StatuspageServiceStatus,
incidentName?: string
): StatuspageServiceFragment {
return {
id: `${name}-id`,
name,
status,
incidents: incidentName
? [
{
id: 'incident-id',
name: incidentName,
status: StatuspageIncidentStatus.Investigating,
impact: StatuspageIncidentImpact.Major,
shortlink: 'https://stspg.io/incident',
},
]
: [],
};
}
function getWarnings(): string[] {
return jest.mocked(Log.warn).mock.calls.map(([message]) => String(message));
}
describe(maybeWarnAboutEasOutagesAsync, () => {
beforeEach(() => {
jest.mocked(Log.warn).mockClear();
});
it('warns about an Expo Simulators outage with the incident name', async () => {
jest
.mocked(StatuspageServiceQuery.statuspageServicesAsync)
.mockResolvedValue([
makeService(
StatuspageServiceName.EasSimulator,
StatuspageServiceStatus.PartialOutage,
'iOS simulator sessions fail to start'
),
]);
await maybeWarnAboutEasOutagesAsync(graphqlClient, [StatuspageServiceName.EasSimulator]);
expect(StatuspageServiceQuery.statuspageServicesAsync).toHaveBeenCalledWith(graphqlClient, [
StatuspageServiceName.EasSimulator,
]);
const warnings = getWarnings();
expect(warnings[0]).toContain('Expo Simulators is experiencing a partial outage.');
expect(warnings[1]).toBe('Reason: iOS simulator sessions fail to start.');
});
it.each([
[StatuspageServiceStatus.MajorOutage, 'EAS Workflows is experiencing a major outage.'],
[
StatuspageServiceStatus.DegradedPerformance,
'EAS Workflows is experiencing degraded performance.',
],
[StatuspageServiceStatus.UnderMaintenance, 'EAS Workflows is under maintenance.'],
])('describes the %s status', async (status, expectedWarning) => {
jest
.mocked(StatuspageServiceQuery.statuspageServicesAsync)
.mockResolvedValue([makeService(StatuspageServiceName.EasWorkflows, status)]);
await maybeWarnAboutEasOutagesAsync(graphqlClient, [StatuspageServiceName.EasWorkflows]);
expect(getWarnings()[0]).toContain(expectedWarning);
});
it('does not warn about an operational service', async () => {
jest
.mocked(StatuspageServiceQuery.statuspageServicesAsync)
.mockResolvedValue([
makeService(StatuspageServiceName.EasSimulator, StatuspageServiceStatus.Operational),
]);
await maybeWarnAboutEasOutagesAsync(graphqlClient, [StatuspageServiceName.EasSimulator]);
expect(Log.warn).not.toHaveBeenCalled();
});
it('does not fail the command when the status query fails', async () => {
jest
.mocked(StatuspageServiceQuery.statuspageServicesAsync)
.mockRejectedValue(new Error('Variable "$serviceNames" got invalid value'));
await expect(
maybeWarnAboutEasOutagesAsync(graphqlClient, [StatuspageServiceName.EasSimulator])
).resolves.toBeUndefined();
expect(Log.warn).not.toHaveBeenCalled();
});
});
+1 -1
View File
@@ -1,3 +1,4 @@
import { formatBytes } from '@expo/eas-build-job';
import spawnAsync from '@expo/spawn-async';
import glob from 'fast-glob';
import fs from 'fs-extra';
@@ -7,7 +8,6 @@ import { extract } from 'tar';
import { promisify } from 'util';
import { v4 as uuidv4 } from 'uuid';
import { formatBytes } from './files';
import { getTmpDirectory } from './paths';
import { ProgressHandler, createProgressTracker } from './progress';
import fetch, { RequestInit, Response } from '../fetch';
-29
View File
@@ -29,32 +29,3 @@ export async function maybeRenameExistingFileAsync(
await fs.rename(desiredFilePath, path.resolve(projectDir, getRenamedFilename(filename, num)));
}
}
export function formatBytes(bytes: number): string {
if (bytes === 0) {
return `0`;
}
let multiplier = 1;
if (bytes < 1024 * multiplier) {
return `${Math.floor(bytes)} B`;
}
multiplier *= 1024;
if (bytes < 102.4 * multiplier) {
return `${(bytes / multiplier).toFixed(1)} KB`;
}
if (bytes < 1024 * multiplier) {
return `${Math.floor(bytes / 1024)} KB`;
}
multiplier *= 1024;
if (bytes < 102.4 * multiplier) {
return `${(bytes / multiplier).toFixed(1)} MB`;
}
if (bytes < 1024 * multiplier) {
return `${Math.floor(bytes / multiplier)} MB`;
}
multiplier *= 1024;
if (bytes < 102.4 * multiplier) {
return `${(bytes / multiplier).toFixed(1)} GB`;
}
return `${Math.floor(bytes / 1024)} GB`;
}
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff. Show more