mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-15 13:08:11 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d31ecc2bc | ||
|
|
890286a08d | ||
|
|
33ff59a75e | ||
|
|
c2bbba6e78 |
No files matched your search
+10
-2
@@ -8,6 +8,16 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--network-capture` to `eas simulator` to record HTTP(S) traffic from apps on the device, and `--network-capture-field` to also keep headers, query values, or request and response bodies. ([#4308](https://github.com/expo/eas-cli/pull/4308) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [24.9.0](https://github.com/expo/eas-cli/releases/tag/v24.9.0) - 2026-10-02
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--force-end-active-rollout` to `eas update`, `eas update:republish`, `eas update:roll-back-to-embedded` and `eas update:rollback`. ([#4233](https://github.com/expo/eas-cli/pull/4233) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Allow `eas simulator:start --platform android --egress local`. On Android, `--egress-allow localhost:<port>` also covers `10.0.2.2:<port>`, and refused destinations are shown as warnings. ([#4519](https://github.com/expo/eas-cli/pull/4519) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [build-tools] Support `eas/start_local_egress` on Linux for Android emulators: an always-listening relay for the emulator's proxy, a systemd scope and nftables fence that refuse traffic that cannot use the tunnel, and fence refusals in the session log. ([#4519](https://github.com/expo/eas-cli/pull/4519) by [@brentvatne](https://github.com/brentvatne))
|
||||
@@ -38,8 +48,6 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
- [build-tools] Disable agent-device's internal idle timeouts in remote sessions, so only the session's own idle and duration limits end it. ([#4485](https://github.com/expo/eas-cli/pull/4485) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Keep `eas simulator:events --follow` running while a session is queued or starting. ([#4506](https://github.com/expo/eas-cli/pull/4506) by [@sjkim-expo](https://github.com/sjkim-expo))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [24.8.0](https://github.com/expo/eas-cli/releases/tag/v24.8.0) - 2026-09-24
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "24.8.0",
|
||||
"PRODUCTION": "24.8.0"
|
||||
"STAGING": "24.9.0",
|
||||
"PRODUCTION": "24.9.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "24.9.0",
|
||||
"version": "24.10.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
+175
-148
File diff suppressed because it is too large.
Load diff
Generated
+32
@@ -45660,6 +45660,38 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "networkCapture",
|
||||
"description": "Record HTTP(S) traffic from apps on the device for the whole session. iOS only.\nHTTPS is decrypted, so recordings contain credentials and cookies in cleartext.\nIf omitted, no traffic is recorded.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Boolean",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "networkCaptureFields",
|
||||
"description": "What network capture may keep beyond method, URL, status, timing and size: header, query,\nrequest-body, response-body. Each can carry credentials, so nothing here is recorded unless\nit is asked for. Requires networkCapture.",
|
||||
"type": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "openUrl",
|
||||
"description": "Expo or development-client URL to open after launching the installed application. Requires\nbuildId, applicationArchiveUrl, or expoGo.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "24.9.0",
|
||||
"version": "24.10.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from '../../../simulator/env';
|
||||
import { resolveExpoGoSdkVersionAsync } from '../../../simulator/expoGo';
|
||||
import * as promiseUtils from '../../../utils/promise';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import Simulator from '../index';
|
||||
|
||||
jest.mock('fs-extra');
|
||||
@@ -53,6 +54,11 @@ jest.mock('../../../simulator/env', () => ({
|
||||
resetSimulatorEnvAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../simulator/expoGo');
|
||||
jest.mock('../../../utils/json');
|
||||
jest.mock('../../../utils/promise', () => ({
|
||||
...jest.requireActual('../../../utils/promise'),
|
||||
sleepAsync: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('../../../simulator/egress', () => ({
|
||||
...jest.requireActual('../../../simulator/egress'),
|
||||
@@ -93,6 +99,8 @@ const mockResetSimulatorEnvAsync = jest.mocked(resetSimulatorEnvAsync);
|
||||
const mockResolveExpoGoSdkVersionAsync = jest.mocked(resolveExpoGoSdkVersionAsync);
|
||||
const mockOra = jest.mocked(ora);
|
||||
const mockPromptAsync = jest.mocked(promptAsync);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
function makeCreatedDeviceRunSession(
|
||||
overrides: Partial<CreatedDeviceRunSession> = {}
|
||||
@@ -164,6 +172,7 @@ describe(Simulator, () => {
|
||||
const previousDeviceRunSessionId = process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest.clearAllMocks();
|
||||
delete process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
mockAvailabilityByAppIdAsync.mockResolvedValue({ accountName: 'testuser', available: true });
|
||||
@@ -454,6 +463,101 @@ describe(Simulator, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('passes --network-capture to the createDeviceRunSession mutation', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--network-capture',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: 'project-123',
|
||||
name: undefined,
|
||||
networkCapture: true,
|
||||
packageVersion: undefined,
|
||||
platform: AppPlatform.Ios,
|
||||
type: DeviceRunSessionType.AgentDevice,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes the requested capture fields, splitting a comma-separated value', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--network-capture',
|
||||
'--network-capture-field',
|
||||
'header,query',
|
||||
'--network-capture-field',
|
||||
'request-body',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({
|
||||
networkCapture: true,
|
||||
networkCaptureFields: ['header', 'query', 'request-body'],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not send a capture field list when none was asked for', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--network-capture',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.not.objectContaining({ networkCaptureFields: expect.anything() })
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a capture field the recorder does not know', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--network-capture',
|
||||
'--network-capture-field',
|
||||
'cookies',
|
||||
]);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(/Unknown network capture field 'cookies'/);
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects capture fields without --network-capture', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--network-capture-field',
|
||||
'header',
|
||||
]);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects --network-capture on android', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'android',
|
||||
'--non-interactive',
|
||||
'--network-capture',
|
||||
]);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(/only supported on iOS/);
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`throws when ${EAS_SIMULATOR_SESSION_ID} is already present with --no-force`, async () => {
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = 'existing-session';
|
||||
|
||||
@@ -1324,4 +1428,280 @@ describe(Simulator, () => {
|
||||
expect(mockPromptAsync).not.toHaveBeenCalled();
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails the create spinner and rethrows when session creation fails', async () => {
|
||||
mockCreateDeviceRunSessionAsync.mockRejectedValue(new Error('create boom'));
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('create boom');
|
||||
expect(mockByIdAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when the session errors before it becomes ready', async () => {
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({ status: DeviceRunSessionStatus.Errored, remoteConfig: null })
|
||||
);
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(/errored before the .* session was ready/);
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when the turtle job run finishes before the session is ready', async () => {
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({
|
||||
remoteConfig: null,
|
||||
turtleJobRun: { id: 'job-123', status: JobRunStatus.Errored },
|
||||
})
|
||||
);
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
/Turtle job run for simulator session .* errored before/
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps polling until the remote config appears', async () => {
|
||||
mockByIdAsync
|
||||
.mockResolvedValueOnce(makeDeviceRunSession({ remoteConfig: null }))
|
||||
.mockResolvedValueOnce(makeDeviceRunSession());
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockByIdAsync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('stops the session and rethrows when polling for readiness fails', async () => {
|
||||
mockByIdAsync.mockRejectedValue(new Error('poll boom'));
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('poll boom');
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
});
|
||||
|
||||
it('times out when the session never becomes ready', async () => {
|
||||
mockByIdAsync.mockResolvedValue(makeDeviceRunSession({ remoteConfig: null }));
|
||||
const realNow = Date.now();
|
||||
jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockReturnValueOnce(realNow)
|
||||
.mockReturnValue(realNow + 60 * 60 * 1000);
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(/Timed out after \d+s waiting for/);
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prints JSON only and skips the human-readable output with --json', async () => {
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive', '--json']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'session-123', deviceRunSessionUrl })
|
||||
);
|
||||
});
|
||||
|
||||
it('warns but continues when the dotenv file cannot be written', async () => {
|
||||
jest.mocked(fs.writeFile).mockRejectedValue(new Error('disk full') as never);
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
`Failed to write simulator environment variables to ${SIMULATOR_DOTENV_FILE_NAME}`
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('retries after a transient poll failure while the session is running', async () => {
|
||||
mockByIdAsync
|
||||
.mockResolvedValueOnce(makeDeviceRunSession())
|
||||
.mockRejectedValueOnce(new Error('transient'))
|
||||
.mockResolvedValueOnce(makeDeviceRunSession({ status: DeviceRunSessionStatus.Stopped }));
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(Log.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to poll simulator session')
|
||||
);
|
||||
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
|
||||
});
|
||||
|
||||
it('throws when the session errors while it is running', async () => {
|
||||
mockByIdAsync
|
||||
.mockResolvedValueOnce(makeDeviceRunSession())
|
||||
.mockResolvedValueOnce(makeDeviceRunSession({ status: DeviceRunSessionStatus.Errored }));
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('Simulator session session-123 errored.');
|
||||
});
|
||||
|
||||
it('keeps waiting while the session is still in progress', async () => {
|
||||
mockByIdAsync
|
||||
.mockResolvedValueOnce(makeDeviceRunSession())
|
||||
.mockResolvedValueOnce(makeDeviceRunSession())
|
||||
.mockResolvedValueOnce(
|
||||
makeDeviceRunSession({ turtleJobRun: { id: 'job-123', status: JobRunStatus.Finished } })
|
||||
);
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockByIdAsync).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('rethrows when clearing the dotenv file fails after the session ends', async () => {
|
||||
mockByIdAsync
|
||||
.mockResolvedValueOnce(makeDeviceRunSession())
|
||||
.mockResolvedValueOnce(makeDeviceRunSession({ status: DeviceRunSessionStatus.Stopped }));
|
||||
mockResetSimulatorEnvAsync.mockRejectedValue(new Error('unlink failed'));
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('unlink failed');
|
||||
expect(Log.error).toHaveBeenCalledWith(`Failed to clean up ${SIMULATOR_DOTENV_FILE_NAME}`);
|
||||
});
|
||||
|
||||
it('warns and reports failure when the session cannot be stopped', async () => {
|
||||
mockByIdAsync.mockResolvedValueOnce(makeDeviceRunSession()).mockImplementationOnce(async () => {
|
||||
process.emit('SIGINT');
|
||||
return makeDeviceRunSession();
|
||||
});
|
||||
mockEnsureDeviceRunSessionStoppedAsync.mockRejectedValue(new Error('stop boom'));
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
'Failed to stop simulator session session-123: stop boom'
|
||||
);
|
||||
});
|
||||
|
||||
it('stops the session when interrupted while it is running', async () => {
|
||||
mockByIdAsync.mockResolvedValueOnce(makeDeviceRunSession()).mockImplementationOnce(async () => {
|
||||
process.emit('SIGINT');
|
||||
return makeDeviceRunSession();
|
||||
});
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
|
||||
});
|
||||
|
||||
it('stops the session and reports when the stop cannot be confirmed after an interrupt before ready', async () => {
|
||||
jest.spyOn(process, 'exit').mockImplementation(code => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
});
|
||||
mockByIdAsync.mockImplementation(async () => {
|
||||
process.emit('SIGINT');
|
||||
return makeDeviceRunSession({ remoteConfig: null });
|
||||
});
|
||||
mockEnsureDeviceRunSessionStoppedAsync.mockRejectedValue(new Error('stop boom'));
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('process.exit(130)');
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
});
|
||||
|
||||
it('stops the session when interrupted after it is ready', async () => {
|
||||
jest.spyOn(process, 'exit').mockImplementation(code => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
});
|
||||
jest
|
||||
.mocked(fs.writeFile)
|
||||
.mockImplementationOnce((async () => undefined) as never)
|
||||
.mockImplementationOnce((async () => {
|
||||
process.emit('SIGINT');
|
||||
}) as never);
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('process.exit(130)');
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
|
||||
});
|
||||
|
||||
it('force exits when a second interrupt arrives', async () => {
|
||||
const exitSpy = jest.spyOn(process, 'exit').mockImplementation(code => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
});
|
||||
mockByIdAsync.mockResolvedValueOnce(makeDeviceRunSession()).mockImplementationOnce(async () => {
|
||||
process.emit('SIGINT');
|
||||
process.emit('SIGINT');
|
||||
return makeDeviceRunSession();
|
||||
});
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios']);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(Log.error).toHaveBeenCalledWith(
|
||||
'Aborted before the simulator session could be stopped. Run `eas simulator:stop --id session-123` to terminate it and avoid unexpected charges.'
|
||||
);
|
||||
expect(exitSpy).toHaveBeenCalledWith(130);
|
||||
});
|
||||
|
||||
it('stringifies a non-Error dotenv write failure', async () => {
|
||||
jest.mocked(fs.writeFile).mockRejectedValue('disk full' as never);
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('disk full'));
|
||||
});
|
||||
|
||||
it('stringifies a non-Error poll failure while the session is running', async () => {
|
||||
mockByIdAsync
|
||||
.mockResolvedValueOnce(makeDeviceRunSession())
|
||||
.mockRejectedValueOnce('network down')
|
||||
.mockResolvedValueOnce(makeDeviceRunSession({ status: DeviceRunSessionStatus.Stopped }));
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(Log.debug).toHaveBeenCalledWith(expect.stringContaining('network down'));
|
||||
});
|
||||
|
||||
it('stringifies a non-Error stop failure', async () => {
|
||||
jest.spyOn(process, 'exit').mockImplementation(code => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
});
|
||||
mockByIdAsync.mockImplementation(async () => {
|
||||
process.emit('SIGINT');
|
||||
return makeDeviceRunSession({ remoteConfig: null });
|
||||
});
|
||||
mockEnsureDeviceRunSessionStoppedAsync.mockRejectedValue('gateway timeout');
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('process.exit(130)');
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
'Failed to stop simulator session session-123: gateway timeout'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import Log, { link } from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { parseEgressAllowList, runLocalEgressAsync } from '../../simulator/egress';
|
||||
import { parseNetworkCaptureFields } from '../../simulator/networkCapture';
|
||||
import {
|
||||
EAS_SIMULATOR_SESSION_ID,
|
||||
SIMULATOR_DOTENV_FILE_NAME,
|
||||
@@ -128,6 +129,16 @@ export default class Simulator extends EasCommand {
|
||||
description:
|
||||
'Version of the package backing the simulator session (e.g. "0.1.3-alpha.3"). Defaults to "latest" when omitted.',
|
||||
}),
|
||||
'network-capture': Flags.boolean({
|
||||
description:
|
||||
'Record HTTP(S) traffic from apps on the device (iOS only). HTTPS is decrypted, so recordings contain credentials in cleartext and certificate-pinned apps fail to connect.',
|
||||
}),
|
||||
'network-capture-field': Flags.string({
|
||||
description:
|
||||
'What a recording may keep beyond method, URL, status, timing and size: header, query, request-body, response-body. Repeatable or comma-separated. Defaults to none of them, because each can carry credentials.',
|
||||
multiple: true,
|
||||
dependsOn: ['network-capture'],
|
||||
}),
|
||||
'max-duration-minutes': Flags.integer({
|
||||
description:
|
||||
"Maximum duration of the simulator session in minutes before it is automatically stopped. Defaults to, and cannot exceed, the maximum session duration of the account's plan.",
|
||||
@@ -241,6 +252,14 @@ export default class Simulator extends EasCommand {
|
||||
);
|
||||
}
|
||||
|
||||
// Before the platform prompt: a typo here does not depend on the answer.
|
||||
let networkCaptureFields: string[] = [];
|
||||
try {
|
||||
networkCaptureFields = parseNetworkCaptureFields(flags['network-capture-field'] ?? []);
|
||||
} catch (err) {
|
||||
throw new EasCommandError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
|
||||
const platform = await resolvePlatformAsync(flags.platform, nonInteractive);
|
||||
const egress = flags.egress === 'local' ? DeviceRunSessionEgress.Local : undefined;
|
||||
let egressAllow: string[] = [];
|
||||
@@ -249,6 +268,12 @@ export default class Simulator extends EasCommand {
|
||||
} catch (err) {
|
||||
throw new EasCommandError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
if (flags['network-capture'] && platform !== AppPlatform.Ios) {
|
||||
throw new EasCommandError(
|
||||
'Network capture is only supported on iOS simulator sessions. Re-run without --network-capture, or pass --platform ios.'
|
||||
);
|
||||
}
|
||||
|
||||
if (platform === AppPlatform.Android) {
|
||||
Log.warn(
|
||||
'Android emulator support in EAS Simulator is still in development. Some features available on iOS may not work on Android yet. Full parity with iOS is coming soon.'
|
||||
@@ -280,6 +305,8 @@ export default class Simulator extends EasCommand {
|
||||
platform,
|
||||
type: DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE[flags.type],
|
||||
packageVersion: flags['package-version'],
|
||||
networkCapture: flags['network-capture'],
|
||||
...(networkCaptureFields.length ? { networkCaptureFields } : {}),
|
||||
...(deviceIdentifier
|
||||
? platform === AppPlatform.Ios
|
||||
? { ios: { deviceIdentifier } }
|
||||
@@ -317,6 +344,11 @@ export default class Simulator extends EasCommand {
|
||||
simulatorEnvWritten ? `, saved to ${SIMULATOR_DOTENV_FILE_NAME}` : ''
|
||||
}) ${link(deviceRunSessionUrl)}`
|
||||
);
|
||||
if (flags['network-capture']) {
|
||||
Log.warn(
|
||||
'Network capture was requested. HTTPS is decrypted, so recordings contain credentials in cleartext. Relaunch an installed app to record its traffic.'
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
createSpinner.fail('Failed to create simulator session');
|
||||
sessionInterrupt?.dispose();
|
||||
|
||||
+12
@@ -6451,6 +6451,18 @@ export type CreateDeviceRunSessionInput = {
|
||||
* fall back to identifying it by id.
|
||||
*/
|
||||
name?: InputMaybe<Scalars['String']['input']>;
|
||||
/**
|
||||
* Record HTTP(S) traffic from apps on the device for the whole session. iOS only.
|
||||
* HTTPS is decrypted, so recordings contain credentials and cookies in cleartext.
|
||||
* If omitted, no traffic is recorded.
|
||||
*/
|
||||
networkCapture?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
/**
|
||||
* What network capture may keep beyond method, URL, status, timing and size: header, query,
|
||||
* request-body, response-body. Each can carry credentials, so nothing here is recorded unless
|
||||
* it is asked for. Requires networkCapture.
|
||||
*/
|
||||
networkCaptureFields?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
/**
|
||||
* Expo or development-client URL to open after launching the installed application. Requires
|
||||
* buildId, applicationArchiveUrl, or expoGo.
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { parseNetworkCaptureFields } from '../networkCapture';
|
||||
|
||||
describe(parseNetworkCaptureFields, () => {
|
||||
it('accepts repeated and comma-separated values, like serve-sim does', () => {
|
||||
expect(parseNetworkCaptureFields(['header,query', 'request-body'])).toEqual([
|
||||
'header',
|
||||
'query',
|
||||
'request-body',
|
||||
]);
|
||||
});
|
||||
|
||||
it('matches the name case-insensitively, like serve-sim does', () => {
|
||||
expect(parseNetworkCaptureFields(['Header', ' QUERY '])).toEqual(['header', 'query']);
|
||||
});
|
||||
|
||||
it('keeps one of each', () => {
|
||||
expect(parseNetworkCaptureFields(['header', 'header'])).toEqual(['header']);
|
||||
});
|
||||
|
||||
it('rejects a field the recorder does not know', () => {
|
||||
expect(() => parseNetworkCaptureFields(['cookies'])).toThrow(/Unknown network capture field/);
|
||||
});
|
||||
|
||||
it('rejects an empty value rather than quietly keeping nothing', () => {
|
||||
expect(() => parseNetworkCaptureFields([''])).toThrow(/Empty network capture field/);
|
||||
expect(() => parseNetworkCaptureFields([',,'])).toThrow(/Empty network capture field/);
|
||||
});
|
||||
|
||||
it('returns nothing when nothing was asked for', () => {
|
||||
expect(parseNetworkCaptureFields([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
export const NETWORK_CAPTURE_FIELDS = ['header', 'query', 'request-body', 'response-body'] as const;
|
||||
|
||||
export type NetworkCaptureField = (typeof NETWORK_CAPTURE_FIELDS)[number];
|
||||
|
||||
/**
|
||||
* Resolve `--network-capture-field` the way serve-sim's own flag does: comma-separated values are
|
||||
* accepted, and the name is matched case-insensitively.
|
||||
*/
|
||||
export function parseNetworkCaptureFields(entries: readonly string[]): NetworkCaptureField[] {
|
||||
const fields = entries
|
||||
.flatMap(entry => entry.split(','))
|
||||
.map(field => field.trim().toLowerCase());
|
||||
if (fields.some(field => field.length === 0)) {
|
||||
throw new Error(
|
||||
`Empty network capture field. Choose from: ${NETWORK_CAPTURE_FIELDS.join(', ')}.`
|
||||
);
|
||||
}
|
||||
const unknown = fields.find(
|
||||
field => !NETWORK_CAPTURE_FIELDS.includes(field as NetworkCaptureField)
|
||||
);
|
||||
if (unknown !== undefined) {
|
||||
throw new Error(
|
||||
`Unknown network capture field '${unknown}'. Supported: ${NETWORK_CAPTURE_FIELDS.join(', ')}.`
|
||||
);
|
||||
}
|
||||
return [...new Set(fields as NetworkCaptureField[])];
|
||||
}
|
||||
Reference in new issue
Block a user