mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
930890cfda | ||
|
|
8f382124bb | ||
|
|
cbdd756bca | ||
|
|
f5e272953c | ||
|
|
3534870e52 | ||
|
|
0bb50b9ff3 | ||
|
|
04126d642d | ||
|
|
5c7d375aa3 | ||
|
|
77315174e2 |
No files matched your search
@@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user