Compare commits

...
Author SHA1 Message Date
Gabe DebesandClaude Opus 5.5 930890cfda [eas-cli] Add the simulator topic aliases to simulator:network-requests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 13:21:06 -07:00
Gabe DebesandClaude Opus 5.5 8f382124bb [eas-cli] Add the simulator:network-requests changeset
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 13:20:36 -07:00
Codex cbdd756bca [eas-cli] Download capture artifacts from stopped simulator sessions 2026-11-10 13:20:36 -07:00
Gabe DebesandClaude Opus 5.5 f5e272953c [eas-cli] Drop update suggestions from network capture errors
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 13:20:36 -07:00
Gabe DebesandClaude Opus 5.5 3534870e52 [eas-cli] Only remove a HAR file that simulator:network-requests opened
Track the write stream's open event so a failed download never deletes a file that
another process created at the --output path.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 13:20:36 -07:00
Gabe DebesandClaude Opus 5.5 0bb50b9ff3 [eas-cli] Create the simulator:network-requests HAR exclusively
Open the --output file with wx so a file created during the download is never
overwritten or removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 13:20:36 -07:00
Gabe DebesandClaude Opus 5.5 04126d642d [eas-cli] Match eas-cli patterns in simulator:network-requests
Validate capture data with zod, write the HAR the way src/utils/download.ts does, and
move 404 messages, Ctrl+C, and the --output spinner to the caller like sibling commands.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 13:20:36 -07:00
Gabe DebesandClaude Opus 5.5 5c7d375aa3 [eas-cli] Say what, why, and how in simulator:network-requests errors
Enable JSON output before parsing flags, and rewrite the network capture
errors to say what failed, why, and what to do next.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-11-10 13:20:36 -07:00
Gabe DebesandClaude Opus 5.5 77315174e2 [eas-cli] Add simulator:network-requests
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-11-10 13:20:36 -07:00
6 changed files with 1445 additions and 0 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
'eas-cli': minor
---
Add `simulator:network-requests` to list, follow, and inspect captured requests and download HAR files.
@@ -0,0 +1,492 @@
import { Config } from '@oclif/core';
import chalk from 'chalk';
import { stripVTControlCharacters } from 'node:util';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { DeviceRunSessionStatus } from '../../../graphql/generated';
import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSessionQuery';
import { downloadSimulatorArtifactAsync } from '../../../simulator/artifacts';
import Log from '../../../log';
import { ora } from '../../../ora';
import { loadSimulatorEnvAsync } from '../../../simulator/env';
import {
downloadNetworkCaptureAsync,
readNetworkRequestsAsync,
streamNetworkRequestsAsync,
} from '../../../simulator/networkRequests';
import { resolveSimulatorPreviewAsync } from '../../../simulator/preview';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import SimulatorNetworkRequests from '../network-requests';
jest.mock('../../../log');
jest.mock('../../../graphql/queries/DeviceRunSessionQuery');
jest.mock('../../../simulator/artifacts', () => ({
...jest.requireActual('../../../simulator/artifacts'),
downloadSimulatorArtifactAsync: jest.fn(),
}));
jest.mock('../../../ora', () => ({
ora: jest.fn(() => {
const spinner = {
fail: jest.fn(),
start: jest.fn(),
succeed: jest.fn(),
};
spinner.start.mockReturnValue(spinner);
return spinner;
}),
}));
jest.mock('../../../simulator/env', () => ({
...jest.requireActual('../../../simulator/env'),
loadSimulatorEnvAsync: jest.fn(),
}));
jest.mock('../../../simulator/networkRequests');
jest.mock('../../../simulator/preview', () => ({
...jest.requireActual('../../../simulator/preview'),
resolveSimulatorPreviewAsync: jest.fn(),
}));
jest.mock('../../../utils/json');
const mockLoadSimulatorEnvAsync = jest.mocked(loadSimulatorEnvAsync);
const mockOra = jest.mocked(ora);
const mockResolvePreviewAsync = jest.mocked(resolveSimulatorPreviewAsync);
const mockReadRequestsAsync = jest.mocked(readNetworkRequestsAsync);
const mockDownloadCaptureAsync = jest.mocked(downloadNetworkCaptureAsync);
const mockStreamRequestsAsync = jest.mocked(streamNetworkRequestsAsync);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
const mockLog = jest.mocked(Log.log);
const originalColorLevel = chalk.level;
const graphqlClient = {} as ExpoGraphqlClient;
const projectDir = '/test/project';
const preview = {
deviceRunSessionId: 'session-id',
baseUrl: new URL('https://preview.test'),
token: 'preview-token',
};
const requestSummary = {
id: 'request-id',
startedAt: Date.parse('2026-10-05T12:00:00.000Z'),
startedDateTime: '2026-10-05T12:00:00.000Z',
method: 'POST',
url: 'https://example.test/posts',
status: 201,
duration: 50,
requestSize: 100,
responseSize: 200,
};
const request = {
_captureId: 'request-id',
startedDateTime: '2026-10-05T12:00:00.000Z',
time: 50,
request: {
method: 'POST',
url: 'https://example.test/posts',
bodySize: 100,
headers: [{ name: 'Content-Type', value: 'application/json' }],
postData: { mimeType: 'application/json', text: '{"title":"Coin Flip"}' },
},
response: {
status: 201,
bodySize: 200,
content: { mimeType: 'application/json', text: '{"id":101}' },
},
};
function getMockOclifConfig(): Config {
const config = new Config({ root: __dirname });
config.runHook = async () => ({ failures: [], successes: [] });
return config;
}
describe(SimulatorNetworkRequests, () => {
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);
mockReadRequestsAsync.mockResolvedValue([]);
mockDownloadCaptureAsync.mockResolvedValue('/test/capture.har');
});
afterEach(() => {
delete process.env.EAS_SIMULATOR_SESSION_ID;
chalk.level = originalColorLevel;
});
it.each([DeviceRunSessionStatus.Stopped, DeviceRunSessionStatus.Errored])(
'downloads the capture for a %s session without 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', 'capture.har', '--json']).runAsync();
expect(downloadSimulatorArtifactAsync).toHaveBeenCalledWith(session, 'network-capture', {
artifact: 2,
output: 'capture.har',
nonInteractive: true,
json: true,
});
expect(mockResolvePreviewAsync).not.toHaveBeenCalled();
expect(DeviceRunSessionQuery.byIdAsync).toHaveBeenCalledTimes(1);
}
);
it.each([['--follow'], ['--request-id', 'r1']])(
'rejects live inspection of a stopped session %s',
async (...args) => {
jest
.mocked(DeviceRunSessionQuery.byIdAsync)
.mockResolvedValue({ id: 'session-id', status: DeviceRunSessionStatus.Stopped } as Awaited<
ReturnType<typeof DeviceRunSessionQuery.byIdAsync>
>);
await expect(createCommand(args).runAsync()).rejects.toThrow('Use --output <path>');
expect(mockResolvePreviewAsync).not.toHaveBeenCalled();
}
);
it('rejects artifact selection on a running session', async () => {
await expect(createCommand(['--artifact', '1']).runAsync()).rejects.toThrow(
'only available for stopped sessions'
);
expect(mockReadRequestsAsync).not.toHaveBeenCalled();
});
it('prints a JSON list for an explicit session with the default limit', async () => {
mockReadRequestsAsync.mockResolvedValue([requestSummary]);
await createCommand(['--id', 'session-id', '--json']).runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(DeviceRunSessionQuery.byIdAsync).toHaveBeenCalledWith(graphqlClient, 'session-id');
expect(mockResolvePreviewAsync).toHaveBeenCalledWith(
expect.objectContaining({ id: 'session-id' })
);
expect(mockReadRequestsAsync).toHaveBeenCalledWith(preview, {
limit: 100,
requestId: undefined,
});
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
deviceRunSessionId: 'session-id',
requests: [requestSummary],
});
expect(mockLog).not.toHaveBeenCalled();
expect(mockDownloadCaptureAsync).not.toHaveBeenCalled();
});
it('prints a full request detail including captured headers and bodies as JSON', async () => {
mockReadRequestsAsync.mockResolvedValue(request);
await createCommand(['--request-id', 'request-id', '--json']).runAsync();
expect(mockReadRequestsAsync).toHaveBeenCalledWith(preview, {
limit: 100,
requestId: 'request-id',
});
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
deviceRunSessionId: 'session-id',
request,
});
expect(mockLog).not.toHaveBeenCalled();
});
it('downloads a HAR and prints only the resulting file metadata as JSON', async () => {
await createCommand(['--output', './capture.har', '--json']).runAsync();
expect(mockDownloadCaptureAsync).toHaveBeenCalledWith(preview, './capture.har');
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
deviceRunSessionId: 'session-id',
filePath: '/test/capture.har',
});
expect(mockReadRequestsAsync).not.toHaveBeenCalled();
expect(mockLog).not.toHaveBeenCalled();
expect(mockOra).not.toHaveBeenCalled();
});
it('shows a spinner with the saved path for human output', async () => {
await createCommand(['-o', './capture.har']).runAsync();
const spinner = mockOra.mock.results[0].value;
expect(mockOra).toHaveBeenCalledWith('Saving network capture');
expect(spinner.succeed).toHaveBeenCalledWith('Saved network capture to /test/capture.har');
expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled();
expect(mockReadRequestsAsync).not.toHaveBeenCalled();
});
it('forwards an explicit limit and uses the dotenv session ID', async () => {
mockReadRequestsAsync.mockResolvedValue([requestSummary]);
mockLoadSimulatorEnvAsync.mockImplementation(async () => {
process.env.EAS_SIMULATOR_SESSION_ID = 'dotenv-id';
});
await createCommand(['--limit', '3']).runAsync();
expect(mockLoadSimulatorEnvAsync).toHaveBeenCalledWith(projectDir);
expect(DeviceRunSessionQuery.byIdAsync).toHaveBeenCalledWith(graphqlClient, 'dotenv-id');
expect(mockReadRequestsAsync).toHaveBeenCalledWith(preview, { limit: 3, requestId: undefined });
expect(mockLog).toHaveBeenCalledWith(
'request-id POST https://example.test/posts 201 50ms'
);
});
it('sanitizes terminal controls in human request summaries', async () => {
mockReadRequestsAsync.mockResolvedValue([
{ ...requestSummary, url: '\u001b[31mhttps://example.test/posts\u001b[0m\u0007' },
]);
await createCommand([]).runAsync();
expect(mockLog).toHaveBeenCalledWith(
'request-id POST https://example.test/posts 201 50ms'
);
});
it('rounds floating-point HAR timing noise only in human summaries', async () => {
const summary = { ...requestSummary, duration: 2.8999999999999995 };
mockReadRequestsAsync.mockResolvedValue([summary]);
await createCommand([]).runAsync();
expect(mockLog).toHaveBeenCalledWith(
'request-id POST https://example.test/posts 201 2.9ms'
);
await createCommand(['--json']).runAsync();
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
deviceRunSessionId: 'session-id',
requests: [summary],
});
});
it('adds intentional colors after sanitizing remote text and preserves full URLs', async () => {
chalk.level = 1;
const url = `https://example.test/${'long-path/'.repeat(30)}`;
mockReadRequestsAsync.mockResolvedValue([
{
...requestSummary,
id: '\u001b[35mr1\u001b[0m\u0007',
startedDateTime: '\u001b[35m2026-10-05T12:00:00.000Z\u001b[0m\u0007',
method: '\u001b[35mGET\u001b[0m\u0007',
url: `\u001b[35m${url}\u001b[0m\u0007`,
},
]);
await createCommand(['--timestamp']).runAsync();
const output = mockLog.mock.calls[0][0] as string;
expect(output).toBe(
[
chalk.dim('2026-10-05T12:00:00.000Z'),
chalk.dim('r1 '),
chalk.bold.cyan('GET '),
url,
chalk.green('201'),
chalk.dim('50ms'),
].join(' ')
);
expect(output).toContain('\u001b[1m\u001b[36mGET ');
expect(output).not.toContain('\u001b[35m');
expect(output).not.toContain('\u0007');
expect(stripVTControlCharacters(output)).toBe(
`2026-10-05T12:00:00.000Z r1 GET ${url} 201 50ms`
);
});
it.each([
[200, '\u001b[32m'],
[299, '\u001b[32m'],
[301, '\u001b[36m'],
[399, '\u001b[36m'],
[404, '\u001b[33m'],
[499, '\u001b[33m'],
[500, '\u001b[31m'],
[599, '\u001b[31m'],
[0, '\u001b[31m'],
])('colors numeric status %i by response class', async (status, color) => {
chalk.level = 1;
mockReadRequestsAsync.mockResolvedValue([{ ...requestSummary, status }]);
await createCommand([]).runAsync();
expect(mockLog.mock.calls[0][0]).toContain(`${color}${status}\u001b[39m`);
});
it('leaves JSON data untouched when terminal color is enabled', async () => {
chalk.level = 1;
mockReadRequestsAsync.mockResolvedValue([requestSummary]);
await createCommand(['--json']).runAsync();
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
deviceRunSessionId: 'session-id',
requests: [requestSummary],
});
expect(JSON.stringify(mockPrintJsonOnlyOutput.mock.calls[0][0])).not.toContain('\\u001b');
expect(mockLog).not.toHaveBeenCalled();
});
it('shows request start timestamps only when requested in human output', async () => {
mockReadRequestsAsync.mockResolvedValue([requestSummary]);
await createCommand(['--timestamp']).runAsync();
expect(mockLog).toHaveBeenCalledWith(
'2026-10-05T12:00:00.000Z request-id POST https://example.test/posts 201 50ms'
);
});
it.each(['--follow', '-f'])(
'streams human summaries with %s without fetching a finite list',
async follow => {
mockStreamRequestsAsync.mockImplementation(async (_preview, onRequest) => {
onRequest(requestSummary);
});
await createCommand([follow, '--timestamp']).runAsync();
expect(mockStreamRequestsAsync).toHaveBeenCalledWith(
preview,
expect.any(Function),
expect.any(AbortSignal)
);
expect(mockLog).toHaveBeenCalledWith(
'2026-10-05T12:00:00.000Z request-id POST https://example.test/posts 201 50ms'
);
expect(mockReadRequestsAsync).not.toHaveBeenCalled();
expect(mockDownloadCaptureAsync).not.toHaveBeenCalled();
expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled();
}
);
it('stops following on Ctrl+C and removes its interrupt handler', async () => {
const listeners = process.listeners('SIGINT');
let signal: AbortSignal | undefined;
mockStreamRequestsAsync.mockImplementation(async (_preview, _onRequest, followSignal) => {
signal = followSignal;
process.emit('SIGINT');
});
await createCommand(['--follow']).runAsync();
expect(signal?.aborted).toBe(true);
expect(process.listeners('SIGINT')).toEqual(listeners);
});
it.each([
{ options: ['--json'] },
{ options: ['--limit', '1'] },
{ options: ['--request-id', 'r1'] },
{ options: ['--output', 'capture.har'] },
])('rejects incompatible follow options $options before session access', async ({ options }) => {
await expect(createCommand(['--follow', ...options]).runAsync()).rejects.toThrow();
expect(mockResolvePreviewAsync).not.toHaveBeenCalled();
expect(mockStreamRequestsAsync).not.toHaveBeenCalled();
});
it('enables JSON output before rejecting JSON follow output', async () => {
await expect(createCommand(['--json', '--follow']).runAsync()).rejects.toThrow(
'Use either --json or --follow, not both.'
);
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockLoadSimulatorEnvAsync).not.toHaveBeenCalled();
});
it('prints a full request detail for human output', async () => {
mockReadRequestsAsync.mockResolvedValue(request);
await createCommand(['--request-id', 'request-id']).runAsync();
expect(mockLog).toHaveBeenCalledWith(JSON.stringify(request, null, 2));
expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled();
});
it('explains the initial-launch limitation when the capture is empty', async () => {
await createCommand([]).runAsync();
expect(mockLog).toHaveBeenCalledWith(
'No completed network requests were captured. Initial-launch requests may have been missed.'
);
});
it('rejects combining a request detail and HAR output before resolving a session', async () => {
await expect(
createCommand(['--request-id', 'request-id', '--output', './capture.har']).runAsync()
).rejects.toThrow();
expect(mockResolvePreviewAsync).not.toHaveBeenCalled();
expect(mockReadRequestsAsync).not.toHaveBeenCalled();
expect(mockDownloadCaptureAsync).not.toHaveBeenCalled();
});
it('rejects a nonpositive request limit', async () => {
await expect(createCommand(['--limit', '0']).runAsync()).rejects.toThrow();
expect(mockResolvePreviewAsync).not.toHaveBeenCalled();
});
it('propagates a missing capture error without printing an empty list', async () => {
mockReadRequestsAsync.mockRejectedValue(
new Error('No network capture was found. Start the session with --network-capture.')
);
await expect(createCommand([]).runAsync()).rejects.toThrow(
'Start the session with --network-capture.'
);
expect(mockLog).not.toHaveBeenCalled();
expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled();
});
it('propagates HAR download failures without claiming a file was saved', async () => {
mockDownloadCaptureAsync.mockRejectedValue(new Error('The output file already exists.'));
await expect(createCommand(['--output', './capture.har', '--json']).runAsync()).rejects.toThrow(
'The output file already exists.'
);
expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled();
expect(mockLog).not.toHaveBeenCalled();
expect(mockReadRequestsAsync).not.toHaveBeenCalled();
});
it('fails the spinner when the HAR download fails', async () => {
mockDownloadCaptureAsync.mockRejectedValue(new Error('The output file already exists.'));
await expect(createCommand(['--output', './capture.har']).runAsync()).rejects.toThrow(
'The output file already exists.'
);
const spinner = mockOra.mock.results[0].value;
expect(spinner.fail).toHaveBeenCalledWith('Failed to save network capture');
expect(spinner.succeed).not.toHaveBeenCalled();
});
it('stops before reading traffic when there is no simulator 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(mockReadRequestsAsync).not.toHaveBeenCalled();
expect(mockDownloadCaptureAsync).not.toHaveBeenCalled();
});
});
function createCommand(args: string[]): SimulatorNetworkRequests {
const command = new SimulatorNetworkRequests(args, getMockOclifConfig());
// @ts-expect-error getContextAsync is protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
loggedIn: { graphqlClient },
projectDir,
});
return command;
}
@@ -0,0 +1,216 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
import Log from '../../log';
import { downloadSimulatorArtifactAsync, hasSimulatorArtifacts } from '../../simulator/artifacts';
import {
getSimulatorCommandAliases,
getSimulatorCommandHiddenAliases,
} from '../../simulator/commandAliases';
import { ora } from '../../ora';
import {
EAS_SIMULATOR_SESSION_ID,
SIMULATOR_DOTENV_FILE_NAME,
loadSimulatorEnvAsync,
} from '../../simulator/env';
import {
type NetworkRequestSummary,
downloadNetworkCaptureAsync,
readNetworkRequestsAsync,
streamNetworkRequestsAsync,
} from '../../simulator/networkRequests';
import { resolveSimulatorPreviewAsync } from '../../simulator/preview';
import { stripTerminalControlCharacters } from '../../simulator/utils';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class SimulatorNetworkRequests extends EasCommand {
static override aliases = getSimulatorCommandAliases('network-requests');
static override hiddenAliases = getSimulatorCommandHiddenAliases('network-requests');
static override description =
'[EXPERIMENTAL] inspect completed network requests from a capture-enabled simulator session';
static override examples = [
'<%= config.bin %> simulator:network-requests --json',
'<%= config.bin %> simulator:network-requests --follow --timestamp',
'<%= config.bin %> simulator:network-requests --request-id r1 --json',
'<%= config.bin %> simulator:network-requests --output capture.har',
];
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).',
}),
'request-id': Flags.string({
description:
'Show a captured request, including the headers and bodies selected for capture.',
exclusive: ['output'],
}),
output: Flags.string({
char: 'o',
description: 'Save the live HAR or a stopped session capture artifact to this path.',
exclusive: ['request-id'],
}),
follow: Flags.boolean({
char: 'f',
description: 'Stream completed requests, including those retained by the current capture.',
exclusive: ['request-id', 'output', 'limit'],
}),
limit: Flags.integer({
description: 'Maximum number of recent live requests to list without --follow.',
default: 100,
min: 1,
}),
timestamp: Flags.boolean({
description: 'Show request start timestamps in live human-readable lists.',
}),
...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(SimulatorNetworkRequests);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (jsonFlag && flags.follow) {
throw new Error('Use either --json or --follow, not both.');
}
const {
projectDir,
loggedIn: { graphqlClient },
} = await this.getContextAsync(SimulatorNetworkRequests, { 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 || flags['request-id']) {
throw new Error(
'The session has stopped. --follow and --request-id are only available while it runs. Use --output <path> to download its capture artifact.'
);
}
await downloadSimulatorArtifactAsync(session, 'network-capture', {
artifact: flags.artifact,
output: flags.output,
nonInteractive,
json: jsonFlag,
});
return;
}
const preview = await resolveSimulatorPreviewAsync(session);
if (flags.artifact !== undefined) {
throw new Error(
'--artifact is only available for stopped sessions. Use --output <path> to save the live HAR.'
);
}
if (flags.follow) {
const abortController = new AbortController();
const interruptHandler = (): void => {
if (abortController.signal.aborted) {
process.exit(130);
}
abortController.abort();
};
process.on('SIGINT', interruptHandler);
try {
await streamNetworkRequestsAsync(
preview,
request => {
Log.log(formatRequestSummary(request, flags.timestamp));
},
abortController.signal
);
} finally {
process.removeListener('SIGINT', interruptHandler);
}
return;
}
if (flags.output) {
const downloadSpinner = jsonFlag ? null : ora('Saving network capture').start();
let filePath: string;
try {
filePath = await downloadNetworkCaptureAsync(preview, flags.output);
downloadSpinner?.succeed(`Saved network capture to ${filePath}`);
} catch (err) {
downloadSpinner?.fail('Failed to save network capture');
throw err;
}
if (jsonFlag) {
printJsonOnlyOutput({ deviceRunSessionId: preview.deviceRunSessionId, filePath });
}
return;
}
const result = await readNetworkRequestsAsync(preview, {
limit: flags.limit,
requestId: flags['request-id'],
});
if (jsonFlag) {
printJsonOnlyOutput({
deviceRunSessionId: preview.deviceRunSessionId,
...(Array.isArray(result) ? { requests: result } : { request: result }),
});
} else if (!Array.isArray(result)) {
Log.log(
stripTerminalControlCharacters(JSON.stringify(result, null, 2), {
keepNewlinesAndTabs: true,
})
);
} else if (result.length === 0) {
Log.log(
'No completed network requests were captured. Initial-launch requests may have been missed.'
);
} else {
for (const request of result) {
Log.log(formatRequestSummary(request, flags.timestamp));
}
}
}
}
function formatRequestSummary(request: NetworkRequestSummary, timestamp = false): string {
let status = String(request.status);
if (request.status === 0 || request.status >= 500) {
status = chalk.red(status);
} else if (request.status >= 400) {
status = chalk.yellow(status);
} else if (request.status >= 300) {
status = chalk.cyan(status);
} else if (request.status >= 200) {
status = chalk.green(status);
}
const fields = [
chalk.dim(stripTerminalControlCharacters(request.id ?? '-').padEnd(5)),
chalk.bold.cyan(stripTerminalControlCharacters(request.method).padEnd(6)),
stripTerminalControlCharacters(request.url),
status,
chalk.dim(`${Math.round(request.duration * 10) / 10}ms`),
];
if (timestamp) {
fields.unshift(chalk.dim(stripTerminalControlCharacters(request.startedDateTime)));
}
return fields.join(' ');
}
@@ -7,6 +7,7 @@ import SimulatorGet from '../../commands/simulator/get';
import Simulator from '../../commands/simulator/index';
import SimulatorList from '../../commands/simulator/list';
import SimulatorLogs from '../../commands/simulator/logs';
import SimulatorNetworkRequests from '../../commands/simulator/network-requests';
import SimulatorStop from '../../commands/simulator/stop';
import { isSimulatorCommandId } from '../commandAliases';
@@ -20,6 +21,7 @@ describe('simulator command aliases', () => {
['get', SimulatorGet],
['list', SimulatorList],
['logs', SimulatorLogs],
['network-requests', SimulatorNetworkRequests],
['stop', SimulatorStop],
])('runs simulator:%s from every simulator topic alias', (name, command) => {
expect(command.aliases).toEqual([`sim:${name}`]);
@@ -0,0 +1,519 @@
import { mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises';
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 { Response } from '../../fetch';
import {
type NetworkRequest,
type NetworkRequestSummary,
downloadNetworkCaptureAsync,
readNetworkRequestsAsync,
streamNetworkRequestsAsync,
} from '../networkRequests';
import { fetchSimulatorPreviewAsync, streamSimulatorPreviewAsync } from '../preview';
jest.mock('../preview', () => ({
...jest.requireActual('../preview'),
fetchSimulatorPreviewAsync: jest.fn(),
streamSimulatorPreviewAsync: jest.fn(),
}));
const NOT_ENABLED_MESSAGE =
'Network capture is not enabled for this session. Capture must be requested when the session starts. Start a new session with `eas simulator:start --network-capture`.';
const preview = {
deviceRunSessionId: 'session-id',
baseUrl: new URL('https://preview.test'),
token: 'secret',
};
const signal = new AbortController().signal;
let directory: string;
function entry(id: string): NetworkRequest {
return {
_captureId: id,
_captureStartedAt: 123.45,
startedDateTime: '2026-10-05T20:54:22.074Z',
time: 82,
request: { method: 'POST', url: 'https://example.test/posts', bodySize: 0, postData: null },
response: { status: 201, bodySize: 12, content: { text: '{"id":101}' } },
};
}
function reply(body: string | Readable): void {
jest
.mocked(fetchSimulatorPreviewAsync)
.mockResolvedValue(new Response(typeof body === 'string' ? Readable.from([body]) : body));
}
function rejectOnTimeout(): AbortController {
const controller = new AbortController();
jest.spyOn(AbortSignal, 'timeout').mockReturnValue(controller.signal);
jest.mocked(fetchSimulatorPreviewAsync).mockImplementation(
async (_preview, _route, options) =>
await new Promise<Response>((_resolve, reject) => {
if (options?.signal?.aborted) {
reject(new Error('secret remote URL'));
}
options?.signal?.addEventListener('abort', () => {
reject(new Error('secret remote URL'));
});
})
);
return controller;
}
function capturedRequest(
id: string,
startedAt = Date.parse(entry(id).startedDateTime)
): {
id: string;
startedAt: number;
method: string;
url: string;
status: number;
durationMs: number;
requestBytes: number;
responseBytes: number;
} {
return {
id,
startedAt,
method: 'POST',
url: 'https://example.test/posts',
status: 201,
durationMs: 82,
requestBytes: 0,
responseBytes: 12,
};
}
function streamEvents(events: unknown[]): void {
jest.mocked(streamSimulatorPreviewAsync).mockImplementation(async (_preview, _route, onData) => {
for (const event of events) {
onData(typeof event === 'string' ? event : JSON.stringify(event));
}
});
}
beforeEach(async () => {
jest.clearAllMocks();
directory = await mkdtemp(path.join(os.tmpdir(), 'eas-network-requests-test-'));
});
afterEach(async () => {
jest.restoreAllMocks();
await rm(directory, { recursive: true, force: true });
});
describe(readNetworkRequestsAsync, () => {
it('closes a real HTTP response immediately after a malformed capture frame', async () => {
let markClosed: () => void = () => {};
const closed = new Promise<void>(resolve => {
markClosed = resolve;
});
const server = createServer((_request, response) => {
response.writeHead(200, { 'content-type': 'application/x-ndjson' });
response.write('malformed-frame\n');
const timer = setInterval(() => response.write(' \n'), 10);
response.on('close', () => {
clearInterval(timer);
markClosed();
});
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
jest
.mocked(fetchSimulatorPreviewAsync)
.mockImplementationOnce(
jest.requireActual<typeof import('../preview')>('../preview').fetchSimulatorPreviewAsync
);
let timeout: NodeJS.Timeout | undefined;
try {
await expect(
readNetworkRequestsAsync(
{
...preview,
baseUrl: new URL(`http://127.0.0.1:${(server.address() as AddressInfo).port}`),
},
{ limit: 100 }
)
).rejects.toThrow('Could not read the network capture');
await Promise.race([
closed,
new Promise<void>((_resolve, reject) => {
timeout = setTimeout(() => {
reject(new Error('The capture connection stayed open.'));
}, 1000);
}),
]);
} finally {
clearTimeout(timeout);
server.closeAllConnections();
await new Promise<void>(resolve =>
server.close(() => {
resolve();
})
);
}
});
it('keeps recent summaries without retaining captured bodies, including a final line without a newline', async () => {
reply([entry('r1'), entry('r2'), entry('r3')].map(value => JSON.stringify(value)).join('\n'));
const requests = await readNetworkRequestsAsync(preview, { limit: 2 });
expect(fetchSimulatorPreviewAsync).toHaveBeenCalledWith(preview, '/network-capture.ndjson', {
signal: expect.any(AbortSignal),
notFoundMessage: NOT_ENABLED_MESSAGE,
});
expect(requests).toEqual(
['r2', 'r3'].map(id => ({
id,
startedAt: 123.45,
startedDateTime: '2026-10-05T20:54:22.074Z',
method: 'POST',
url: 'https://example.test/posts',
status: 201,
duration: 82,
requestSize: 0,
responseSize: 12,
}))
);
});
it('orders snapshots by start date and limits the latest starts rather than completions', async () => {
const entries = [
{ ...entry('r3'), startedDateTime: '2026-10-05T20:54:24.000Z', _captureStartedAt: 1 },
{ ...entry('r1'), startedDateTime: '2026-10-05T20:54:22.000Z', _captureStartedAt: 3 },
{ ...entry('r2'), startedDateTime: '2026-10-05T20:54:23.000Z', _captureStartedAt: 2 },
];
reply(entries.map(value => JSON.stringify(value)).join('\n'));
expect(await readNetworkRequestsAsync(preview, { limit: 2 })).toEqual([
expect.objectContaining({ id: 'r2' }),
expect.objectContaining({ id: 'r3' }),
]);
reply(entries.map(value => JSON.stringify(value)).join('\n'));
expect(await readNetworkRequestsAsync(preview, { limit: 1 })).toEqual([
expect.objectContaining({ id: 'r3' }),
]);
});
it('uses fractional start times and numeric request IDs to order equal start dates', async () => {
const entries = [
{ ...entry('r10'), _captureStartedAt: 123.45 },
{ ...entry('r3'), _captureStartedAt: 123.5 },
{ ...entry('r2'), _captureStartedAt: 123.45 },
];
reply(entries.map(value => JSON.stringify(value)).join('\n'));
expect(await readNetworkRequestsAsync(preview, { limit: 2 })).toEqual([
expect.objectContaining({ id: 'r10' }),
expect.objectContaining({ id: 'r3' }),
]);
});
it('returns selected captured headers and bodies without depending on the live request buffer', async () => {
const request = entry('r1');
reply(JSON.stringify(request));
expect(await readNetworkRequestsAsync(preview, { limit: 100, requestId: 'r1' })).toEqual(
request
);
});
it('handles UTF-8 split across chunks', async () => {
const bytes = Buffer.from(
JSON.stringify({
...entry('r1'),
request: { ...entry('r1').request, url: 'https://example.test/é' },
})
);
const split = bytes.indexOf(Buffer.from('é')) + 1;
reply(Readable.from([bytes.subarray(0, split), bytes.subarray(split)]));
const result = await readNetworkRequestsAsync(preview, { limit: 1 });
expect(result).toEqual([expect.objectContaining({ url: 'https://example.test/é' })]);
});
it('returns an empty list for an empty capture and reports a missing request', async () => {
reply('');
expect(await readNetworkRequestsAsync(preview, { limit: 100 })).toEqual([]);
reply('');
await expect(
readNetworkRequestsAsync(preview, { limit: 100, requestId: 'r1' })
).rejects.toThrow('not found');
});
it.each([
'secret-body',
'{"startedDateTime":"secret-body"}',
JSON.stringify({ ...entry('r1'), startedDateTime: 'secret-body' }),
])('reports malformed data without echoing decrypted traffic %#', async data => {
reply(data);
const error = readNetworkRequestsAsync(preview, { limit: 100 });
await expect(error).rejects.toThrow('Could not read the network capture.');
await expect(error).rejects.not.toThrow('secret-body');
});
it('reports a capture deadline', async () => {
const controller = rejectOnTimeout();
const pending = readNetworkRequestsAsync(preview, { limit: 100 });
const rejected = expect(pending).rejects.toThrow('The network capture request timed out.');
controller.abort();
await rejected;
expect(AbortSignal.timeout).toHaveBeenCalledWith(10 * 60_000);
});
it('retains useful request failures', async () => {
jest
.mocked(fetchSimulatorPreviewAsync)
.mockRejectedValue(new Error('Preview API access was refused.'));
await expect(readNetworkRequestsAsync(preview, { limit: 100 })).rejects.toThrow(
'Preview API access was refused.'
);
});
});
describe(streamNetworkRequestsAsync, () => {
it('follows retained completed requests and emits live completions immediately', async () => {
const requests: NetworkRequestSummary[] = [];
jest
.mocked(streamSimulatorPreviewAsync)
.mockImplementation(async (_preview, _route, onData) => {
onData(JSON.stringify({ type: 'meta', meta: { attachment: 'starting' }, initial: true }));
onData(JSON.stringify({ type: 'meta', meta: { attachment: 'capturing' } }));
onData(JSON.stringify({ type: 'finished', request: capturedRequest('r1', 1000) }));
onData(JSON.stringify({ type: 'finished', request: capturedRequest('r2', 2000) }));
onData(JSON.stringify({ type: 'started', request: capturedRequest('r3', 3000) }));
onData(JSON.stringify({ type: 'finished', request: capturedRequest('r4', 4000) }));
expect(requests.map(request => request.id)).toEqual(['r1', 'r2', 'r4']);
onData(
JSON.stringify({
type: 'finished',
request: { ...capturedRequest('r3', 3000), status: null, durationMs: null },
})
);
});
await streamNetworkRequestsAsync(preview, request => requests.push(request), signal);
expect(streamSimulatorPreviewAsync).toHaveBeenCalledWith(
preview,
'/network-capture',
expect.any(Function),
{ signal, notFoundMessage: NOT_ENABLED_MESSAGE }
);
expect(fetchSimulatorPreviewAsync).not.toHaveBeenCalled();
expect(requests.map(request => request.id)).toEqual(['r1', 'r2', 'r4', 'r3']);
expect(requests[3]).toEqual({
id: 'r3',
startedAt: 3000,
startedDateTime: '1970-01-01T00:00:03.000Z',
method: 'POST',
url: 'https://example.test/posts',
status: 0,
duration: 0,
requestSize: 0,
responseSize: 12,
});
});
it('keeps following when capture restarts, request IDs are reused, or events are unknown', async () => {
streamEvents([
{ type: 'meta', meta: { attachment: 'capturing' }, initial: true },
{ type: 'session', udid: 'SIM-A' },
{ type: 'finished', request: capturedRequest('r1', 1000) },
{ type: 'cleared' },
{ type: 'meta', meta: { attachment: 'starting' } },
{ type: 'started', request: capturedRequest('r1', 2000) },
{ type: 'finished', request: capturedRequest('r1', 2000) },
{ type: 'evicted', id: 'r1' },
{ type: 'finished', request: capturedRequest('r2', 3000) },
]);
const requests: NetworkRequestSummary[] = [];
await streamNetworkRequestsAsync(preview, request => requests.push(request), signal);
expect(requests.map(request => request.startedAt)).toEqual([1000, 2000, 3000]);
});
it.each([
['not-enabled', NOT_ENABLED_MESSAGE],
[
'failed',
'The network capture failed. The session reported a capture error. Open the session preview to see the error, or start a new session with `eas simulator:start --network-capture`.',
],
])(
'reports the %s capture state without exposing attachment errors',
async (attachment, message) => {
streamEvents([
{ type: 'meta', meta: { attachment, attachError: 'secret remote URL' }, initial: true },
]);
await expect(streamNetworkRequestsAsync(preview, jest.fn(), signal)).rejects.toEqual(
new Error(message)
);
}
);
it('stops on capture failure after emitting earlier completed requests', async () => {
streamEvents([
{ type: 'meta', meta: { attachment: 'capturing' } },
{ type: 'finished', request: capturedRequest('r1') },
{ type: 'meta', meta: { attachment: 'failed' } },
{ type: 'finished', request: capturedRequest('r2') },
]);
const onRequest = jest.fn();
await expect(streamNetworkRequestsAsync(preview, onRequest, signal)).rejects.toThrow(
'The network capture failed.'
);
expect(onRequest).toHaveBeenCalledTimes(1);
});
it('explains when capture is turned off after following has started', async () => {
streamEvents([
{ type: 'meta', meta: { attachment: 'capturing' }, initial: true },
{ type: 'finished', request: capturedRequest('r1', 1000) },
{ type: 'meta', meta: { attachment: 'not-enabled' } },
]);
const onRequest = jest.fn();
await expect(streamNetworkRequestsAsync(preview, onRequest, signal)).rejects.toThrow(
'Network capture was turned off,'
);
expect(onRequest).toHaveBeenCalledTimes(1);
});
it.each(['secret-body', '{"type":"finished","request":{"url":"secret-body"}}'])(
'reports malformed stream data without echoing decrypted traffic %#',
async data => {
streamEvents([data]);
const error = streamNetworkRequestsAsync(preview, jest.fn(), signal);
await expect(error).rejects.toThrow('Could not read the network capture stream.');
await expect(error).rejects.not.toThrow('secret-body');
}
);
it('preserves useful stream setup errors and returns after a clean shared interruption', async () => {
jest
.mocked(streamSimulatorPreviewAsync)
.mockRejectedValueOnce(new Error('The simulator preview is offline.'))
.mockResolvedValueOnce();
await expect(streamNetworkRequestsAsync(preview, jest.fn(), signal)).rejects.toThrow(
'The simulator preview is offline.'
);
await expect(streamNetworkRequestsAsync(preview, jest.fn(), signal)).resolves.toBeUndefined();
});
});
describe(downloadNetworkCaptureAsync, () => {
it('streams an exact HAR into a private file', async () => {
const data = JSON.stringify({ log: { entries: [entry('r1')] } });
reply(Readable.from([data.slice(0, 15), data.slice(15)]));
const output = path.join(directory, 'capture.har');
const oldUmask = process.umask(0);
try {
expect(await downloadNetworkCaptureAsync(preview, output)).toBe(output);
} finally {
process.umask(oldUmask);
}
expect(fetchSimulatorPreviewAsync).toHaveBeenCalledWith(preview, '/network-capture.har', {
signal: expect.any(AbortSignal),
notFoundMessage: NOT_ENABLED_MESSAGE,
});
expect(await readFile(output, 'utf8')).toBe(data);
expect((await stat(output)).mode & 0o777).toBe(0o600);
expect(await readdir(directory)).toEqual(['capture.har']);
});
it('refuses an existing file before requesting the capture', async () => {
const output = path.join(directory, 'capture.har');
await writeFile(output, 'keep');
reply('new capture');
await expect(downloadNetworkCaptureAsync(preview, output)).rejects.toThrow('already exists');
expect(fetchSimulatorPreviewAsync).not.toHaveBeenCalled();
expect(await readFile(output, 'utf8')).toBe('keep');
});
it('does not overwrite a file created during the download', async () => {
const output = path.join(directory, 'capture.har');
jest.mocked(fetchSimulatorPreviewAsync).mockImplementation(async () => {
await writeFile(output, 'created concurrently');
return new Response(Readable.from(['new capture']));
});
await expect(downloadNetworkCaptureAsync(preview, output)).rejects.toThrow('already exists');
expect(await readFile(output, 'utf8')).toBe('created concurrently');
expect(await readdir(directory)).toEqual(['capture.har']);
});
it('keeps a file created during a download whose body fails before the file opens', async () => {
const output = path.join(directory, 'capture.har');
jest.mocked(fetchSimulatorPreviewAsync).mockImplementation(async () => {
await writeFile(output, 'created concurrently');
const body = new Readable({ read() {} });
body.destroy(new Error('secret remote URL'));
return new Response(body);
});
await expect(downloadNetworkCaptureAsync(preview, output)).rejects.toThrow();
expect(await readFile(output, 'utf8')).toBe('created concurrently');
});
it('refuses to write through a dangling symlink', async () => {
const output = path.join(directory, 'capture.har');
await symlink(path.join(directory, 'missing-target'), output);
reply('new capture');
await expect(downloadNetworkCaptureAsync(preview, output)).rejects.toThrow('already exists');
expect(await readdir(directory)).toEqual(['capture.har']);
});
it('explains a missing output directory', async () => {
reply('capture');
await expect(
downloadNetworkCaptureAsync(preview, path.join(directory, 'missing', 'capture.har'))
).rejects.toThrow('Check that the --output directory exists and is writable,');
expect(await readdir(directory)).toEqual([]);
});
it('retains useful request errors without creating a file', async () => {
jest
.mocked(fetchSimulatorPreviewAsync)
.mockRejectedValue(new Error('Start the session with --network-capture.'));
await expect(
downloadNetworkCaptureAsync(preview, path.join(directory, 'capture.har'))
).rejects.toThrow('--network-capture');
expect(await readdir(directory)).toEqual([]);
});
it('removes the partial file after a failed transfer', async () => {
reply(
Readable.from(
(async function* () {
yield 'partial';
throw new Error('secret remote URL');
})()
)
);
const error = downloadNetworkCaptureAsync(preview, path.join(directory, 'capture.har'));
await expect(error).rejects.toThrow('Could not save the download.');
await expect(error).rejects.not.toThrow('secret');
expect(await readdir(directory)).toEqual([]);
});
it('reports a download deadline', async () => {
const controller = rejectOnTimeout();
const pending = downloadNetworkCaptureAsync(preview, path.join(directory, 'capture.har'));
const rejected = expect(pending).rejects.toThrow('The download timed out.');
controller.abort();
await rejected;
expect(AbortSignal.timeout).toHaveBeenCalledWith(10 * 60_000);
expect(await readdir(directory)).toEqual([]);
});
});
@@ -0,0 +1,211 @@
import readline from 'node:readline';
import { z } from 'zod';
import { downloadSimulatorFileAsync } from './download';
import {
type SimulatorPreview,
fetchSimulatorPreviewAsync,
streamSimulatorPreviewAsync,
} from './preview';
import { type Response } from '../fetch';
const NETWORK_CAPTURE_TIMEOUT_MS = 10 * 60_000;
const NETWORK_CAPTURE_NOT_ENABLED_MESSAGE =
'Network capture is not enabled for this session. Capture must be requested when the session starts. Start a new session with `eas simulator:start --network-capture`.';
const INVALID_NETWORK_CAPTURE_MESSAGE =
'Could not read the network capture. The capture data was incomplete or not in the expected format. Try again.';
const networkRequestSchema = z.looseObject({
_captureId: z.string().optional(),
_captureStartedAt: z.number().nullable().optional(),
startedDateTime: z.string().refine(value => Number.isFinite(Date.parse(value))),
time: z.number(),
request: z.looseObject({ method: z.string(), url: z.string(), bodySize: z.number() }),
response: z.looseObject({ status: z.number(), bodySize: z.number() }),
});
const networkCaptureEventSchema = z.union([
z.object({
type: z.literal('meta'),
initial: z.boolean().optional(),
meta: z.object({ attachment: z.string().optional() }),
}),
z.object({
type: z.literal('finished'),
request: z.object({
id: z.string(),
startedAt: z.number(),
method: z.string(),
url: z.string(),
status: z.number().nullable(),
durationMs: z.number().nullable(),
requestBytes: z.number(),
responseBytes: z.number(),
}),
}),
// Other events, such as `started` and `cleared`, have nothing to print.
z.object({ type: z.string().refine(type => type !== 'meta' && type !== 'finished') }),
]);
export type NetworkRequest = z.infer<typeof networkRequestSchema>;
export type NetworkRequestSummary = {
id: string | null;
startedAt: number | null;
startedDateTime: string;
method: string;
url: string;
status: number;
duration: number;
requestSize: number;
responseSize: number;
};
function parseJson(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return undefined;
}
}
function compareRequestStarts(a: NetworkRequestSummary, b: NetworkRequestSummary): number {
return (
Date.parse(a.startedDateTime) - Date.parse(b.startedDateTime) ||
(a.startedAt ?? 0) - (b.startedAt ?? 0) ||
(a.id ?? '').localeCompare(b.id ?? '', undefined, { numeric: true })
);
}
export async function readNetworkRequestsAsync(
preview: SimulatorPreview,
{ limit, requestId }: { limit: number; requestId?: string }
): Promise<NetworkRequestSummary[] | NetworkRequest> {
const controller = new AbortController();
const signal = AbortSignal.any([
controller.signal,
AbortSignal.timeout(NETWORK_CAPTURE_TIMEOUT_MS),
]);
let response: Response | undefined;
let lines: readline.Interface | undefined;
const requests: NetworkRequestSummary[] = [];
let selected: NetworkRequest | undefined;
try {
response = await fetchSimulatorPreviewAsync(preview, '/network-capture.ndjson', {
signal,
notFoundMessage: NETWORK_CAPTURE_NOT_ENABLED_MESSAGE,
});
lines = readline.createInterface({ input: response.body, crlfDelay: Infinity });
for await (const line of lines) {
if (!line.trim()) {
continue;
}
const result = networkRequestSchema.safeParse(parseJson(line));
if (!result.success) {
throw new Error(INVALID_NETWORK_CAPTURE_MESSAGE);
}
const entry = result.data;
if (requestId) {
if (entry._captureId === requestId) {
selected = entry;
}
continue;
}
requests.push({
id: entry._captureId ?? null,
startedAt: entry._captureStartedAt ?? null,
startedDateTime: entry.startedDateTime,
method: entry.request.method,
url: entry.request.url,
status: entry.response.status,
duration: entry.time,
requestSize: entry.request.bodySize,
responseSize: entry.response.bodySize,
});
}
} catch (error) {
if (signal.aborted) {
throw new Error(
'The network capture request timed out. The capture did not download within 10 minutes. Run `eas simulator:get` to check that the session is still running, then try again.'
);
}
if (!response) {
throw error;
}
throw new Error(INVALID_NETWORK_CAPTURE_MESSAGE);
} finally {
lines?.close();
response?.body.once('error', () => {});
controller.abort();
}
if (!requestId) {
return requests.sort(compareRequestStarts).slice(-limit);
}
if (!selected) {
throw new Error(
'The request was not found in the network capture. The ID does not match a completed request in the current capture. Run `eas simulator:network-requests` to see current request IDs.'
);
}
return selected;
}
export async function streamNetworkRequestsAsync(
preview: SimulatorPreview,
onRequest: (request: NetworkRequestSummary) => void,
signal: AbortSignal
): Promise<void> {
await streamSimulatorPreviewAsync(
preview,
'/network-capture',
data => {
const result = networkCaptureEventSchema.safeParse(parseJson(data));
if (!result.success) {
throw new Error(
'Could not read the network capture stream. An event from the session was not in the expected format. Run the command again.'
);
}
const event = result.data;
if ('meta' in event) {
if (event.meta.attachment === 'not-enabled') {
throw new Error(
event.initial
? NETWORK_CAPTURE_NOT_ENABLED_MESSAGE
: 'Network capture was turned off, so the session no longer records requests. Start a new session with `eas simulator:start --network-capture` to capture again.'
);
}
if (event.meta.attachment === 'failed') {
throw new Error(
'The network capture failed. The session reported a capture error. Open the session preview to see the error, or start a new session with `eas simulator:start --network-capture`.'
);
}
} else if ('request' in event) {
const { request } = event;
onRequest({
id: request.id,
startedAt: request.startedAt,
startedDateTime: new Date(request.startedAt).toISOString(),
method: request.method,
url: request.url,
status: request.status ?? 0,
duration: Math.max(0, request.durationMs ?? 0),
requestSize: request.requestBytes,
responseSize: request.responseBytes,
});
}
},
{ signal, notFoundMessage: NETWORK_CAPTURE_NOT_ENABLED_MESSAGE }
);
}
export async function downloadNetworkCaptureAsync(
preview: SimulatorPreview,
output: string
): Promise<string> {
return await downloadSimulatorFileAsync(
output,
async signal =>
await fetchSimulatorPreviewAsync(preview, '/network-capture.har', {
signal,
notFoundMessage: NETWORK_CAPTURE_NOT_ENABLED_MESSAGE,
})
);
}