Compare commits

...
Author SHA1 Message Date
Expo CI 5e192ecba6 v27.0.0 2026-10-27 22:10:54 +00:00
Gabe DebesandClaude Opus 5.5 864b11effe [eas-cli] Add simulator:logs for iOS sessions (#4545)
* [eas-cli] Add simulator:logs

Read buffered or live logs from a running iOS simulator session through
its preview API, or save the logs artifact once the session has stopped.

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

* [eas-cli] Use oclif exclusive for simulator:logs --follow and --json

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

* [eas-cli] Suggest redirecting --follow to save live simulator logs

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

* [eas-cli] Report log source failures while following simulator logs

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

* [eas-cli] Close the connection of a rejected simulator preview response

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

* [eas-cli] Write simulator log health notices to stderr

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

* [eas-cli] Explain that live simulator logs need an iOS session

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-27 15:02:52 -07:00
Gabe DebesandCodex 62df1bae1c [eas-cli] Require explicit simulator-host upload paths (#4683)
* [eas-cli] Require explicit simulator-host upload paths

* [eas-cli] Cover simulator upload path help and filesystem failures

* [eas-cli] Reject host paths for upload ID actions

Keep upload guidance consistent in text and JSON help, and explain local filenames beginning with host:. Add rejection and help regressions.

Co-authored-by: Codex <[email protected]>
2026-10-27 12:34:27 -07:00
57 changed files with 2614 additions and 311 deletions

No files matched your search

-5
View File
@@ -1,5 +0,0 @@
---
'@expo/build-tools': patch
---
Keep the launched app open when an Appium iOS session starts.
-5
View File
@@ -1,5 +0,0 @@
---
'@expo/build-tools': patch
---
Run Appium through the prepared Full Disk Access helper when available on macOS.
@@ -1,5 +0,0 @@
---
'eas-cli': minor
---
Send the `permissions.account.role` from the build profile to request a role for the build's `EXPO_TOKEN`.
@@ -1,5 +0,0 @@
---
'eas-cli': patch
---
Show permission guidance, including `permissions.account.role` in eas.json, when a build request fails with an authorization error.
-5
View File
@@ -1,5 +0,0 @@
---
'eas-cli': patch
---
Print the notice about eas-cli in project dependencies only when stderr is a terminal, so programs that read stderr get the command's error.
-5
View File
@@ -1,5 +0,0 @@
---
'eas-cli': major
---
Write the reason a command failed to stderr, next to the `command failed.` line, instead of to stdout. A program that reads the reason from stdout must read stderr instead.
-5
View File
@@ -1,5 +0,0 @@
---
'eas-cli': none
---
Run the GraphQL schema and generated code check on pull requests to any base branch, including stacked pull requests.
@@ -1,5 +0,0 @@
---
'eas-cli': patch
---
Show account roles and credential permission limits in `eas login` when you are already logged in.
@@ -1,5 +0,0 @@
---
'@expo/build-tools': patch
---
Identify the build or application archive when a download fails, and explain when the archive is no longer available.
-5
View File
@@ -1,5 +0,0 @@
---
'@expo/build-tools': patch
---
Run apsd setup in the background without delaying iOS app launch or preview readiness.
-5
View File
@@ -1,5 +0,0 @@
---
'eas-cli': patch
---
Fix simulator control action help routing and show help when a flag is incomplete.
-5
View File
@@ -1,5 +0,0 @@
---
'@expo/build-tools': patch
---
Handle a device session stopped during startup as normal cleanup instead of reporting a startup error.
-5
View File
@@ -1,5 +0,0 @@
---
'@expo/build-tools': patch
---
Support idle timeouts for iOS web-preview-only sessions, resetting the timer when preview input arrives.
+31
View File
@@ -2,6 +2,37 @@
This is the log of notable changes to EAS CLI and related packages.
## [27.0.0](https://github.com/expo/eas-cli/releases/tag/v27.0.0) - 2026-10-27
### 🛠 Breaking changes
- [eas-cli] Write the reason a command failed to stderr, next to the `command failed.` line, instead of to stdout. A program that reads the reason from stdout must read stderr instead. ([#4592](https://github.com/expo/eas-cli/pull/4592) by [@vonovak](https://github.com/vonovak))
### 🎉 New features
- [eas-cli] Send the `permissions.account.role` from the build profile to request a role for the build's `EXPO_TOKEN`. ([#4734](https://github.com/expo/eas-cli/pull/4734) by [@wschurman](https://github.com/wschurman))
- [eas-cli] Add `simulator:logs` to read and follow logs from an iOS simulator session and download saved logs after it stops. ([#4545](https://github.com/expo/eas-cli/pull/4545) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [build-tools] Keep the launched app open when an Appium iOS session starts. ([#4699](https://github.com/expo/eas-cli/pull/4699) by [@gwdp](https://github.com/gwdp))
- [build-tools] Run Appium through the prepared Full Disk Access helper when available on macOS. ([#4622](https://github.com/expo/eas-cli/pull/4622) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Show permission guidance, including `permissions.account.role` in eas.json, when a build request fails with an authorization error. ([#4760](https://github.com/expo/eas-cli/pull/4760) by [@wschurman](https://github.com/wschurman))
- [eas-cli] Print the notice about eas-cli in project dependencies only when stderr is a terminal, so programs that read stderr get the command's error. ([#4591](https://github.com/expo/eas-cli/pull/4591) by [@vonovak](https://github.com/vonovak))
- [eas-cli] Show account roles and credential permission limits in `eas login` when you are already logged in. ([#4759](https://github.com/expo/eas-cli/pull/4759) by [@wschurman](https://github.com/wschurman))
- [build-tools] Identify the build or application archive when a download fails, and explain when the archive is no longer available. ([#4703](https://github.com/expo/eas-cli/pull/4703) by [@gwdp](https://github.com/gwdp))
- [build-tools] Run apsd setup in the background without delaying iOS app launch or preview readiness. ([#4706](https://github.com/expo/eas-cli/pull/4706) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Fix simulator control action help routing and show help when a flag is incomplete. ([#4681](https://github.com/expo/eas-cli/pull/4681) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Require explicit `host:` simulator-host upload paths and report missing local files. ([#4683](https://github.com/expo/eas-cli/pull/4683) by [@gwdp](https://github.com/gwdp))
Missing local absolute paths now require the `host:` prefix. Actions that require upload IDs accept local files and reject host paths with an explanation.
- [build-tools] Handle a device session stopped during startup as normal cleanup instead of reporting a startup error. ([#4716](https://github.com/expo/eas-cli/pull/4716) by [@gwdp](https://github.com/gwdp))
- [build-tools] Support idle timeouts for iOS web-preview-only sessions, resetting the timer when preview input arrives. ([#4731](https://github.com/expo/eas-cli/pull/4731) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
- [eas-cli] Run the GraphQL schema and generated code check on pull requests to any base branch, including stacked pull requests. ([#4763](https://github.com/expo/eas-cli/pull/4763) by [@wschurman](https://github.com/wschurman))
## [26.0.0](https://github.com/expo/eas-cli/releases/tag/v26.0.0) - 2026-10-26
### 🛠 Breaking changes
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "26.0.0",
"version": "27.0.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+11
View File
@@ -1,5 +1,16 @@
# @expo/build-tools
## 27.0.0
### Patch Changes
- Keep the launched app open when an Appium iOS session starts. ([#4699](https://github.com/expo/eas-cli/pull/4699) by [@gwdp](https://github.com/gwdp))
- Run Appium through the prepared Full Disk Access helper when available on macOS. ([#4622](https://github.com/expo/eas-cli/pull/4622) by [@gwdp](https://github.com/gwdp))
- Identify the build or application archive when a download fails, and explain when the archive is no longer available. ([#4703](https://github.com/expo/eas-cli/pull/4703) by [@gwdp](https://github.com/gwdp))
- Run apsd setup in the background without delaying iOS app launch or preview readiness. ([#4706](https://github.com/expo/eas-cli/pull/4706) by [@gwdp](https://github.com/gwdp))
- Handle a device session stopped during startup as normal cleanup instead of reporting a startup error. ([#4716](https://github.com/expo/eas-cli/pull/4716) by [@gwdp](https://github.com/gwdp))
- Support idle timeouts for iOS web-preview-only sessions, resetting the timer when preview input arrives. ([#4731](https://github.com/expo/eas-cli/pull/4731) by [@gwdp](https://github.com/gwdp))
## 26.0.0
### Major Changes
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "26.0.0",
"version": "27.0.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": "26.0.0",
"@expo/eas-build-job": "26.0.0",
"@expo/downloader": "27.0.0",
"@expo/eas-build-job": "27.0.0",
"@expo/env": "^0.4.0",
"@expo/logger": "26.0.0",
"@expo/logger": "27.0.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": "26.0.0",
"@expo/template-file": "26.0.0",
"@expo/turtle-spawn": "26.0.0",
"@expo/steps": "27.0.0",
"@expo/template-file": "27.0.0",
"@expo/turtle-spawn": "27.0.0",
"@expo/xcpretty": "^4.3.1",
"@ngrok/ngrok": "1.7.0",
"@sentry/node": "7.77.0",
@@ -1,5 +1,9 @@
# create-eas-build-function
## 27.0.0
No changes in this release.
## 26.0.0
No changes in this release.
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "26.0.0",
"version": "27.0.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "26.0.0"
"@expo/steps": "27.0.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+4
View File
@@ -1,5 +1,9 @@
# @expo/downloader
## 27.0.0
No changes in this release.
## 26.0.0
No changes in this release.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/downloader",
"version": "26.0.0",
"version": "27.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -1,5 +1,9 @@
# eas-build-cache-provider
## 27.0.0
No changes in this release.
## 26.0.0
No changes in this release.
@@ -1,6 +1,6 @@
{
"name": "eas-build-cache-provider",
"version": "26.0.0",
"version": "27.0.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",
+4
View File
@@ -1,5 +1,9 @@
# @expo/eas-build-job
## 27.0.0
No changes in this release.
## 26.0.0
### Major Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "26.0.0",
"version": "27.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -25,9 +25,9 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "26.0.0",
"@expo/logger": "27.0.0",
"@expo/results": "1.0.0",
"@expo/turtle-spawn": "26.0.0",
"@expo/turtle-spawn": "27.0.0",
"joi": "^17.13.1",
"semver": "^7.6.2",
"zod": "^4.3.5"
+21
View File
@@ -1,5 +1,26 @@
# eas-cli
## 27.0.0
### Major Changes
- Write the reason a command failed to stderr, next to the `command failed.` line, instead of to stdout. A program that reads the reason from stdout must read stderr instead. ([#4592](https://github.com/expo/eas-cli/pull/4592) by [@vonovak](https://github.com/vonovak))
### Minor Changes
- Send the `permissions.account.role` from the build profile to request a role for the build's `EXPO_TOKEN`. ([#4734](https://github.com/expo/eas-cli/pull/4734) by [@wschurman](https://github.com/wschurman))
- Add `simulator:logs` to read and follow logs from an iOS simulator session and download saved logs after it stops. ([#4545](https://github.com/expo/eas-cli/pull/4545) by [@gwdp](https://github.com/gwdp))
### Patch Changes
- Show permission guidance, including `permissions.account.role` in eas.json, when a build request fails with an authorization error. ([#4760](https://github.com/expo/eas-cli/pull/4760) by [@wschurman](https://github.com/wschurman))
- Print the notice about eas-cli in project dependencies only when stderr is a terminal, so programs that read stderr get the command's error. ([#4591](https://github.com/expo/eas-cli/pull/4591) by [@vonovak](https://github.com/vonovak))
- Show account roles and credential permission limits in `eas login` when you are already logged in. ([#4759](https://github.com/expo/eas-cli/pull/4759) by [@wschurman](https://github.com/wschurman))
- Fix simulator control action help routing and show help when a flag is incomplete. ([#4681](https://github.com/expo/eas-cli/pull/4681) by [@gwdp](https://github.com/gwdp))
- Require explicit `host:` simulator-host upload paths and report missing local files. ([#4683](https://github.com/expo/eas-cli/pull/4683) by [@gwdp](https://github.com/gwdp))
Missing local absolute paths now require the `host:` prefix. Actions that require upload IDs accept local files and reject host paths with an explanation.
## 26.0.0
### Patch Changes
+213 -143
View File
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "26.0.0",
"version": "27.0.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": "26.0.0",
"@expo/eas-json": "26.0.0",
"@expo/eas-build-job": "27.0.0",
"@expo/eas-json": "27.0.0",
"@expo/env": "^1.0.0",
"@expo/experimentation": "0.0.5",
"@expo/json-file": "8.3.3",
"@expo/logger": "26.0.0",
"@expo/logger": "27.0.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": "26.0.0",
"@expo/steps": "27.0.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -44,6 +44,10 @@ jest.mock('../../../ora', () => ({
}),
}));
jest.mock('../../../utils/json');
jest.mock('fs-extra', () => {
const actual = jest.requireActual<typeof import('fs-extra')>('fs-extra');
return { ...actual, stat: jest.fn(actual.stat) };
});
type DeviceRunSessionById = DeviceRunSessionByIdQuery['deviceRunSessions']['byId'];
@@ -236,12 +240,23 @@ describe(SimulatorControl, () => {
expect(close).toHaveBeenCalledTimes(1);
});
it('explains local and explicit host upload paths without running an action', async () => {
await createCommand(['media.add', '--help', '--id', 'session-123']).runAsync();
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(
' File paths are local. Use host:/absolute/path for files on the simulator host.'
);
expect(actionRequests()).toEqual([]);
expect(close).toHaveBeenCalledTimes(1);
});
it('prints the host action schema as JSON for help', async () => {
await createCommand(['app.install', '--help', '--json', '--id', 'session-123']).runAsync();
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
action: 'app.install',
...catalog['app.install'],
description: expect.stringContaining('host:/absolute/path'),
});
expect(actionRequests()).toEqual([]);
expect(close).toHaveBeenCalledTimes(1);
@@ -328,6 +343,39 @@ describe(SimulatorControl, () => {
]);
});
it('rejects host paths for ID-only uploads and does not advertise them', async () => {
served = {
...catalog,
'app.file.upload': {
type: 'object',
properties: { uploadId: { type: 'string' } },
upload: 'uploadId',
uploadAs: 'id',
},
};
await expect(
createCommand(['app.file.upload', 'host:/tmp/a.json', '--id', 'session-123']).runAsync()
).rejects.toThrow('requires a local file');
expect(actionRequests()).toEqual([]);
await createCommand(['app.file.upload', '--help', '--id', 'session-123']).runAsync();
const lines = jest.mocked(Log.log).mock.calls.map(([line]) => String(line));
expect(lines.join('\n')).not.toContain('host:/absolute/path');
expect(lines.join('\n')).toContain('requires a local file');
await createCommand(['app.file.upload', '--help', '--json', '--id', 'session-123']).runAsync();
expect(mockPrintJsonOnlyOutput).toHaveBeenLastCalledWith(
expect.objectContaining({
description: expect.stringContaining('requires a local file'),
})
);
});
it('explains how to upload a local filename beginning with host:', async () => {
await expect(
createCommand(['media.add', 'host:photo.png', '--id', 'session-123']).runAsync()
).rejects.toThrow('./host:photo.png');
expect(actionRequests()).toEqual([]);
});
it('keeps an upload that the action goes on reading', async () => {
const localFile = path.join(tmpDir, 'feed.mp4');
await fs.writeFile(localFile, 'video bytes');
@@ -395,17 +443,65 @@ describe(SimulatorControl, () => {
expect(actionRequests()).toEqual([]);
});
it('passes a path that is not a local file through unchanged', async () => {
actionResult = ok('');
it('refuses a missing absolute local file without running an action', async () => {
const missing = path.join(tmpDir, 'missing.png');
await expect(
createCommand(['media.add', missing, '--id', 'session-123']).runAsync()
).rejects.toThrow('does not exist on this computer');
expect(actionRequests()).toEqual([]);
});
await createCommand([
'app.install',
'/Users/runner/MyApp.ipa',
'--id',
'session-123',
]).runAsync();
it('preserves a local filesystem failure without sending an action', async () => {
const denied = Object.assign(new Error('Permission denied'), { code: 'EACCES' });
const stat = jest
.mocked<(path: string) => Promise<fs.Stats>>(fs.stat)
.mockRejectedValueOnce(denied);
try {
await expect(
createCommand([
'media.add',
path.join(tmpDir, 'private.png'),
'--id',
'session-123',
]).runAsync()
).rejects.toBe(denied);
expect(actionRequests()).toEqual([]);
expect(close).toHaveBeenCalledTimes(1);
} finally {
stat
.mockReset()
.mockImplementation(jest.requireActual<typeof import('fs-extra')>('fs-extra').stat);
}
});
expect(actionRequests()).toEqual([['app.install', { path: '/Users/runner/MyApp.ipa' }]]);
it('passes an explicit host path through without uploading a matching local file', async () => {
const localFile = path.join(tmpDir, 'existing.png');
await fs.writeFile(localFile, 'local bytes');
await createCommand(['media.add', `host:${localFile}`, '--id', 'session-123']).runAsync();
expect(actionRequests()).toEqual([['media.add', { path: localFile }]]);
});
it.each(['host:relative.png', 'host:'])('rejects a non-absolute host path: %s', async value => {
await expect(
createCommand(['media.add', value, '--id', 'session-123']).runAsync()
).rejects.toThrow('absolute');
expect(actionRequests()).toEqual([]);
});
it('uploads a local filename beginning with host: when explicitly relative', async () => {
const previous = process.cwd();
await fs.writeFile(path.join(tmpDir, 'host:photo.png'), 'png bytes');
try {
process.chdir(tmpDir);
await createCommand(['media.add', './host:photo.png', '--id', 'session-123']).runAsync();
expect(actionRequests().map(([action]) => action)).toEqual([
'upload.append',
'media.add',
'upload.remove',
]);
} finally {
process.chdir(previous);
}
});
it('downloads the file an action returns', async () => {
@@ -443,7 +539,14 @@ describe(SimulatorControl, () => {
it('lists the actions as JSON when no action is provided', async () => {
await createCommand(['--id', 'session-123', '--json']).runAsync();
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(catalog);
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(
expect.objectContaining({
'app.install': expect.objectContaining({
description: expect.stringContaining('host:/absolute/path'),
}),
'hardwareKeyboard.set': catalog['hardwareKeyboard.set'],
})
);
expect(actionRequests()).toEqual([]);
});
@@ -0,0 +1,501 @@
import { Config } from '@oclif/core';
import chalk from 'chalk';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppPlatform, DeviceRunSessionStatus } from '../../../graphql/generated';
import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSessionQuery';
import { downloadSimulatorArtifactAsync } from '../../../simulator/artifacts';
import Log from '../../../log';
import { loadSimulatorEnvAsync } from '../../../simulator/env';
import {
fetchSimulatorPreviewJsonAsync,
resolveSimulatorPreviewAsync,
streamSimulatorPreviewAsync,
} from '../../../simulator/preview';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import SimulatorLogs from '../logs';
jest.mock('../../../log');
jest.mock('../../../graphql/queries/DeviceRunSessionQuery');
jest.mock('../../../simulator/artifacts', () => ({
...jest.requireActual('../../../simulator/artifacts'),
downloadSimulatorArtifactAsync: jest.fn(),
}));
jest.mock('../../../simulator/env', () => ({
...jest.requireActual('../../../simulator/env'),
loadSimulatorEnvAsync: jest.fn(),
}));
jest.mock('../../../simulator/preview', () => ({
...jest.requireActual('../../../simulator/preview'),
resolveSimulatorPreviewAsync: jest.fn(),
fetchSimulatorPreviewJsonAsync: jest.fn(),
streamSimulatorPreviewAsync: jest.fn(),
}));
jest.mock('../../../utils/json');
const mockLoadSimulatorEnvAsync = jest.mocked(loadSimulatorEnvAsync);
const mockResolvePreviewAsync = jest.mocked(resolveSimulatorPreviewAsync);
const mockFetchJsonAsync = jest.mocked(fetchSimulatorPreviewJsonAsync);
const mockStreamAsync = jest.mocked(streamSimulatorPreviewAsync);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
const mockLog = jest.mocked(Log.log);
const mockWarn = jest.mocked(Log.warn);
const mockWarnToStderr = jest.mocked(Log.warnToStderr);
const originalColorLevel = chalk.level;
const preview = {
deviceRunSessionId: 'session-id',
baseUrl: new URL('https://preview.test'),
token: 'preview-token',
};
const graphqlClient = {} as ExpoGraphqlClient;
const projectDir = '/test/project';
const line = {
seq: 7,
at: Date.parse('2026-10-05T12:00:00.000Z'),
raw: JSON.stringify({
timestamp: '2026-10-05T12:00:00.000Z',
processImagePath: '/apps/CoinFlip.app/CoinFlip',
processID: 42,
eventMessage: 'Initialization request completed.',
}),
};
function getMockOclifConfig(): Config {
const config = new Config({ root: __dirname });
config.runHook = async () => ({ failures: [], successes: [] });
return config;
}
describe(SimulatorLogs, () => {
beforeEach(() => {
jest.clearAllMocks();
process.env.EAS_SIMULATOR_SESSION_ID = 'session-id';
chalk.level = 0;
mockLoadSimulatorEnvAsync.mockResolvedValue();
jest
.mocked(DeviceRunSessionQuery.byIdAsync)
.mockResolvedValue({ id: 'session-id', status: DeviceRunSessionStatus.InProgress } as Awaited<
ReturnType<typeof DeviceRunSessionQuery.byIdAsync>
>);
mockResolvePreviewAsync.mockResolvedValue(preview);
mockFetchJsonAsync.mockResolvedValue(createSnapshot());
});
afterEach(() => {
delete process.env.EAS_SIMULATOR_SESSION_ID;
chalk.level = originalColorLevel;
});
it.each(['Error', 16, 'Fault', 17, 'Debug', 2])(
'styles OS severity %s after removing remote terminal controls',
async messageType => {
chalk.level = 1;
mockFetchJsonAsync.mockResolvedValue(
createSnapshot([
{
...line,
raw: JSON.stringify({
...JSON.parse(line.raw),
messageType,
processImagePath: '/apps/\u001b[35mCoinFlip\u001b[0m',
eventMessage: '\u001b[35mInitialization request completed.\u001b[0m\u0007',
}),
},
])
);
await createCommand(['--timestamp']).runAsync();
const output = mockLog.mock.calls[0][0];
expect(output).toContain('\u001b[36m[CoinFlip:42]\u001b[39m');
expect(output).not.toContain('\u001b[35m');
expect(output).not.toContain('\u0007');
const message = 'Initialization request completed.';
const styledMessage =
messageType === 'Debug' || messageType === 2
? chalk.dim(message)
: messageType === 'Fault' || messageType === 17
? chalk.red.bold(message)
: chalk.red(message);
expect(output).toContain(styledMessage);
}
);
it('prints the full JSON snapshot for an explicit session without starting the log source', async () => {
const snapshot = createSnapshot([line]);
mockFetchJsonAsync.mockResolvedValue(snapshot);
const command = createCommand(['--id', 'session-id', '--json']);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(DeviceRunSessionQuery.byIdAsync).toHaveBeenCalledWith(graphqlClient, 'session-id');
expect(mockResolvePreviewAsync).toHaveBeenCalledWith(
expect.objectContaining({ id: 'session-id' })
);
expect(mockFetchJsonAsync).toHaveBeenCalledWith(preview, '/logs', {
query: { scope: 'user-apps', limit: '100', snapshot: '1' },
});
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
deviceRunSessionId: 'session-id',
...snapshot,
});
expect(mockLog).not.toHaveBeenCalled();
expect(mockWarn).not.toHaveBeenCalled();
expect(mockStreamAsync).not.toHaveBeenCalled();
});
it('uses the dotenv session ID when no ID is provided', async () => {
mockLoadSimulatorEnvAsync.mockImplementation(async () => {
process.env.EAS_SIMULATOR_SESSION_ID = 'dotenv-id';
});
await createCommand(['--json']).runAsync();
expect(mockLoadSimulatorEnvAsync).toHaveBeenCalledWith(projectDir);
expect(DeviceRunSessionQuery.byIdAsync).toHaveBeenCalledWith(graphqlClient, 'dotenv-id');
});
it('formats app messages and forwards scope and buffer limit', async () => {
mockFetchJsonAsync.mockResolvedValue(createSnapshot([line]));
await createCommand(['--scope', 'all', '--limit', '3']).runAsync();
expect(mockFetchJsonAsync).toHaveBeenCalledWith(preview, '/logs', {
query: { scope: 'all', limit: '3', snapshot: '1' },
});
expect(mockLog).toHaveBeenCalledWith('[CoinFlip:42] Initialization request completed.');
});
it('shows original timestamps for parsed and unparsed snapshot lines when requested', async () => {
mockFetchJsonAsync.mockResolvedValue(
createSnapshot([line, { ...line, raw: '\u001b[31mraw message\u001b[0m\u0007' }])
);
await createCommand(['--timestamp']).runAsync();
expect(mockLog).toHaveBeenNthCalledWith(
1,
'2026-10-05T12:00:00.000Z [CoinFlip:42] Initialization request completed.'
);
expect(mockLog).toHaveBeenLastCalledWith('2026-10-05T12:00:00.000Z raw message');
});
it('prints unparsed lines safely and reports an unhealthy log source', async () => {
mockFetchJsonAsync.mockResolvedValue({
...createSnapshot([{ ...line, raw: '\u001b[31mraw message\u001b[0m\u0007' }]),
streamError: '\u001b[31mlog source restarting\u001b[0m\u0007',
});
await createCommand([]).runAsync();
expect(mockLog).toHaveBeenCalledWith('raw message');
expect(mockWarn).toHaveBeenCalledWith('log source restarting');
});
it('reports an unhealthy log source without claiming the buffer is simply empty', async () => {
mockFetchJsonAsync.mockResolvedValue({
...createSnapshot(),
streamError: 'log source restarting',
});
await createCommand([]).runAsync();
expect(mockWarn).toHaveBeenCalledWith('log source restarting');
expect(mockLog).not.toHaveBeenCalled();
});
it('keeps the log source error in JSON output instead of warning', async () => {
const snapshot = { ...createSnapshot(), streamError: 'log source restarting' };
mockFetchJsonAsync.mockResolvedValue(snapshot);
await createCommand(['--json']).runAsync();
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
deviceRunSessionId: 'session-id',
...snapshot,
});
expect(mockWarn).not.toHaveBeenCalled();
});
it('uses the current time for a snapshot line with an invalid time', async () => {
const now = jest.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-10-05T13:00:00.000Z'));
mockFetchJsonAsync.mockResolvedValue(
createSnapshot([line, { ...line, at: 1e300, raw: 'raw message' }])
);
await createCommand(['--timestamp']).runAsync();
expect(mockLog).toHaveBeenLastCalledWith('2026-10-05T13:00:00.000Z raw message');
now.mockRestore();
});
it('explains that an empty snapshot needs following before actions', async () => {
await createCommand([]).runAsync();
expect(mockLog).toHaveBeenCalledWith(
'No buffered logs. Use --follow to collect logs before performing actions.'
);
});
it('formats replayed and new raw SSE logs without requiring envelopes', async () => {
mockStreamAsync.mockImplementation(async (_preview, _path, onData) => {
onData(line.raw);
onData('\u001b[31mnew message\u001b[0m\u0007');
});
await createCommand(['--id', 'session-id', '--follow']).runAsync();
expect(mockStreamAsync).toHaveBeenCalledWith(preview, '/logs', expect.any(Function), {
query: { scope: 'user-apps', limit: '100' },
signal: expect.any(AbortSignal),
});
expect(mockLog).toHaveBeenCalledTimes(2);
expect(mockLog).toHaveBeenNthCalledWith(1, '[CoinFlip:42] Initialization request completed.');
expect(mockLog).toHaveBeenLastCalledWith('new message');
expect(mockFetchJsonAsync).not.toHaveBeenCalled();
expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled();
});
it('uses server envelopes for accurate timestamps while following unparsed logs', async () => {
mockStreamAsync.mockImplementation(async (_preview, _path, onData) => {
onData(JSON.stringify(line));
onData(JSON.stringify({ ...line, at: line.at + 1_000, raw: 'new message' }));
});
await createCommand(['--follow', '--timestamp']).runAsync();
expect(mockStreamAsync).toHaveBeenCalledWith(preview, '/logs', expect.any(Function), {
query: { scope: 'user-apps', limit: '100', envelope: '1' },
signal: expect.any(AbortSignal),
});
expect(mockLog).toHaveBeenNthCalledWith(
1,
'2026-10-05T12:00:00.000Z [CoinFlip:42] Initialization request completed.'
);
expect(mockLog).toHaveBeenLastCalledWith('2026-10-05T12:00:01.000Z new message');
});
it('prints a frame that is not an envelope instead of ending the stream', async () => {
mockStreamAsync.mockImplementation(async (_preview, _path, onData) => {
onData('\u001b[31mnot an envelope\u001b[0m');
onData(JSON.stringify({ seq: 8 }));
onData(JSON.stringify(line));
});
await createCommand(['--follow', '--timestamp']).runAsync();
expect(mockLog).toHaveBeenCalledTimes(3);
expect(mockLog).toHaveBeenNthCalledWith(1, 'not an envelope');
expect(mockLog).toHaveBeenNthCalledWith(2, '{"seq":8}');
expect(mockLog).toHaveBeenLastCalledWith(
'2026-10-05T12:00:00.000Z [CoinFlip:42] Initialization request completed.'
);
});
it.each([
JSON.stringify({ ...line, at: 'not-a-number' }),
JSON.stringify({ ...line, at: null }),
JSON.stringify(line).replace(String(line.at), '1e400'),
JSON.stringify({ ...line, at: 1e300 }),
])('prints an envelope with an invalid time instead of ending the stream %#', async frame => {
mockStreamAsync.mockImplementation(async (_preview, _path, onData) => {
onData(frame);
});
await createCommand(['--follow', '--timestamp']).runAsync();
expect(mockLog).toHaveBeenCalledWith(frame);
});
it('reports a failed log source while its stream only sends heartbeats', async () => {
jest.useFakeTimers();
try {
mockFetchJsonAsync
.mockResolvedValueOnce({
...createSnapshot(),
status: 'restarting',
streamError: '\u001b[31mlog stream exited (code 1, signal null)\u001b[0m',
})
.mockResolvedValueOnce({
...createSnapshot(),
status: 'restarting',
streamError: 'log stream exited (code 1, signal null)',
})
.mockResolvedValueOnce(createSnapshot());
// A connected stream that only receives heartbeats never calls onData.
mockStreamAsync.mockImplementation(async () => {
await jest.advanceTimersByTimeAsync(30_000);
});
await createCommand(['--follow', '--scope', 'all']).runAsync();
expect(mockFetchJsonAsync).toHaveBeenCalledTimes(3);
expect(mockFetchJsonAsync).toHaveBeenCalledWith(preview, '/logs', {
query: { scope: 'all', limit: '1', snapshot: '1' },
});
// Both notices go to stderr, so `--follow > logs.txt` keeps only log lines.
expect(mockWarnToStderr.mock.calls).toEqual([
[
"Simulator logs are not being collected. The session's log source stopped: log stream exited (code 1, signal null). It restarts automatically, and logs from this gap are missing.",
],
['Simulator log collection resumed.'],
]);
expect(mockWarn).not.toHaveBeenCalled();
expect(mockLog).not.toHaveBeenCalled();
} finally {
jest.useRealTimers();
}
});
it('stops checking the log source when following stops', async () => {
jest.useFakeTimers();
try {
mockStreamAsync.mockResolvedValue();
await createCommand(['--follow']).runAsync();
await jest.advanceTimersByTimeAsync(30_000);
expect(mockFetchJsonAsync).not.toHaveBeenCalled();
} finally {
jest.useRealTimers();
}
});
it('stops following on Ctrl+C and removes its interrupt handler', async () => {
const listeners = process.listeners('SIGINT');
let signal: AbortSignal | undefined;
mockStreamAsync.mockImplementation(async (_preview, _path, _onData, options) => {
signal = options?.signal;
process.emit('SIGINT');
});
await createCommand(['--follow']).runAsync();
expect(signal?.aborted).toBe(true);
expect(process.listeners('SIGINT')).toEqual(listeners);
});
it('rejects JSON follow output before resolving a session', async () => {
await expect(createCommand(['--json', '--follow']).runAsync()).rejects.toThrow(
'--json=true cannot also be provided when using --follow'
);
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockLoadSimulatorEnvAsync).not.toHaveBeenCalled();
expect(mockResolvePreviewAsync).not.toHaveBeenCalled();
});
it.each([DeviceRunSessionStatus.Stopped, DeviceRunSessionStatus.Errored])(
'downloads logs for a %s session without accessing preview',
async status => {
const session = { id: 'session-id', status } as Awaited<
ReturnType<typeof DeviceRunSessionQuery.byIdAsync>
>;
jest.mocked(DeviceRunSessionQuery.byIdAsync).mockResolvedValue(session);
await createCommand(['--artifact', '2', '--output', 'logs.ndjson', '--json']).runAsync();
expect(downloadSimulatorArtifactAsync).toHaveBeenCalledWith(session, 'simulator-log', {
artifact: 2,
output: 'logs.ndjson',
nonInteractive: true,
json: true,
});
expect(mockResolvePreviewAsync).not.toHaveBeenCalled();
expect(DeviceRunSessionQuery.byIdAsync).toHaveBeenCalledTimes(1);
}
);
it('rejects following a stopped session with a download action', async () => {
jest
.mocked(DeviceRunSessionQuery.byIdAsync)
.mockResolvedValue({ id: 'session-id', status: DeviceRunSessionStatus.Stopped } as Awaited<
ReturnType<typeof DeviceRunSessionQuery.byIdAsync>
>);
await expect(createCommand(['--follow']).runAsync()).rejects.toThrow('Use --output <path>');
expect(mockResolvePreviewAsync).not.toHaveBeenCalled();
});
it.each([
['--artifact', '1'],
['--output', 'logs.ndjson'],
])('rejects artifact flags on a running session %s', async (...args) => {
await expect(createCommand(args).runAsync()).rejects.toThrow(
'only available for stopped sessions'
);
expect(mockFetchJsonAsync).not.toHaveBeenCalled();
});
it.each([[], ['--follow'], ['--json']])(
'explains that a live Android session has no logs %s',
async (...args) => {
jest.mocked(DeviceRunSessionQuery.byIdAsync).mockResolvedValue({
id: 'session-id',
status: DeviceRunSessionStatus.InProgress,
platform: AppPlatform.Android,
} as Awaited<ReturnType<typeof DeviceRunSessionQuery.byIdAsync>>);
await expect(createCommand(args).runAsync()).rejects.toThrow(
'Live logs are only available for iOS simulator sessions.'
);
expect(mockResolvePreviewAsync).not.toHaveBeenCalled();
expect(mockFetchJsonAsync).not.toHaveBeenCalled();
expect(mockStreamAsync).not.toHaveBeenCalled();
}
);
it.each(['0', '-1'])('rejects a nonpositive buffer limit %s', async limit => {
await expect(createCommand(['--limit', limit]).runAsync()).rejects.toThrow();
expect(mockResolvePreviewAsync).not.toHaveBeenCalled();
});
it('rejects invalid scopes', async () => {
await expect(createCommand(['--scope', 'system']).runAsync()).rejects.toThrow();
expect(mockResolvePreviewAsync).not.toHaveBeenCalled();
});
it('stops before contacting the preview when there is no session', async () => {
delete process.env.EAS_SIMULATOR_SESSION_ID;
await expect(createCommand([]).runAsync()).rejects.toThrow('No simulator session ID provided.');
expect(mockResolvePreviewAsync).not.toHaveBeenCalled();
expect(mockFetchJsonAsync).not.toHaveBeenCalled();
expect(mockStreamAsync).not.toHaveBeenCalled();
});
it('propagates preview errors without printing an empty result', async () => {
mockFetchJsonAsync.mockRejectedValue(new Error('The simulator preview refused access.'));
await expect(createCommand([]).runAsync()).rejects.toThrow(
'The simulator preview refused access.'
);
expect(mockLog).not.toHaveBeenCalled();
});
});
function createCommand(args: string[]): SimulatorLogs {
const command = new SimulatorLogs(args, getMockOclifConfig());
// @ts-expect-error getContextAsync is protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
loggedIn: { graphqlClient },
projectDir,
});
return command;
}
function createSnapshot(lines: (typeof line)[] = []): object {
return {
device: 'device-id',
latestSeq: 7,
oldestSeq: 1,
bufferedBytes: 200,
status: 'streaming',
streamError: null,
lines,
};
}
@@ -148,7 +148,11 @@ export default class SimulatorControl extends EasCommand {
private printCatalog(catalog: HostActionCatalog, jsonFlag: boolean): void {
if (jsonFlag) {
printJsonOnlyOutput(catalog);
printJsonOnlyOutput(
Object.fromEntries(
Object.entries(catalog).map(([name, entry]) => [name, withUploadPathHelp(entry)])
)
);
return;
}
const names = Object.keys(catalog).sort();
@@ -167,7 +171,7 @@ export default class SimulatorControl extends EasCommand {
private printActionHelp(action: string, entry: HostActionCatalogEntry, jsonFlag: boolean): void {
if (jsonFlag) {
printJsonOnlyOutput({ ...entry, action });
printJsonOnlyOutput({ ...withUploadPathHelp(entry), action });
return;
}
Log.newLine();
@@ -198,6 +202,11 @@ export default class SimulatorControl extends EasCommand {
' Action arguments refer to the simulator host. Use --output for the local filename.'
);
}
const uploadHelp = uploadPathHelp(entry);
if (uploadHelp) {
Log.newLine();
Log.log(` ${uploadHelp}`);
}
Log.newLine();
}
@@ -270,8 +279,24 @@ export default class SimulatorControl extends EasCommand {
}
}
function uploadPathHelp(entry: HostActionCatalogEntry): string | undefined {
if (!entry.upload) {
return undefined;
}
return entry.uploadAs === 'id'
? 'This action requires a local file to upload. host: paths are not supported.'
: 'File paths are local. Use host:/absolute/path for files on the simulator host.';
}
function withUploadPathHelp(entry: HostActionCatalogEntry): HostActionCatalogEntry {
const help = uploadPathHelp(entry);
return help
? { ...entry, description: [entry.description, help].filter(Boolean).join('\n\n') }
: entry;
}
async function findLocalUploadAsync(
{ upload: param, uploadWhen = {} }: HostActionCatalogEntry,
{ upload: param, uploadWhen = {}, uploadAs }: HostActionCatalogEntry,
params: Record<string, unknown>
): Promise<{ param: string; path: string } | null> {
const value = param ? params[param] : undefined;
@@ -279,13 +304,33 @@ async function findLocalUploadAsync(
if (!param || typeof value !== 'string' || !applies) {
return null;
}
const stat = await fs.stat(value).catch(() => null);
if (value.startsWith('host:')) {
if (uploadAs === 'id') {
throw new Error(
'This action requires a local file to upload; host: paths are not supported.'
);
}
const hostPath = value.slice('host:'.length);
if (!path.posix.isAbsolute(hostPath)) {
throw new Error(
`A host: path must be absolute, for example host:/tmp/app.tar.gz. For a local filename beginning with host:, use ./${value}.`
);
}
params[param] = hostPath;
return null;
}
const stat = await fs.stat(value).catch((error: NodeJS.ErrnoException) => {
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
return null;
}
throw error;
});
if (stat?.isDirectory()) {
throw new Error(`${value} is a folder. Pass the path of a file.`);
}
if (!stat && !path.isAbsolute(value)) {
if (!stat) {
throw new Error(
`${value} does not exist on this computer. Pass the path of a local file, or an absolute path on the simulator host.`
`${value} does not exist on this computer. Pass a local file, or prefix an absolute simulator-host path with host:.`
);
}
return stat ? { param, path: value } : null;
@@ -0,0 +1,251 @@
import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { AppPlatform } from '../../graphql/generated';
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
import Log from '../../log';
import { downloadSimulatorArtifactAsync, hasSimulatorArtifacts } from '../../simulator/artifacts';
import {
EAS_SIMULATOR_SESSION_ID,
SIMULATOR_DOTENV_FILE_NAME,
loadSimulatorEnvAsync,
} from '../../simulator/env';
import {
type SimulatorPreview,
fetchSimulatorPreviewJsonAsync,
resolveSimulatorPreviewAsync,
streamSimulatorPreviewAsync,
} from '../../simulator/preview';
import {
formatLogLine,
isValidLogTime,
stripTerminalControlCharacters,
} from '../../simulator/utils';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
// serve-sim keeps sending heartbeats while its log source restarts, so the stream alone cannot show a failure.
const LOG_SOURCE_HEALTH_CHECK_INTERVAL_MS = 10_000;
interface SimulatorLogLine {
seq: number;
at: number;
raw: string;
}
interface SimulatorLogsSnapshot {
device: string;
latestSeq: number;
oldestSeq: number;
bufferedBytes: number;
status: string;
streamError: string | null;
lines: SimulatorLogLine[];
}
export default class SimulatorLogs extends EasCommand {
static override aliases = ['sim:logs'];
static override description =
'[EXPERIMENTAL] show buffered logs from a remote iOS simulator session';
static override examples = [
'<%= config.bin %> simulator:logs --follow',
'<%= config.bin %> simulator:logs --json',
];
static override flags = {
id: Flags.string({
description: `Simulator session ID. Defaults to ${SIMULATOR_DOTENV_FILE_NAME}.`,
}),
artifact: Flags.integer({
min: 1,
description: 'Artifact index to download from a stopped session (1-based).',
}),
output: Flags.string({
char: 'o',
description: 'Save a stopped session artifact to this path.',
}),
follow: Flags.boolean({
char: 'f',
description: 'Stream new logs. Start following before performing actions to collect them.',
exclusive: ['json'],
}),
scope: Flags.option({
description: 'Show user app logs or all device logs while the session runs.',
options: ['user-apps', 'all'] as const,
default: 'user-apps',
})(),
limit: Flags.integer({
description: 'Maximum number of buffered log lines to show while the session runs.',
default: 100,
min: 1,
}),
timestamp: Flags.boolean({
description: 'Show timestamps in live human-readable log output.',
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
...this.ContextOptions.ProjectDir,
};
async runAsync(): Promise<void> {
// Before parsing, so only the JSON result reaches stdout.
if (this.argv.includes('--json')) {
enableJsonOutput();
}
const { flags } = await this.parse(SimulatorLogs);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
projectDir,
loggedIn: { graphqlClient },
} = await this.getContextAsync(SimulatorLogs, { nonInteractive });
await loadSimulatorEnvAsync(projectDir);
const deviceRunSessionId = flags.id ?? process.env[EAS_SIMULATOR_SESSION_ID];
if (!deviceRunSessionId) {
throw new Error(
`No simulator session ID provided. Pass --id, or run \`eas simulator:start\` first to write ${SIMULATOR_DOTENV_FILE_NAME}.`
);
}
const session = await DeviceRunSessionQuery.byIdAsync(graphqlClient, deviceRunSessionId);
if (hasSimulatorArtifacts(session)) {
if (flags.follow) {
throw new Error(
'The session has stopped. --follow is only available while it runs. Use --output <path> to download its logs artifact.'
);
}
await downloadSimulatorArtifactAsync(session, 'simulator-log', {
artifact: flags.artifact,
output: flags.output,
nonInteractive,
json: jsonFlag,
});
return;
}
if (session.platform === AppPlatform.Android) {
throw new Error(
'Live logs are only available for iOS simulator sessions. This session runs an Android emulator, whose preview server does not stream logs. Start an iOS session with `eas simulator:start --platform ios` to read logs.'
);
}
const preview = await resolveSimulatorPreviewAsync(session);
if (flags.artifact !== undefined || flags.output !== undefined) {
throw new Error(
'--artifact and --output are only available for stopped sessions. To save live logs, follow them into a file: `eas simulator:logs --follow > logs.txt`.'
);
}
const query = { scope: flags.scope, limit: String(flags.limit) };
if (flags.follow) {
const abortController = new AbortController();
const interruptHandler = (): void => {
if (abortController.signal.aborted) {
process.exit(130);
}
abortController.abort();
};
process.on('SIGINT', interruptHandler);
const stopHealthChecks = watchLogSourceHealth(preview, flags.scope, abortController.signal);
try {
await streamSimulatorPreviewAsync(
preview,
'/logs',
data => {
Log.log(flags.timestamp ? formatLogEnvelope(data) : formatLogLine(data));
},
{
query: { ...query, ...(flags.timestamp ? { envelope: '1' } : {}) },
signal: abortController.signal,
}
);
} finally {
stopHealthChecks();
process.removeListener('SIGINT', interruptHandler);
}
return;
}
const snapshot = await fetchSimulatorPreviewJsonAsync<SimulatorLogsSnapshot>(preview, '/logs', {
query: { ...query, snapshot: '1' },
});
if (!jsonFlag && snapshot.streamError) {
Log.warn(stripTerminalControlCharacters(snapshot.streamError));
}
if (jsonFlag) {
printJsonOnlyOutput({ deviceRunSessionId: preview.deviceRunSessionId, ...snapshot });
return;
}
if (snapshot.lines.length === 0) {
// The warning above already explains why the buffer can be empty.
if (!snapshot.streamError) {
Log.log('No buffered logs. Use --follow to collect logs before performing actions.');
}
return;
}
for (const line of snapshot.lines) {
Log.log(formatLogLine(line.raw, flags.timestamp, line.at));
}
}
}
function formatLogEnvelope(data: string): string {
let line: Partial<SimulatorLogLine> | undefined;
try {
line = JSON.parse(data);
} catch {}
return typeof line?.raw === 'string' && isValidLogTime(line.at)
? formatLogLine(line.raw, true, line.at)
: formatLogLine(data);
}
/** Polls the log source while following and reports when it stops or recovers. Returns a stop function. */
function watchLogSourceHealth(
preview: SimulatorPreview,
scope: string,
signal: AbortSignal
): () => void {
let failing = false;
let checking = false;
const checkAsync = async (): Promise<void> => {
checking = true;
try {
const { status, streamError } = await fetchSimulatorPreviewJsonAsync<SimulatorLogsSnapshot>(
preview,
'/logs',
{ query: { scope, limit: '1', snapshot: '1' } }
);
if (signal.aborted) {
return;
}
if (!failing && status !== 'streaming') {
failing = true;
const reason = streamError ? `: ${stripTerminalControlCharacters(streamError)}` : '';
// stderr, so the notice stays out of `--follow > logs.txt`.
Log.warnToStderr(
`Simulator logs are not being collected. The session's log source stopped${reason}. It restarts automatically, and logs from this gap are missing.`
);
} else if (failing && status === 'streaming' && !streamError) {
failing = false;
Log.warnToStderr('Simulator log collection resumed.');
}
} catch {
// The stream itself reports connection failures.
} finally {
checking = false;
}
};
const timer = setInterval(() => {
if (!checking) {
void checkAsync();
}
}, LOG_SOURCE_HEALTH_CHECK_INTERVAL_MS);
return () => {
clearInterval(timer);
};
}
+6
View File
@@ -45,6 +45,12 @@ export default class Log {
console.error(...Log.withTextColor(args, chalk.stderr.red));
}
public static warnToStderr(...args: any[]): void {
Log.updateIsLastLineNewLine(args);
// eslint-disable-next-line no-console
console.error(...Log.withTextColor(args, chalk.stderr.yellow));
}
public static warn(...args: any[]): void {
Log.consoleLog(...Log.withTextColor(args, chalk.yellow));
}
@@ -0,0 +1,235 @@
import fs from 'fs-extra';
import { createServer } from 'node:http';
import { type AddressInfo } from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { Readable } from 'node:stream';
import fetch, { Response } from '../../fetch';
import { DeviceRunSessionStatus } from '../../graphql/generated';
import Log from '../../log';
import { promptAsync, selectAsync } from '../../prompts';
import { printJsonOnlyOutput } from '../../utils/json';
import {
type SimulatorSession,
downloadSimulatorArtifactAsync,
hasSimulatorArtifacts,
} from '../artifacts';
import { downloadSimulatorFileAsync } from '../download';
jest.mock('../../fetch');
jest.mock('../../log');
jest.mock('../../ora', () => ({
ora: () => ({ start: () => ({ succeed: jest.fn(), fail: jest.fn() }) }),
}));
jest.mock('../../prompts');
jest.mock('../../utils/json');
jest.mock('../download');
function artifact(
id: string,
type = 'simulator-log',
createdAt = '2026-10-07T12:00:00Z'
): SimulatorSession['artifacts'][number] {
return {
id,
name: `Log ${id}`,
filename: `${id}.ndjson`,
downloadUrl: `https://storage.test/${id}?token=secret`,
createdAt,
updatedAt: createdAt,
fileSizeBytes: 100,
metadata: { __eas_type: type, udid: 'SIM-A', part: 2 },
};
}
const first = artifact('a');
function session(artifacts = [first]): SimulatorSession {
return {
id: 'session-id',
status: DeviceRunSessionStatus.Stopped,
artifacts,
} as SimulatorSession;
}
const options = { output: 'logs.ndjson', nonInteractive: true, json: false };
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(downloadSimulatorFileAsync).mockResolvedValue('/logs.ndjson');
});
it.each([DeviceRunSessionStatus.Stopped, DeviceRunSessionStatus.Errored])(
'uses artifacts for %s',
status => {
expect(hasSimulatorArtifacts({ ...session(), status })).toBe(true);
}
);
it.each([
DeviceRunSessionStatus.InProgress,
DeviceRunSessionStatus.Queued,
DeviceRunSessionStatus.Starting,
])('does not use artifacts for %s', status => {
expect(hasSimulatorArtifacts({ ...session(), status })).toBe(false);
});
it('reports missing artifacts without contacting storage', async () => {
await expect(
downloadSimulatorArtifactAsync(
session([artifact('a', 'session-events')]),
'simulator-log',
options
)
).rejects.toThrow('No matching artifacts');
expect(downloadSimulatorFileAsync).not.toHaveBeenCalled();
});
it('automatically selects one artifact and prints its saved path', async () => {
await downloadSimulatorArtifactAsync(session(), 'simulator-log', options);
expect(downloadSimulatorFileAsync).toHaveBeenCalledWith('logs.ndjson', expect.any(Function));
expect(Log.log).toHaveBeenCalledWith('Saved Log a to /logs.ndjson.');
expect(selectAsync).not.toHaveBeenCalled();
});
it('sorts available indexes, sanitizes labels, and requires explicit selection noninteractively', async () => {
const older = {
...artifact('b', 'simulator-log', '2026-10-07T11:00:00Z'),
name: '\u001b[31mEarlier\u001b[0m\u0007',
};
await expect(
downloadSimulatorArtifactAsync(session([first, older]), 'simulator-log', options)
).rejects.toThrow(
'1. Earlier — b.ndjson (100 B) (SIM-A) (part 2)\n2. Log a — a.ndjson (100 B) (SIM-A) (part 2)'
);
expect(downloadSimulatorFileAsync).not.toHaveBeenCalled();
});
it.each([0, -1, 3, 1.5])('rejects invalid index %s and lists indexes', async index => {
await expect(
downloadSimulatorArtifactAsync(session([first, artifact('b')]), 'simulator-log', {
...options,
artifact: index,
})
).rejects.toThrow('out of range. Choose an artifact with --artifact');
expect(downloadSimulatorFileAsync).not.toHaveBeenCalled();
});
it('uses the explicit 1-based index and returns metadata without storage credentials', async () => {
const second = artifact('b');
await downloadSimulatorArtifactAsync(session([second, first]), 'simulator-log', {
...options,
artifact: 2,
json: true,
});
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
deviceRunSessionId: 'session-id',
artifact: {
id: second.id,
name: second.name,
filename: second.filename,
metadata: second.metadata,
},
filePath: '/logs.ndjson',
});
expect(Log.log).not.toHaveBeenCalled();
expect(JSON.stringify(jest.mocked(printJsonOnlyOutput).mock.calls)).not.toContain('secret');
});
it('prompts for a selection and destination with a basename default', async () => {
jest.mocked(selectAsync).mockResolvedValue(2);
jest.mocked(promptAsync).mockResolvedValue({ filePath: 'chosen.ndjson' });
await downloadSimulatorArtifactAsync(
session([first, { ...artifact('b'), filename: '../saved.ndjson' }]),
'simulator-log',
{ nonInteractive: false, json: false }
);
expect(selectAsync).toHaveBeenCalledWith('Select an artifact to download:', [
{ title: '1. Log a — a.ndjson (100 B) (SIM-A) (part 2)', value: 1 },
{ title: '2. Log b — ../saved.ndjson (100 B) (SIM-A) (part 2)', value: 2 },
]);
expect(promptAsync).toHaveBeenCalledWith(expect.objectContaining({ initial: 'saved.ndjson' }));
expect(downloadSimulatorFileAsync).toHaveBeenCalledWith('chosen.ndjson', expect.any(Function));
});
it('requires output without prompting in noninteractive mode', async () => {
await expect(
downloadSimulatorArtifactAsync(session(), 'simulator-log', { nonInteractive: true, json: true })
).rejects.toThrow('Pass --output');
expect(promptAsync).not.toHaveBeenCalled();
expect(downloadSimulatorFileAsync).not.toHaveBeenCalled();
});
it('downloads from storage without a preview bearer or redirect restrictions', async () => {
const response = new Response(Readable.from(['logs']));
jest.mocked(fetch).mockResolvedValue(response);
jest.mocked(downloadSimulatorFileAsync).mockImplementation(async (_output, getResponse) => {
await expect(getResponse(new AbortController().signal)).resolves.toBe(response);
return '/logs.ndjson';
});
await downloadSimulatorArtifactAsync(session(), 'simulator-log', options);
expect(fetch).toHaveBeenCalledWith(first.downloadUrl, {
signal: expect.any(AbortSignal),
timeout: 30_000,
});
});
it('redacts signed URLs from storage failures', async () => {
jest.mocked(fetch).mockRejectedValue(new Error(first.downloadUrl));
jest.mocked(downloadSimulatorFileAsync).mockImplementation(async (_output, getResponse) => {
await getResponse(new AbortController().signal);
return '/logs.ndjson';
});
const downloading = downloadSimulatorArtifactAsync(session(), 'simulator-log', options);
await expect(downloading).rejects.toThrow('Could not download the session artifact');
await expect(downloading).rejects.not.toThrow('secret');
});
it('closes an unfinished HTTP error response without exposing storage credentials', async () => {
const actualFetch = jest.requireActual<typeof import('../../fetch')>('../../fetch').default;
const actualDownload = jest.requireActual<typeof import('../download')>('../download');
jest.mocked(fetch).mockImplementation(actualFetch);
jest
.mocked(downloadSimulatorFileAsync)
.mockImplementation(actualDownload.downloadSimulatorFileAsync);
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'sim-artifact-error-'));
let markClosed = (): void => {};
const closed = new Promise<void>(resolve => {
markClosed = resolve;
});
const server = createServer((_request, response) => {
response.once('close', markClosed);
response.writeHead(403);
response.write('secret storage token');
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const failedArtifact = {
...first,
downloadUrl: `http://127.0.0.1:${(server.address() as AddressInfo).port}/?token=secret`,
};
let expired = false;
const deadline = setTimeout(() => {
expired = true;
server.closeAllConnections();
}, 2_000);
try {
const downloading = downloadSimulatorArtifactAsync(session([failedArtifact]), 'simulator-log', {
...options,
output: path.join(directory, 'logs.ndjson'),
});
await expect(downloading).rejects.toThrow('Could not download the session artifact');
await expect(downloading).rejects.not.toThrow('secret');
await closed;
expect(expired).toBe(false);
expect(await fs.readdir(directory)).toEqual([]);
} finally {
clearTimeout(deadline);
server.closeAllConnections();
await new Promise<void>(resolve =>
server.close(() => {
resolve();
})
);
await fs.remove(directory);
}
});
@@ -0,0 +1,133 @@
import fs from 'fs-extra';
import { createServer } from 'node:http';
import { type AddressInfo } from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { PassThrough, Readable } from 'node:stream';
import fetch, { Response } from '../../fetch';
import { downloadSimulatorFileAsync } from '../download';
let directory: string;
let output: string;
beforeEach(async () => {
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'sim-artifact-test-'));
output = path.join(directory, 'capture.har');
});
afterEach(async () => {
await fs.remove(directory);
});
it('writes private files and removes its interrupt listener after success', async () => {
const listeners = process.listeners('SIGINT');
await expect(
downloadSimulatorFileAsync(output, async () => new Response(Readable.from(['private'])))
).resolves.toBe(output);
expect(await fs.readFile(output, 'utf8')).toBe('private');
expect((await fs.stat(output)).mode & 0o777).toBe(0o600);
expect(process.listeners('SIGINT')).toEqual(listeners);
});
it('preserves an existing output file', async () => {
await fs.writeFile(output, 'existing');
await expect(
downloadSimulatorFileAsync(output, async () => new Response(Readable.from(['replacement'])))
).rejects.toThrow('already exists');
expect(await fs.readFile(output, 'utf8')).toBe('existing');
});
it('refuses a symlink without changing its target', async () => {
const target = path.join(directory, 'target');
await fs.writeFile(target, 'existing');
await fs.symlink(target, output);
await expect(
downloadSimulatorFileAsync(output, async () => new Response(Readable.from(['replacement'])))
).rejects.toThrow('already exists');
expect(await fs.readFile(target, 'utf8')).toBe('existing');
expect((await fs.lstat(output)).isSymbolicLink()).toBe(true);
});
it('preserves a file created by a competing process during the request', async () => {
await expect(
downloadSimulatorFileAsync(output, async () => {
await fs.writeFile(output, 'competitor');
return new Response(Readable.from(['replacement']));
})
).rejects.toThrow('already exists');
expect(await fs.readFile(output, 'utf8')).toBe('competitor');
});
it('removes only its partial file after a body failure', async () => {
const body = new PassThrough();
const downloading = downloadSimulatorFileAsync(output, async () => new Response(body));
body.write('partial');
while (!(await fs.pathExists(output))) {
await new Promise(resolve => setImmediate(resolve));
}
body.destroy(new Error('secret storage URL'));
await expect(downloading).rejects.toThrow('Could not save the download');
expect(await fs.pathExists(output)).toBe(false);
});
it('removes the file on an immediate response failure', async () => {
const body = Readable.from(
(async function* () {
yield 'partial';
throw new Error('secret storage URL');
})()
);
const downloading = downloadSimulatorFileAsync(output, async () => new Response(body));
await expect(downloading).rejects.toThrow('Could not save the download');
expect(await fs.readdir(directory)).toEqual([]);
});
it('preserves existing files on request failure', async () => {
await expect(
downloadSimulatorFileAsync(output, async () => {
await fs.writeFile(output, 'existing');
throw new Error('request failed');
})
).rejects.toThrow('request failed');
expect(await fs.readFile(output, 'utf8')).toBe('existing');
});
it('follows storage redirects and cancels a real HTTP download with full cleanup', async () => {
let markClosed: () => void = () => {};
const closed = new Promise<void>(resolve => {
markClosed = resolve;
});
const server = createServer((request, response) => {
if (request.url === '/redirect') {
response.writeHead(302, { location: '/body' });
response.end();
} else {
response.once('close', markClosed);
response.writeHead(200);
response.write('partial');
}
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}/redirect`;
const listeners = process.listeners('SIGINT');
try {
const downloading = downloadSimulatorFileAsync(
output,
async signal => await fetch(url, { signal })
);
while (!(await fs.pathExists(output))) {
await new Promise(resolve => setImmediate(resolve));
}
process.emit('SIGINT');
await expect(downloading).rejects.toThrow('canceled');
await closed;
expect(await fs.pathExists(output)).toBe(false);
expect(process.listeners('SIGINT')).toEqual(listeners);
} finally {
server.closeAllConnections();
await new Promise<void>(resolve =>
server.close(() => {
resolve();
})
);
}
});
@@ -0,0 +1,473 @@
import { createServer } from 'node:http';
import { type AddressInfo } from 'node:net';
import { PassThrough, Readable } from 'node:stream';
import fetch, { Headers, RequestError, Response } from '../../fetch';
import { DeviceRunSessionByIdQuery, DeviceRunSessionStatus } from '../../graphql/generated';
import {
fetchSimulatorPreviewAsync,
fetchSimulatorPreviewJsonAsync,
resolveSimulatorPreviewAsync,
streamSimulatorPreviewAsync,
} from '../preview';
jest.mock('../../fetch', () => ({
__esModule: true,
...jest.requireActual('../../fetch'),
default: jest.fn(),
}));
const preview = {
deviceRunSessionId: 'session-id',
baseUrl: new URL('https://preview.test'),
token: 'secret-token',
};
function session(
remoteConfig: unknown,
status = DeviceRunSessionStatus.InProgress
): DeviceRunSessionByIdQuery['deviceRunSessions']['byId'] {
return {
id: 'session-id',
status,
remoteConfig,
} as DeviceRunSessionByIdQuery['deviceRunSessions']['byId'];
}
function rejectWithStatus(status: number): void {
jest
.mocked(fetch)
.mockRejectedValue(
new RequestError('not found', new Response(Readable.from(['']), { status }))
);
}
beforeEach(() => {
jest.clearAllMocks();
});
describe(resolveSimulatorPreviewAsync, () => {
it.each([
['ArgentRunSessionRemoteConfig', 'webPreviewToken'],
['AgentDeviceRunSessionRemoteConfig', 'webPreviewToken'],
['AppiumRunSessionRemoteConfig', 'webPreviewToken'],
['ServeSimRunSessionRemoteConfig', 'previewToken'],
['WebPreviewOnlyRunSessionRemoteConfig', 'previewToken'],
])(
'resolves preview authentication for %s without using controller credentials',
async (__typename, tokenKey) => {
const currentSession = session({
__typename,
previewApiUrl: 'https://preview.test/prefix/?device=SIM-A&token=old-token#fragment',
[tokenKey]: 'preview-token',
toolsAuthToken: 'wrong-controller-token',
agentDeviceRemoteSessionToken: 'wrong-controller-token',
});
const resolved = await resolveSimulatorPreviewAsync(currentSession);
expect(resolved.token).toBe('preview-token');
expect(resolved.baseUrl.toString()).toBe('https://preview.test/prefix/?device=SIM-A');
}
);
it.each([
[DeviceRunSessionStatus.Stopped, { previewApiUrl: 'https://preview.test' }, 'is not running'],
[
DeviceRunSessionStatus.InProgress,
{ webPreviewUrl: 'https://expo.dev/preview' },
'does not expose a preview API',
],
[
DeviceRunSessionStatus.InProgress,
{ previewApiUrl: 'not a URL', webPreviewToken: 'preview-token' },
'invalid preview API URL',
],
[
DeviceRunSessionStatus.InProgress,
{ previewApiUrl: 'file:///private/secret', webPreviewToken: 'preview-token' },
'invalid preview API URL',
],
[
DeviceRunSessionStatus.InProgress,
{ __typename: 'ArgentRunSessionRemoteConfig', previewApiUrl: 'https://preview.test' },
'does not expose a preview API',
],
[
DeviceRunSessionStatus.InProgress,
{
__typename: 'ArgentRunSessionRemoteConfig',
previewApiUrl: 'https://preview.test/?token=embedded',
},
'does not expose a preview API',
],
])('rejects unavailable or invalid preview configuration %#', async (status, config, message) => {
await expect(resolveSimulatorPreviewAsync(session(config, status))).rejects.toThrow(message);
});
});
describe(fetchSimulatorPreviewAsync, () => {
it('requests the route on the preview API origin with header authentication', async () => {
jest.mocked(fetch).mockResolvedValue(new Response('{}'));
await fetchSimulatorPreviewAsync(preview, '/logs', { query: { snapshot: '1' } });
expect(fetch).toHaveBeenCalledWith('https://preview.test/logs?snapshot=1', expect.anything());
expect(jest.mocked(fetch).mock.calls[0][1]?.redirect).toBe('error');
const headers = new Headers(jest.mocked(fetch).mock.calls[0][1]?.headers);
expect(headers.get('authorization')).toBe('Bearer secret-token');
});
it('rejects redirects before sending preview credentials to another server', async () => {
const destination = jest.fn();
const target = createServer((request, response) => {
destination(request.headers.authorization);
response.end('{}');
});
await new Promise<void>(resolve => target.listen(0, '127.0.0.1', resolve));
const source = createServer((_request, response) => {
response.writeHead(302, {
location: `http://127.0.0.1:${(target.address() as AddressInfo).port}/logs`,
});
response.end();
});
await new Promise<void>(resolve => source.listen(0, '127.0.0.1', resolve));
jest
.mocked(fetch)
.mockImplementationOnce(
jest.requireActual<typeof import('../../fetch')>('../../fetch').default
);
try {
await expect(
fetchSimulatorPreviewAsync(
{
...preview,
baseUrl: new URL(`http://127.0.0.1:${(source.address() as AddressInfo).port}`),
},
'/logs'
)
).rejects.toThrow('Could not connect');
expect(destination).not.toHaveBeenCalled();
} finally {
source.closeAllConnections();
target.closeAllConnections();
await Promise.all([
new Promise<void>(resolve =>
source.close(() => {
resolve();
})
),
new Promise<void>(resolve =>
target.close(() => {
resolve();
})
),
]);
}
});
it('reports an offline preview tunnel before interpreting the route failure', async () => {
jest.mocked(fetch).mockRejectedValue(
new RequestError(
'https://preview.test/?token=secret-token',
new Response(Readable.from(['secret response']), {
status: 404,
headers: { 'ngrok-error-code': 'ERR_NGROK_3200' },
})
)
);
await expect(
fetchSimulatorPreviewAsync(preview, '/logs', { notFoundMessage: 'Not found here.' })
).rejects.toThrow(
'The simulator preview is offline. The session may have stopped or reached its time limit. Start a new session with `eas simulator:start`.'
);
});
it.each([
[401, 'refused access'],
[403, 'refused access'],
[404, 'does not support this request'],
[500, 'HTTP 500'],
])('reports HTTP %s without exposing response bodies or credentials', async (status, message) => {
jest
.mocked(fetch)
.mockRejectedValue(
new RequestError(
'https://preview.test/?token=secret-token',
new Response(Readable.from(['secret response']), { status })
)
);
const error = fetchSimulatorPreviewAsync(preview, '/logs');
await expect(error).rejects.toThrow(message);
await expect(error).rejects.not.toThrow('secret');
});
it('closes the connection of an unfinished error response and keeps its body out of the error', async () => {
const server = createServer((_request, response) => {
response.writeHead(403, { 'content-type': 'text/plain' });
response.write('secret response that never ends');
});
const closed = new Promise<void>(resolve => {
server.once('connection', socket => socket.once('close', resolve));
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
jest
.mocked(fetch)
.mockImplementationOnce(
jest.requireActual<typeof import('../../fetch')>('../../fetch').default
);
let timeout: NodeJS.Timeout | undefined;
try {
const error = fetchSimulatorPreviewJsonAsync(
{
...preview,
baseUrl: new URL(`http://127.0.0.1:${(server.address() as AddressInfo).port}`),
},
'/logs',
{ query: { snapshot: '1' } }
);
await expect(error).rejects.toThrow('refused access');
await expect(error).rejects.not.toThrow('secret');
await Promise.race([
closed,
new Promise<void>((_resolve, reject) => {
timeout = setTimeout(() => {
reject(new Error('The error response connection stayed open.'));
}, 1000);
}),
]);
} finally {
clearTimeout(timeout);
server.closeAllConnections();
await new Promise<void>(resolve =>
server.close(() => {
resolve();
})
);
}
});
it('uses the caller message for a missing route', async () => {
rejectWithStatus(404);
await expect(
fetchSimulatorPreviewAsync(preview, '/crashes/old-id', {
notFoundMessage: 'The crash report was not found.',
})
).rejects.toThrow('The crash report was not found.');
});
it('does not expose URLs in connection errors', async () => {
jest.mocked(fetch).mockRejectedValueOnce(new Error('https://preview.test/?token=secret-token'));
await expect(fetchSimulatorPreviewAsync(preview, '/logs')).rejects.toThrow(
'Could not connect to the simulator preview API.'
);
});
});
describe(fetchSimulatorPreviewJsonAsync, () => {
it('requests JSON and returns the parsed response', async () => {
jest.mocked(fetch).mockResolvedValue(new Response('{"lines":[]}'));
await expect(
fetchSimulatorPreviewJsonAsync(preview, '/logs', { query: { snapshot: '1' } })
).resolves.toEqual({ lines: [] });
expect(fetch).toHaveBeenCalledWith('https://preview.test/logs?snapshot=1', expect.anything());
expect(jest.mocked(fetch).mock.calls[0][1]?.redirect).toBe('error');
const headers = new Headers(jest.mocked(fetch).mock.calls[0][1]?.headers);
expect(headers.get('accept')).toBe('application/json');
});
it('passes the caller message for a missing route', async () => {
rejectWithStatus(404);
await expect(
fetchSimulatorPreviewJsonAsync(preview, '/crashes/old-id', {
notFoundMessage: 'The crash report was not found.',
})
).rejects.toThrow('The crash report was not found.');
});
it('does not expose response data in invalid JSON errors', async () => {
jest.mocked(fetch).mockResolvedValueOnce(new Response('secret-token invalid json'));
const error = fetchSimulatorPreviewJsonAsync(preview, '/logs');
await expect(error).rejects.toThrow('Could not read the simulator preview API response.');
await expect(error).rejects.not.toThrow('secret-token');
});
});
describe(streamSimulatorPreviewAsync, () => {
const signal = new AbortController().signal;
function getRequestSignal(): AbortSignal | undefined {
return jest.mocked(fetch).mock.calls[0][1]?.signal ?? undefined;
}
it('parses SSE across chunk boundaries and reports the server closing the stream', async () => {
const body = Readable.from([
': heartbeat\r\ndata: fir',
'st\r\ndata: second\r\n\r\ndata: last\n\n',
]);
jest
.mocked(fetch)
.mockResolvedValue(new Response(body, { headers: { 'content-type': 'text/event-stream' } }));
const onData = jest.fn();
await expect(streamSimulatorPreviewAsync(preview, '/logs', onData, { signal })).rejects.toThrow(
'The simulator preview stream ended unexpectedly.'
);
expect(onData.mock.calls).toEqual([['first\nsecond'], ['last']]);
expect(getRequestSignal()?.aborted).toBe(true);
expect(jest.mocked(fetch).mock.calls[0][1]?.redirect).toBe('error');
const headers = new Headers(jest.mocked(fetch).mock.calls[0][1]?.headers);
expect(headers.get('accept')).toBe('text/event-stream');
});
it('preserves setup errors when a follow request is refused', async () => {
rejectWithStatus(403);
await expect(
streamSimulatorPreviewAsync(preview, '/logs', jest.fn(), { signal })
).rejects.toThrow('refused access');
});
it('stops when the signal aborts and releases the response', async () => {
const body = new PassThrough();
jest.mocked(fetch).mockImplementationOnce(async (_url, init) => {
init?.signal?.addEventListener('abort', () => body.destroy(new Error('aborted')));
return new Response(body, { headers: { 'content-type': 'text/event-stream' } });
});
const abortController = new AbortController();
const onData = jest.fn(() => {
abortController.abort();
});
const following = streamSimulatorPreviewAsync(preview, '/logs', onData, {
signal: abortController.signal,
});
body.write('data: marker\n\n');
await following;
expect(onData).toHaveBeenCalledWith('marker');
expect(body.destroyed).toBe(true);
});
it('stops when the signal aborts while waiting for response headers', async () => {
const abortController = new AbortController();
jest.mocked(fetch).mockImplementationOnce(
async (_url, init) =>
await new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => {
reject(new Error('aborted'));
});
process.nextTick(() => {
abortController.abort();
});
})
);
const onData = jest.fn();
await streamSimulatorPreviewAsync(preview, '/logs', onData, {
signal: abortController.signal,
});
expect(onData).not.toHaveBeenCalled();
});
it('reports a stream that fails while reading without echoing its data', async () => {
const body = new PassThrough();
jest
.mocked(fetch)
.mockResolvedValueOnce(
new Response(body, { headers: { 'content-type': 'text/event-stream' } })
);
const following = streamSimulatorPreviewAsync(preview, '/logs', jest.fn(), { signal });
body.destroy(new Error('secret-token'));
await expect(following).rejects.toThrow('The simulator preview stream ended unexpectedly.');
await expect(following).rejects.not.toThrow('secret-token');
});
it('rethrows handler errors unchanged and closes the connection', async () => {
jest.mocked(fetch).mockResolvedValueOnce(
new Response(Readable.from(['data: frame\n\n']), {
headers: { 'content-type': 'text/event-stream' },
})
);
await expect(
streamSimulatorPreviewAsync(
preview,
'/logs',
() => {
throw new Error('Handler failed.');
},
{ signal }
)
).rejects.toThrow('Handler failed.');
expect(getRequestSignal()?.aborted).toBe(true);
});
it('reports a stream that goes silent after an event once the idle timeout passes', async () => {
const server = createServer((_request, response) => {
response.writeHead(200, { 'content-type': 'text/event-stream' });
response.write(':\n\ndata: marker\n\n');
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const remote = {
...preview,
baseUrl: new URL(`http://127.0.0.1:${(server.address() as AddressInfo).port}`),
};
jest
.mocked(fetch)
.mockImplementationOnce(
jest.requireActual<typeof import('../../fetch')>('../../fetch').default
);
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
try {
let received: () => void = () => {};
const receivedMarker = new Promise<void>(resolve => {
received = resolve;
});
const following = streamSimulatorPreviewAsync(remote, '/logs', received, { signal });
await receivedMarker;
jest.advanceTimersByTime(60_000);
await expect(following).rejects.toThrow('The simulator preview stream ended unexpectedly.');
} finally {
jest.useRealTimers();
server.closeAllConnections();
await new Promise<void>(resolve =>
server.close(() => {
resolve();
})
);
}
});
it('closes the upstream connection after rejecting a response that is not an event stream', async () => {
const server = createServer((_request, response) => {
response.writeHead(200, { 'content-type': 'text/plain' });
response.write('body that never ends');
});
const closed = new Promise<void>(resolve => {
server.once('connection', socket => socket.once('close', resolve));
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const remote = {
...preview,
baseUrl: new URL(`http://127.0.0.1:${(server.address() as AddressInfo).port}`),
};
jest
.mocked(fetch)
.mockImplementationOnce(
jest.requireActual<typeof import('../../fetch')>('../../fetch').default
);
let timeout: NodeJS.Timeout | undefined;
try {
await expect(
streamSimulatorPreviewAsync(remote, '/logs', jest.fn(), { signal })
).rejects.toThrow('does not support streaming');
await Promise.race([
closed,
new Promise<void>((_resolve, reject) => {
timeout = setTimeout(() => {
reject(new Error('The upstream connection stayed open.'));
}, 1000);
}),
]);
} finally {
clearTimeout(timeout);
server.closeAllConnections();
await new Promise<void>(resolve =>
server.close(() => {
resolve();
})
);
}
});
});
@@ -22,6 +22,7 @@ import {
getPreviewApiAccess,
getRemoteSessionEnvironmentVariables,
sanitizeRemoteConfigForJson,
stripTerminalControlCharacters,
} from '../utils';
const agentDeviceConfig = {
@@ -966,3 +967,19 @@ describe('embed instructions', () => {
expect(formatRemoteSessionInstructions(config, 'dotenv')).not.toContain('tok-1');
});
});
describe(stripTerminalControlCharacters, () => {
it('removes terminal escapes and all control characters by default', () => {
expect(stripTerminalControlCharacters('\u001b[31mhello\u001b[0m\u0007\n\tworld\u009b')).toBe(
'helloworld'
);
});
it('keeps newlines and tabs when requested', () => {
expect(
stripTerminalControlCharacters('\u001b[31mhello\u001b[0m\u0007\n\tworld', {
keepNewlinesAndTabs: true,
})
).toBe('hello\n\tworld');
});
});
+119
View File
@@ -0,0 +1,119 @@
import path from 'node:path';
import { downloadSimulatorFileAsync } from './download';
import { stripTerminalControlCharacters } from './utils';
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';
export type SimulatorSession = DeviceRunSessionByIdQuery['deviceRunSessions']['byId'];
export function hasSimulatorArtifacts(session: SimulatorSession): boolean {
return (
session.status === DeviceRunSessionStatus.Stopped ||
session.status === DeviceRunSessionStatus.Errored
);
}
export async function downloadSimulatorArtifactAsync(
session: SimulatorSession,
type: string,
{
artifact: index,
output,
nonInteractive,
json,
}: { artifact?: number; output?: string; nonInteractive: boolean; json: boolean }
): Promise<void> {
const artifacts = session.artifacts
.filter(artifact => artifact.metadata?.__eas_type === type)
.sort(
(a, b) =>
Date.parse(a.createdAt) - Date.parse(b.createdAt) ||
a.name.localeCompare(b.name) ||
a.id.localeCompare(b.id)
);
if (artifacts.length === 0) {
throw new Error(
'No matching artifacts are available for this session. Artifacts may still be uploading or may not have been recorded. Run `eas simulator:get` to check the session, then try again.'
);
}
const titles = artifacts.map((artifact, i) => {
const metadata = artifact.metadata;
const device = metadata?.deviceName ?? metadata?.udid;
const part = metadata?.part;
return stripTerminalControlCharacters(
`${i + 1}. ${artifact.name} — ${artifact.filename} (${formatBytes(artifact.fileSizeBytes ?? 0)})${device ? ` (${device})` : ''}${part !== undefined ? ` (part ${part})` : ''}`
);
});
const selectionMessage = `Choose an artifact with --artifact <index>:\n${titles.join('\n')}`;
let selectedIndex = index;
if (
selectedIndex !== undefined &&
(!Number.isInteger(selectedIndex) || selectedIndex < 1 || selectedIndex > artifacts.length)
) {
throw new Error(`The artifact index is out of range. ${selectionMessage}`);
}
if (selectedIndex === undefined) {
if (artifacts.length === 1) {
selectedIndex = 1;
} else if (nonInteractive) {
throw new Error(selectionMessage);
} else {
selectedIndex = await selectAsync(
'Select an artifact to download:',
titles.map((title, i) => ({ title, value: i + 1 }))
);
}
}
const artifact = artifacts[selectedIndex - 1];
if (!output) {
if (nonInteractive) {
throw new Error('Pass --output <path> to save the session artifact in non-interactive mode.');
}
const { filePath } = await promptAsync({
type: 'text',
name: 'filePath',
message: 'Where should the artifact be saved?',
initial: stripTerminalControlCharacters(path.basename(artifact.filename)),
validate: value => value.trim().length > 0 || 'Enter an output path.',
});
output = typeof filePath === 'string' ? filePath : undefined;
}
if (!output) {
throw new Error('Enter an output path.');
}
const spinner = json ? undefined : ora('Downloading session artifact').start();
let filePath: string;
try {
filePath = await downloadSimulatorFileAsync(output, async signal => {
try {
return await fetch(artifact.downloadUrl, { signal, timeout: 30_000 });
} catch {
throw new Error(
'Could not download the session artifact. Check your internet connection and try again.'
);
}
});
spinner?.succeed();
} catch (error) {
spinner?.fail();
throw error;
}
if (json) {
const { id, name, filename, metadata } = artifact;
printJsonOnlyOutput({
deviceRunSessionId: session.id,
artifact: { id, name, filename, metadata },
filePath,
});
} else {
Log.log(
`Saved ${stripTerminalControlCharacters(artifact.name)} to ${stripTerminalControlCharacters(filePath)}.`
);
}
}
@@ -0,0 +1,65 @@
import fs from 'fs-extra';
import { open } from 'node:fs/promises';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import { type Response } from '../fetch';
const DOWNLOAD_TIMEOUT_MS = 10 * 60_000;
export async function downloadSimulatorFileAsync(
output: string,
getResponse: (signal: AbortSignal) => Promise<Response>
): Promise<string> {
const outputPath = path.resolve(output);
if (await fs.pathExists(outputPath)) {
throw new Error(
'The output file already exists. The command does not overwrite files. Choose another --output path.'
);
}
const controller = new AbortController();
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS)]);
const interruptHandler = (): void => {
controller.abort();
};
let response: Response | undefined;
let created = false;
process.on('SIGINT', interruptHandler);
try {
response = await getResponse(signal);
const file = await open(outputPath, 'wx', 0o600);
created = true;
try {
await pipeline(response.body, file.createWriteStream(), { signal });
} finally {
await file.close();
}
return outputPath;
} catch (error) {
if (created) {
await fs.remove(outputPath);
}
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
throw new Error(
'The output file already exists. The command does not overwrite files. Choose another --output path.'
);
}
if (controller.signal.aborted) {
throw new Error('The download was canceled.');
}
if (signal.aborted) {
throw new Error(
'The download timed out. It did not finish within 10 minutes. Check your internet connection, then run the command again.'
);
}
if (!response) {
throw error;
}
throw new Error(
'Could not save the download. Check that the --output directory exists and is writable, then try again.'
);
} finally {
controller.abort();
process.removeListener('SIGINT', interruptHandler);
}
}
+1 -11
View File
@@ -1,7 +1,6 @@
import { stripVTControlCharacters } from 'util';
import { type DeviceRunSessionEvent, parseDeviceRunSessionEvents } from '@expo/eas-build-job';
import { stripTerminalControlCharacters } from './utils';
import fetch, { RequestError } from '../fetch';
import { formatMilliseconds } from '../utils/timer';
@@ -109,12 +108,3 @@ function getScreenshotFilename(event: DeviceRunSessionEvent): string | undefined
}
return event.data.path.split(/[\\/]/).at(-1);
}
function stripTerminalControlCharacters(value: string): string {
return [...stripVTControlCharacters(value)]
.filter(character => {
const codePoint = character.codePointAt(0) ?? 0;
return codePoint >= 0x20 && !(codePoint >= 0x7f && codePoint <= 0x9f);
})
.join('');
}
+201
View File
@@ -0,0 +1,201 @@
import readline from 'node:readline';
import { getPreviewApiAccess } from './utils';
import { type SimulatorSession } from './artifacts';
import fetch, { RequestError, type Response } from '../fetch';
import { DeviceRunSessionStatus } from '../graphql/generated';
const PREVIEW_API_TIMEOUT_MS = 30_000;
// The preview server sends a heartbeat every 15 seconds, so a longer silence means the stream is gone.
const PREVIEW_STREAM_IDLE_TIMEOUT_MS = 60_000;
const INVALID_PREVIEW_API_URL_MESSAGE =
'The simulator session has an invalid preview API URL. The session reported a URL that is not a valid HTTP(S) URL. Start a new session with `eas simulator:start`.';
const PREVIEW_STREAM_ENDED_MESSAGE =
"The simulator preview stream ended unexpectedly. The connection to the session's preview server closed. Run `eas simulator:get` to check that the session is still running, then run the command again.";
const PREVIEW_DATA_NOT_FOUND_MESSAGE =
"The requested preview data was not found. The session's preview server does not support this request. Start a new session with `eas simulator:start`, then try again.";
export type SimulatorPreview = {
deviceRunSessionId: string;
baseUrl: URL;
token: string;
};
type SimulatorPreviewRequestOptions = {
query?: Record<string, string>;
signal?: AbortSignal;
/** Message for a 404 response. Defaults to a generic message for unsupported preview data. */
notFoundMessage?: string;
};
export async function resolveSimulatorPreviewAsync(
session: SimulatorSession
): Promise<SimulatorPreview> {
if (session.status !== DeviceRunSessionStatus.InProgress) {
throw new Error(
'The simulator session is not running. Live preview data is only available while a session runs. Start a new session with `eas simulator:start`.'
);
}
const access = session.remoteConfig ? getPreviewApiAccess(session.remoteConfig) : null;
if (!access) {
throw new Error(
'This simulator session does not expose a preview API. The command reads live data through the preview API, and the session did not report its URL and token. Start a new iOS session with `eas simulator:start --platform ios`.'
);
}
let baseUrl: URL;
try {
baseUrl = new URL(access.apiUrl);
} catch {
throw new Error(INVALID_PREVIEW_API_URL_MESSAGE);
}
if (!['https:', 'http:'].includes(baseUrl.protocol)) {
throw new Error(INVALID_PREVIEW_API_URL_MESSAGE);
}
// Requests send the token in a header, so keep it out of URLs that can appear in errors.
baseUrl.searchParams.delete('token');
baseUrl.hash = '';
return { deviceRunSessionId: session.id, baseUrl, token: access.token };
}
export async function fetchSimulatorPreviewAsync(
preview: SimulatorPreview,
route: string,
{
query,
signal,
notFoundMessage = PREVIEW_DATA_NOT_FOUND_MESSAGE,
accept,
}: SimulatorPreviewRequestOptions & { accept?: string } = {}
): Promise<Response> {
const url = new URL(route, preview.baseUrl);
for (const [key, value] of Object.entries(query ?? {})) {
url.searchParams.set(key, value);
}
// Aborting closes the connection of an error response, whose body is never read.
const controller = new AbortController();
try {
return await fetch(url.toString(), {
timeout: PREVIEW_API_TIMEOUT_MS,
redirect: 'error',
signal: signal ? AbortSignal.any([signal, controller.signal]) : controller.signal,
headers: {
Authorization: `Bearer ${preview.token}`,
...(accept ? { Accept: accept } : {}),
},
});
} catch (error) {
if (signal?.aborted) {
throw error;
}
if (error instanceof RequestError) {
controller.abort();
if (error.response.headers.get('ngrok-error-code') === 'ERR_NGROK_3200') {
throw new Error(
'The simulator preview is offline. The session may have stopped or reached its time limit. Start a new session with `eas simulator:start`.'
);
}
const { status } = error.response;
if (status === 401 || status === 403) {
throw new Error(
"The preview API refused access. It did not accept the session's preview token. Run `eas simulator:get` to check that the session is still running, then try again."
);
}
if (status === 404) {
throw new Error(notFoundMessage);
}
throw new Error(
`The preview API request failed (HTTP ${status}). The session's preview server returned an error. Run \`eas simulator:get\` to check that the session is still running, then try again.`
);
}
throw new Error(
"Could not connect to the simulator preview API. The request did not reach the session's preview server. Check your internet connection. Run `eas simulator:get` to check that the session is still running."
);
}
}
export async function fetchSimulatorPreviewJsonAsync<T>(
preview: SimulatorPreview,
route: string,
options: Omit<SimulatorPreviewRequestOptions, 'signal'> = {}
): Promise<T> {
const response = await fetchSimulatorPreviewAsync(preview, route, {
...options,
accept: 'application/json',
});
try {
return (await response.json()) as T;
} catch {
throw new Error(
'Could not read the simulator preview API response. The response was incomplete or not valid JSON. Try again.'
);
}
}
/**
* Calls `onData` with each server-sent event's data until `signal` aborts. The stream ending is an error.
* Errors thrown by `onData` stop the stream and are rethrown unchanged.
*/
export async function streamSimulatorPreviewAsync(
preview: SimulatorPreview,
route: string,
onData: (data: string) => void,
{ signal, ...options }: SimulatorPreviewRequestOptions & { signal: AbortSignal }
): Promise<void> {
// Aborting closes the connection, including when the stream stops because of an error.
const controller = new AbortController();
let reading = false;
let onDataError: unknown;
let idleTimer: NodeJS.Timeout | undefined;
const resetIdleTimer = (): void => {
clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
controller.abort();
}, PREVIEW_STREAM_IDLE_TIMEOUT_MS);
};
try {
const response = await fetchSimulatorPreviewAsync(preview, route, {
...options,
signal: AbortSignal.any([signal, controller.signal]),
accept: 'text/event-stream',
});
if (!response.headers.get('content-type')?.includes('text/event-stream')) {
throw new Error(
'This simulator session does not support streaming. Its preview server did not return an event stream. Run the command without --follow.'
);
}
reading = true;
resetIdleTimer();
let data: string[] = [];
for await (const line of readline.createInterface({
input: response.body,
crlfDelay: Infinity,
})) {
resetIdleTimer();
if (line === '' && data.length > 0) {
const frame = data.join('\n');
data = [];
try {
onData(frame);
} catch (error) {
onDataError = error;
throw error;
}
} else if (line.startsWith('data:')) {
data.push(line.slice(5).replace(/^ /, ''));
}
}
// The server keeps the stream open while the session runs, so a clean end is unexpected too.
throw new Error(PREVIEW_STREAM_ENDED_MESSAGE);
} catch (error) {
if (signal.aborted) {
return;
}
if (!reading || error === onDataError) {
throw error;
}
throw new Error(PREVIEW_STREAM_ENDED_MESSAGE);
} finally {
clearTimeout(idleTimer);
controller.abort();
}
}
+52
View File
@@ -1,4 +1,5 @@
import chalk from 'chalk';
import { stripVTControlCharacters } from 'util';
import {
AppPlatform,
@@ -655,3 +656,54 @@ function formatControllerInstructions(
].join('\n');
}
}
// Session text is untrusted, so strip terminal escape and control characters before printing it.
export function stripTerminalControlCharacters(
value: string,
{ keepNewlinesAndTabs = false }: { keepNewlinesAndTabs?: boolean } = {}
): string {
return stripVTControlCharacters(value).replace(/\p{Cc}/gu, character =>
keepNewlinesAndTabs && (character === '\n' || character === '\t') ? character : ''
);
}
export function isValidLogTime(at: unknown): at is number {
return typeof at === 'number' && !Number.isNaN(new Date(at).getTime());
}
export function formatLogLine(raw: string, timestamp = false, at = Date.now()): string {
const time = new Date(isValidLogTime(at) ? at : Date.now()).toISOString();
try {
const entry = JSON.parse(raw) as {
timestamp?: string;
processImagePath?: string;
processID?: number;
eventMessage?: string;
messageType?: string | number;
};
if (typeof entry.eventMessage === 'string') {
const prefix = timestamp
? `${chalk.dim(stripTerminalControlCharacters(entry.timestamp ?? time))} `
: '';
const processName = entry.processImagePath?.split('/').at(-1) ?? 'unknown';
const processId = typeof entry.processID === 'number' ? `:${entry.processID}` : '';
const processLabel = chalk.cyan(
stripTerminalControlCharacters(`[${processName}${processId}]`)
);
const level = String(entry.messageType ?? '').toLowerCase();
let message = stripTerminalControlCharacters(entry.eventMessage, {
keepNewlinesAndTabs: true,
});
if (level === 'error' || level === '16') {
message = chalk.red(message);
} else if (level === 'fault' || level === '17') {
message = chalk.red.bold(message);
} else if (level === 'debug' || level === '2') {
message = chalk.dim(message);
}
return `${prefix}${processLabel} ${message}`;
}
} catch {}
const prefix = timestamp ? `${chalk.dim(time)} ` : '';
return `${prefix}${stripTerminalControlCharacters(raw, { keepNewlinesAndTabs: true })}`;
}
+4
View File
@@ -1,5 +1,9 @@
# @expo/eas-json
## 27.0.0
No changes in this release.
## 26.0.0
### Minor Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-json",
"version": "26.0.0",
"version": "27.0.0",
"description": "A library for interacting with eas.json",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
@@ -32,7 +32,7 @@
},
"dependencies": {
"@babel/code-frame": "7.23.5",
"@expo/eas-build-job": "26.0.0",
"@expo/eas-build-job": "27.0.0",
"chalk": "4.1.2",
"env-string": "1.0.1",
"fs-extra": "11.2.0",
+4
View File
@@ -1,5 +1,9 @@
# eas-cli-local-build-plugin
## 27.0.0
No changes in this release.
## 26.0.0
No changes in this release.
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "26.0.0",
"version": "27.0.0",
"description": "Tool for running EAS compatible builds on a local machine.",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
@@ -26,12 +26,12 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/build-tools": "26.0.0",
"@expo/build-tools": "27.0.0",
"@expo/bunyan": "^4.0.1",
"@expo/eas-build-job": "26.0.0",
"@expo/logger": "26.0.0",
"@expo/eas-build-job": "27.0.0",
"@expo/logger": "27.0.0",
"@expo/spawn-async": "^1.7.2",
"@expo/turtle-spawn": "26.0.0",
"@expo/turtle-spawn": "27.0.0",
"chalk": "4.1.2",
"env-paths": "^2.2.1",
"fs-extra": "^11.2.0",
+4
View File
@@ -1,5 +1,9 @@
# @expo/logger
## 27.0.0
No changes in this release.
## 26.0.0
No changes in this release.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/logger",
"version": "26.0.0",
"version": "27.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
+4
View File
@@ -1,5 +1,9 @@
# @expo/steps
## 27.0.0
No changes in this release.
## 26.0.0
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/steps",
"version": "26.0.0",
"version": "27.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -27,8 +27,8 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/eas-build-job": "26.0.0",
"@expo/logger": "26.0.0",
"@expo/eas-build-job": "27.0.0",
"@expo/logger": "27.0.0",
"@expo/spawn-async": "^1.7.2",
"arg": "^5.0.2",
"fast-glob": "^3.3.2",
+4
View File
@@ -1,5 +1,9 @@
# @expo/template-file
## 27.0.0
No changes in this release.
## 26.0.0
No changes in this release.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/template-file",
"version": "26.0.0",
"version": "27.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
+4
View File
@@ -1,5 +1,9 @@
# @expo/turtle-spawn
## 27.0.0
No changes in this release.
## 26.0.0
No changes in this release.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/turtle-spawn",
"version": "26.0.0",
"version": "27.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -25,7 +25,7 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "26.0.0",
"@expo/logger": "27.0.0",
"@expo/spawn-async": "^1.7.2"
},
"devDependencies": {
+4
View File
@@ -1,5 +1,9 @@
# @expo/worker
## 27.0.0
No changes in this release.
## 26.0.0
### Patch Changes
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "26.0.0",
"version": "27.0.0",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,17 +19,17 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "26.0.0",
"@expo/build-tools": "27.0.0",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/eas-build-job": "26.0.0",
"@expo/logger": "26.0.0",
"@expo/eas-build-job": "27.0.0",
"@expo/logger": "27.0.0",
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "26.0.0",
"@expo/template-file": "26.0.0",
"@expo/turtle-spawn": "26.0.0",
"@expo/steps": "27.0.0",
"@expo/template-file": "27.0.0",
"@expo/turtle-spawn": "27.0.0",
"@hapi/boom": "10.0.1",
"check-disk-space": "3.4.0",
"fast-glob": "3.3.2",
Generated
+35 -35
View File
@@ -2515,24 +2515,24 @@ __metadata:
languageName: node
linkType: hard
"@expo/build-tools@npm:26.0.0, @expo/build-tools@workspace:packages/build-tools":
"@expo/build-tools@npm:27.0.0, @expo/build-tools@workspace:packages/build-tools":
version: 0.0.0-use.local
resolution: "@expo/build-tools@workspace:packages/build-tools"
dependencies:
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/downloader": "npm:26.0.0"
"@expo/eas-build-job": "npm:26.0.0"
"@expo/downloader": "npm:27.0.0"
"@expo/eas-build-job": "npm:27.0.0"
"@expo/env": "npm:^0.4.0"
"@expo/logger": "npm:26.0.0"
"@expo/logger": "npm:27.0.0"
"@expo/package-manager": "npm:1.9.10"
"@expo/plist": "npm:^0.3.5"
"@expo/repack-app": "npm:~0.9.0"
"@expo/results": "npm:^1.0.0"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:26.0.0"
"@expo/template-file": "npm:26.0.0"
"@expo/turtle-spawn": "npm:26.0.0"
"@expo/steps": "npm:27.0.0"
"@expo/template-file": "npm:27.0.0"
"@expo/turtle-spawn": "npm:27.0.0"
"@expo/xcpretty": "npm:^4.3.1"
"@ngrok/ngrok": "npm:1.7.0"
"@sentry/node": "npm:7.77.0"
@@ -2768,7 +2768,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/downloader@npm:26.0.0, @expo/downloader@workspace:packages/downloader":
"@expo/downloader@npm:27.0.0, @expo/downloader@workspace:packages/downloader":
version: 0.0.0-use.local
resolution: "@expo/downloader@workspace:packages/downloader"
dependencies:
@@ -2784,13 +2784,13 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-build-job@npm:26.0.0, @expo/eas-build-job@workspace:packages/eas-build-job":
"@expo/eas-build-job@npm:27.0.0, @expo/eas-build-job@workspace:packages/eas-build-job":
version: 0.0.0-use.local
resolution: "@expo/eas-build-job@workspace:packages/eas-build-job"
dependencies:
"@expo/logger": "npm:26.0.0"
"@expo/logger": "npm:27.0.0"
"@expo/results": "npm:1.0.0"
"@expo/turtle-spawn": "npm:26.0.0"
"@expo/turtle-spawn": "npm:27.0.0"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:22.20.5"
jest: "npm:^29.7.0"
@@ -2803,12 +2803,12 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-json@npm:26.0.0, @expo/eas-json@workspace:packages/eas-json":
"@expo/eas-json@npm:27.0.0, @expo/eas-json@workspace:packages/eas-json":
version: 0.0.0-use.local
resolution: "@expo/eas-json@workspace:packages/eas-json"
dependencies:
"@babel/code-frame": "npm:7.23.5"
"@expo/eas-build-job": "npm:26.0.0"
"@expo/eas-build-job": "npm:27.0.0"
"@tsconfig/node22": "npm:22.0.6"
"@types/babel__code-frame": "npm:7.0.3"
"@types/fs-extra": "npm:11.0.4"
@@ -2931,7 +2931,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/logger@npm:26.0.0, @expo/logger@workspace:packages/logger":
"@expo/logger@npm:27.0.0, @expo/logger@workspace:packages/logger":
version: 0.0.0-use.local
resolution: "@expo/logger@workspace:packages/logger"
dependencies:
@@ -3131,12 +3131,12 @@ __metadata:
languageName: node
linkType: hard
"@expo/steps@npm:26.0.0, @expo/steps@workspace:packages/steps":
"@expo/steps@npm:27.0.0, @expo/steps@workspace:packages/steps":
version: 0.0.0-use.local
resolution: "@expo/steps@workspace:packages/steps"
dependencies:
"@expo/eas-build-job": "npm:26.0.0"
"@expo/logger": "npm:26.0.0"
"@expo/eas-build-job": "npm:27.0.0"
"@expo/logger": "npm:27.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@jest/globals": "npm:^29.7.0"
"@types/jest": "npm:^29.5.12"
@@ -3162,7 +3162,7 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/template-file@npm:26.0.0, @expo/template-file@workspace:packages/template-file":
"@expo/template-file@npm:27.0.0, @expo/template-file@workspace:packages/template-file":
version: 0.0.0-use.local
resolution: "@expo/template-file@workspace:packages/template-file"
dependencies:
@@ -3185,11 +3185,11 @@ __metadata:
languageName: node
linkType: hard
"@expo/turtle-spawn@npm:26.0.0, @expo/turtle-spawn@workspace:packages/turtle-spawn":
"@expo/turtle-spawn@npm:27.0.0, @expo/turtle-spawn@workspace:packages/turtle-spawn":
version: 0.0.0-use.local
resolution: "@expo/turtle-spawn@workspace:packages/turtle-spawn"
dependencies:
"@expo/logger": "npm:26.0.0"
"@expo/logger": "npm:27.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:22.20.5"
@@ -3205,17 +3205,17 @@ __metadata:
resolution: "@expo/worker@workspace:packages/worker"
dependencies:
"@babel/core": "npm:7.24.7"
"@expo/build-tools": "npm:26.0.0"
"@expo/build-tools": "npm:27.0.0"
"@expo/config-plugins": "npm:55.0.7"
"@expo/config-types": "npm:55.0.5"
"@expo/eas-build-job": "npm:26.0.0"
"@expo/logger": "npm:26.0.0"
"@expo/eas-build-job": "npm:27.0.0"
"@expo/logger": "npm:27.0.0"
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:26.0.0"
"@expo/template-file": "npm:26.0.0"
"@expo/turtle-spawn": "npm:26.0.0"
"@expo/steps": "npm:27.0.0"
"@expo/template-file": "npm:27.0.0"
"@expo/turtle-spawn": "npm:27.0.0"
"@hapi/boom": "npm:10.0.1"
"@types/babel__core": "npm:7.20.5"
"@types/fs-extra": "npm:11.0.4"
@@ -10099,7 +10099,7 @@ __metadata:
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
dependencies:
"@expo/package-manager": "npm:1.9.10"
"@expo/steps": "npm:26.0.0"
"@expo/steps": "npm:27.0.0"
"@jest/globals": "npm:^29.7.0"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:22.20.5"
@@ -10683,12 +10683,12 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
dependencies:
"@expo/build-tools": "npm:26.0.0"
"@expo/build-tools": "npm:27.0.0"
"@expo/bunyan": "npm:^4.0.1"
"@expo/eas-build-job": "npm:26.0.0"
"@expo/logger": "npm:26.0.0"
"@expo/eas-build-job": "npm:27.0.0"
"@expo/logger": "npm:27.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@expo/turtle-spawn": "npm:26.0.0"
"@expo/turtle-spawn": "npm:27.0.0"
"@types/fs-extra": "npm:^11.0.4"
"@types/hapi__joi": "npm:^17.1.14"
"@types/lodash": "npm:^4.17.4"
@@ -10742,12 +10742,12 @@ __metadata:
"@expo/code-signing-certificates": "npm:0.0.5"
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/eas-build-job": "npm:26.0.0"
"@expo/eas-json": "npm:26.0.0"
"@expo/eas-build-job": "npm:27.0.0"
"@expo/eas-json": "npm:27.0.0"
"@expo/env": "npm:^1.0.0"
"@expo/experimentation": "npm:0.0.5"
"@expo/json-file": "npm:8.3.3"
"@expo/logger": "npm:26.0.0"
"@expo/logger": "npm:27.0.0"
"@expo/multipart-body-parser": "npm:2.0.0"
"@expo/osascript": "npm:2.1.4"
"@expo/package-manager": "npm:1.9.10"
@@ -10759,7 +10759,7 @@ __metadata:
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:26.0.0"
"@expo/steps": "npm:27.0.0"
"@expo/timeago.js": "npm:1.0.0"
"@graphql-codegen/cli": "npm:6.3.1"
"@graphql-codegen/introspection": "npm:5.0.2"