mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-15 03:38:08 -10:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
930890cfda | ||
|
|
8f382124bb | ||
|
|
cbdd756bca | ||
|
|
f5e272953c | ||
|
|
3534870e52 | ||
|
|
0bb50b9ff3 | ||
|
|
04126d642d | ||
|
|
5c7d375aa3 | ||
|
|
77315174e2 | ||
|
|
e914819052 | ||
|
|
ab32f0c241 |
No files matched your search
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Fix black iOS session preview artifacts on multi-display Simulators by capturing the active display through serve-sim.
|
||||
|
||||
Skip automatic previews on older serve-sim versions that cannot exclude them from idle activity.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
Add `simulator:network-requests` to list, follow, and inspect captured requests and download HAR files.
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "27.7.1",
|
||||
"PRODUCTION": "27.7.1"
|
||||
"STAGING": "27.7.2",
|
||||
"PRODUCTION": "27.7.2"
|
||||
}
|
||||
@@ -315,10 +315,8 @@ describe(captureDeviceRunSessionPreviewAsync, () => {
|
||||
}) as ReturnType<typeof spawn>;
|
||||
}
|
||||
return (async () => {
|
||||
if (command === 'xcrun') {
|
||||
expect(args.slice(0, 4)).toEqual(['simctl', 'io', 'selected-device', 'screenshot']);
|
||||
await writeFile(args[4], Uint8Array.from(png));
|
||||
} else if (command === 'sips') {
|
||||
expect(command).not.toBe('xcrun');
|
||||
if (command === 'sips') {
|
||||
expect(args.slice(0, 2)).toEqual(['-Z', '320']);
|
||||
expect(await readFile(args[2])).toEqual(png);
|
||||
await writeFile(args[args.indexOf('--out') + 1], Uint8Array.from(resized));
|
||||
@@ -340,6 +338,10 @@ describe(captureDeviceRunSessionPreviewAsync, () => {
|
||||
await captureDeviceRunSessionPreviewAsync({
|
||||
runtimePlatform,
|
||||
device,
|
||||
captureIosScreenshotAsync: async (file, signal) => {
|
||||
signal.throwIfAborted();
|
||||
await writeFile(file, Uint8Array.from(png));
|
||||
},
|
||||
env: {},
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
@@ -372,6 +374,9 @@ describe(captureDeviceRunSessionPreviewAsync, () => {
|
||||
captureDeviceRunSessionPreviewAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
device: 'device',
|
||||
captureIosScreenshotAsync: async () => {
|
||||
throw new Error('capture failed');
|
||||
},
|
||||
env: {},
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
|
||||
@@ -40,9 +40,14 @@ import {
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
} from '../remoteDeviceRunSession';
|
||||
import { runServeSimActionAsync, stageServeSimAppAsync } from '../serveSimActions';
|
||||
import {
|
||||
runServeSimActionAsync,
|
||||
runServeSimActionWithResultAsync,
|
||||
stageServeSimAppAsync,
|
||||
} from '../serveSimActions';
|
||||
import { startServeSimActivityPolling } from '../serveSimActivity';
|
||||
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
|
||||
import * as previewCapture from '../serveSimPreviewCapture';
|
||||
|
||||
jest.mock('@ngrok/ngrok');
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
@@ -165,6 +170,12 @@ beforeEach(() => {
|
||||
.mockImplementation(async ({ artifactPath }) => installedBundleIds[artifactPath]);
|
||||
jest.mocked(verifyLocalEgressGuardAsync).mockReset().mockResolvedValue(undefined);
|
||||
jest.mocked(readLocalEgressHandoffAsync).mockReset().mockResolvedValue(null);
|
||||
jest
|
||||
.mocked(runServeSimActionWithResultAsync)
|
||||
.mockReset()
|
||||
.mockResolvedValue(
|
||||
JSON.stringify({ 'screenshot.capture': { properties: { automation: { type: 'boolean' } } } })
|
||||
);
|
||||
stopSessionPreview.mockResolvedValue(undefined);
|
||||
jest.mocked(startDeviceRunSessionPreview).mockReturnValue({ stopAsync: stopSessionPreview });
|
||||
jest.mocked(ensureFfmpegInstalledOnceAsync).mockResolvedValue(undefined);
|
||||
@@ -1479,6 +1490,119 @@ it.each([BuildRuntimePlatform.LINUX, BuildRuntimePlatform.DARWIN])(
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['missing automation', 'invalid catalog', 'unavailable catalog'])(
|
||||
'skips automated thumbnails with %s without stopping the iOS session',
|
||||
async failure => {
|
||||
if (failure === 'unavailable catalog') {
|
||||
jest
|
||||
.mocked(runServeSimActionWithResultAsync)
|
||||
.mockRejectedValueOnce(new Error('unknown action'));
|
||||
} else {
|
||||
jest
|
||||
.mocked(runServeSimActionWithResultAsync)
|
||||
.mockResolvedValueOnce(
|
||||
failure === 'invalid catalog'
|
||||
? 'invalid JSON'
|
||||
: JSON.stringify({ 'screenshot.capture': { properties: {} } })
|
||||
);
|
||||
}
|
||||
const host = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
preview: { baseDomain, authtoken },
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(runServeSimActionWithResultAsync).toHaveBeenCalledTimes(1);
|
||||
expect(startDeviceRunSessionPreview).not.toHaveBeenCalled();
|
||||
expect(ensureMacosPreviewEncoderInstalledAsync).not.toHaveBeenCalled();
|
||||
expect(stopServer).not.toHaveBeenCalled();
|
||||
await host.finishAsync();
|
||||
}
|
||||
);
|
||||
|
||||
it('cancels catalog inspection during shutdown without sending a thumbnail capture', async () => {
|
||||
let inspectionSignal: AbortSignal | undefined;
|
||||
jest.mocked(runServeSimActionWithResultAsync).mockImplementationOnce(async ({ signal }) => {
|
||||
inspectionSignal = signal;
|
||||
return await new Promise<string>((_resolve, reject) => {
|
||||
signal?.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||
});
|
||||
});
|
||||
const host = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
preview: { baseDomain, authtoken },
|
||||
});
|
||||
expect(inspectionSignal?.aborted).toBe(false);
|
||||
await host.finishAsync();
|
||||
expect(inspectionSignal?.aborted).toBe(true);
|
||||
expect(startDeviceRunSessionPreview).not.toHaveBeenCalled();
|
||||
expect(ensureMacosPreviewEncoderInstalledAsync).not.toHaveBeenCalled();
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('finishes manual screenshots even when thumbnail cleanup fails', async () => {
|
||||
const finishScreenshots = jest.fn().mockResolvedValue(undefined);
|
||||
const collector = jest
|
||||
.spyOn(screenshotCollector, 'startDeviceRunSessionScreenshotsAsync')
|
||||
.mockResolvedValueOnce({
|
||||
directory: '/tmp/manual-screenshots',
|
||||
finishAsync: finishScreenshots,
|
||||
});
|
||||
const cleanup = jest.fn().mockRejectedValue(new Error('unlink refused'));
|
||||
const factory = jest.spyOn(previewCapture, 'createServeSimPreviewCapture').mockReturnValueOnce({
|
||||
isSupportedAsync: jest.fn().mockResolvedValue(true),
|
||||
captureAsync: jest.fn(),
|
||||
cleanupAfterHostStoppedAsync: cleanup,
|
||||
});
|
||||
try {
|
||||
const host = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
preview: { baseDomain, authtoken },
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
await host.finishAsync();
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
expect(finishScreenshots).toHaveBeenCalledWith(true);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Could not remove session preview capture files.'
|
||||
);
|
||||
} finally {
|
||||
factory.mockRestore();
|
||||
collector.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('drains thumbnail capture before stopping the iOS host', async () => {
|
||||
const stopped = deferred<void>();
|
||||
stopSessionPreview.mockReturnValueOnce(stopped.promise);
|
||||
const host = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
preview: { baseDomain, authtoken },
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
const finishing = host.finishAsync();
|
||||
try {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(stopServer).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
stopped.resolve();
|
||||
await finishing;
|
||||
}
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each(['success', 'failure'])(
|
||||
'keeps macOS readiness nonblocking and drains encoder setup %s before ending the phase',
|
||||
async result => {
|
||||
|
||||
@@ -16,7 +16,11 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { WebSocketServer } from 'ws';
|
||||
|
||||
import { runServeSimActionAsync, stageServeSimAppAsync } from '../serveSimActions';
|
||||
import {
|
||||
runServeSimActionAsync,
|
||||
runServeSimActionWithResultAsync,
|
||||
stageServeSimAppAsync,
|
||||
} from '../serveSimActions';
|
||||
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
@@ -67,6 +71,18 @@ it('authenticates the upgrade, waits for ready, and sends the action once', asyn
|
||||
expect(actions).toEqual([{ id: 1, action: 'app.install', params: request().params }]);
|
||||
});
|
||||
|
||||
it('returns stdout from an authenticated action for catalog inspection', async () => {
|
||||
server.on('connection', socket => {
|
||||
socket.on('message', () =>
|
||||
socket.send(JSON.stringify({ id: 1, stdout: '{"actions":[]}', exitCode: 0 }))
|
||||
);
|
||||
socket.send(JSON.stringify({ ready: true }));
|
||||
});
|
||||
await expect(
|
||||
runServeSimActionWithResultAsync({ ...request(), action: 'actions.describe', params: {} })
|
||||
).resolves.toBe('{"actions":[]}');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ id: 1, exitCode: 1, stderr: 'install refused' }, 'install refused'],
|
||||
[{ id: 1, error: 'unknown action app.install' }, 'unknown action'],
|
||||
@@ -137,6 +153,55 @@ it('aborts an in-flight action without resending it', async () => {
|
||||
expect(attempts).toBe(1);
|
||||
});
|
||||
|
||||
it('cancels before dispatch while waiting for ready in drain mode', async () => {
|
||||
const controller = new AbortController();
|
||||
const actions = jest.fn();
|
||||
server.on('connection', socket => {
|
||||
socket.on('message', actions);
|
||||
controller.abort(new Error('stopped before ready'));
|
||||
socket.send(JSON.stringify({ ready: true }));
|
||||
});
|
||||
await expect(
|
||||
runServeSimActionAsync({
|
||||
...request(),
|
||||
signal: controller.signal,
|
||||
drainAfterDispatch: true,
|
||||
})
|
||||
).rejects.toThrow('stopped before ready');
|
||||
expect(actions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drains dispatched actions after cancellation in drain mode', async () => {
|
||||
const controller = new AbortController();
|
||||
let reply!: () => void;
|
||||
let dispatched!: () => void;
|
||||
const ready = new Promise<void>(resolve => {
|
||||
dispatched = resolve;
|
||||
});
|
||||
server.on('connection', socket => {
|
||||
socket.send(JSON.stringify({ ready: true }));
|
||||
socket.on('message', () => {
|
||||
reply = () => socket.send(JSON.stringify({ id: 1, stdout: 'completed', exitCode: 0 }));
|
||||
dispatched();
|
||||
});
|
||||
});
|
||||
let settled = false;
|
||||
const action = runServeSimActionAsync({
|
||||
...request(),
|
||||
signal: controller.signal,
|
||||
drainAfterDispatch: true,
|
||||
});
|
||||
void action.finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
await ready;
|
||||
controller.abort(new Error('stopped after dispatch'));
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(settled).toBe(false);
|
||||
reply();
|
||||
await expect(action).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not connect when already aborted', async () => {
|
||||
const connected = jest.fn();
|
||||
server.on('connection', connected);
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { runServeSimActionAsync, runServeSimActionWithResultAsync } from '../serveSimActions';
|
||||
import { createServeSimPreviewCapture } from '../serveSimPreviewCapture';
|
||||
|
||||
jest.mock('../serveSimActions');
|
||||
|
||||
const signal = new AbortController().signal;
|
||||
const tmpdir = path.join(os.tmpdir(), 'host-tmp');
|
||||
const staging = path.join(tmpdir, 'serve-sim-screenshots');
|
||||
const destination = path.join(os.tmpdir(), 'screen.png');
|
||||
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0xff, 0x00]);
|
||||
const options = { port: 1234, token: 'host-token', device: 'selected-udid', tmpdir };
|
||||
|
||||
beforeEach(async () => {
|
||||
await mkdir(staging, { recursive: true });
|
||||
jest.mocked(runServeSimActionAsync).mockReset();
|
||||
});
|
||||
|
||||
it('captures the selected active display and removes only its own staging', async () => {
|
||||
await writeFile(path.join(staging, 'unrelated.png'), 'keep');
|
||||
jest.mocked(runServeSimActionAsync).mockImplementation(async request => {
|
||||
expect(request).toEqual({
|
||||
port: 1234,
|
||||
token: 'host-token',
|
||||
action: 'screenshot.capture',
|
||||
params: {
|
||||
udid: 'selected-udid',
|
||||
fileName: expect.stringMatching(/^serve-sim-screenshot-preview-.*\.png$/),
|
||||
automation: true,
|
||||
},
|
||||
timeoutMs: 30_000,
|
||||
signal,
|
||||
drainAfterDispatch: true,
|
||||
});
|
||||
await writeFile(path.join(staging, request.params.fileName as string), png);
|
||||
return;
|
||||
});
|
||||
const capture = createServeSimPreviewCapture(options);
|
||||
await capture.captureAsync(destination, signal);
|
||||
expect(await readFile(destination)).toEqual(png);
|
||||
expect(await readdir(staging)).toEqual(['unrelated.png']);
|
||||
});
|
||||
|
||||
it('drains an already sent action before honoring abort and deleting its output', async () => {
|
||||
const controller = new AbortController();
|
||||
let complete!: () => void;
|
||||
const pending = new Promise<void>(resolve => {
|
||||
complete = resolve;
|
||||
});
|
||||
let staged!: string;
|
||||
jest.mocked(runServeSimActionAsync).mockImplementation(async request => {
|
||||
staged = path.join(staging, request.params.fileName as string);
|
||||
await pending;
|
||||
await writeFile(staged, png);
|
||||
return;
|
||||
});
|
||||
const capture = createServeSimPreviewCapture(options);
|
||||
let settled = false;
|
||||
const capturing = capture.captureAsync(destination, controller.signal);
|
||||
void capturing.then(
|
||||
() => {
|
||||
settled = true;
|
||||
},
|
||||
() => {
|
||||
settled = true;
|
||||
}
|
||||
);
|
||||
controller.abort(new Error('session stopped'));
|
||||
await Promise.resolve();
|
||||
expect(settled).toBe(false);
|
||||
complete();
|
||||
await expect(capturing).rejects.toThrow('session stopped');
|
||||
expect(await readdir(staging)).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(['timed out', 'connection closed'])(
|
||||
'keeps a possibly writable file after %s until the host stops',
|
||||
async message => {
|
||||
let staged!: string;
|
||||
jest.mocked(runServeSimActionAsync).mockImplementation(async request => {
|
||||
staged = path.join(staging, request.params.fileName as string);
|
||||
await writeFile(staged, 'reservation');
|
||||
throw new Error(message);
|
||||
});
|
||||
const capture = createServeSimPreviewCapture(options);
|
||||
await expect(capture.captureAsync(destination, signal)).rejects.toThrow(message);
|
||||
expect(await readFile(staged, 'utf8')).toBe('reservation');
|
||||
// The server may still write after the client's deadline or disconnect.
|
||||
await writeFile(staged, png);
|
||||
await capture.cleanupAfterHostStoppedAsync();
|
||||
expect(await readdir(staging)).toEqual([]);
|
||||
}
|
||||
);
|
||||
|
||||
it('attempts every owned cleanup when one file cannot be removed', async () => {
|
||||
let blocked!: string;
|
||||
let count = 0;
|
||||
jest.mocked(runServeSimActionAsync).mockImplementation(async request => {
|
||||
const staged = path.join(staging, request.params.fileName as string);
|
||||
if (count++ === 0) {
|
||||
blocked = staged;
|
||||
await mkdir(staged);
|
||||
} else {
|
||||
await writeFile(staged, png);
|
||||
}
|
||||
throw new Error('capture disconnected');
|
||||
});
|
||||
const capture = createServeSimPreviewCapture(options);
|
||||
await expect(capture.captureAsync(destination, signal)).rejects.toThrow('disconnected');
|
||||
await expect(capture.captureAsync(destination, signal)).rejects.toThrow('disconnected');
|
||||
await expect(capture.cleanupAfterHostStoppedAsync()).rejects.toThrow(
|
||||
'Could not remove preview captures'
|
||||
);
|
||||
expect(await readdir(staging)).toEqual([path.basename(blocked)]);
|
||||
});
|
||||
|
||||
it('does not send a capture when already aborted', async () => {
|
||||
const capture = createServeSimPreviewCapture(options);
|
||||
await expect(
|
||||
capture.captureAsync(destination, AbortSignal.abort(new Error('stopped')))
|
||||
).rejects.toThrow('stopped');
|
||||
expect(runServeSimActionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
{ 'screenshot.capture': { properties: { automation: { type: 'boolean', default: false } } } },
|
||||
true,
|
||||
],
|
||||
[{ 'screenshot.capture': { properties: {} } }, false],
|
||||
[{ 'screenshot.capture': { properties: { automation: { type: 'string' } } } }, false],
|
||||
[{}, false],
|
||||
[null, false],
|
||||
])(
|
||||
'checks the catalog automation field before enabling captures (%j)',
|
||||
async (catalog, supported) => {
|
||||
jest.mocked(runServeSimActionWithResultAsync).mockResolvedValueOnce(JSON.stringify(catalog));
|
||||
const capture = createServeSimPreviewCapture(options);
|
||||
await expect(capture.isSupportedAsync(signal)).resolves.toBe(supported);
|
||||
expect(runServeSimActionWithResultAsync).toHaveBeenCalledWith({
|
||||
port: options.port,
|
||||
token: options.token,
|
||||
action: 'actions.describe',
|
||||
params: {},
|
||||
timeoutMs: 30_000,
|
||||
signal,
|
||||
});
|
||||
expect(runServeSimActionAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
@@ -141,18 +141,25 @@ export async function captureDeviceRunSessionPreviewAsync({
|
||||
device,
|
||||
env,
|
||||
signal,
|
||||
captureIosScreenshotAsync,
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
device: string;
|
||||
env: BuildStepEnv;
|
||||
signal: AbortSignal;
|
||||
captureIosScreenshotAsync?: (file: string, signal: AbortSignal) => Promise<void>;
|
||||
}): Promise<Buffer> {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), 'session-preview-'));
|
||||
try {
|
||||
const options = { env, signal, timeout: 30_000, stdio: 'pipe' as const };
|
||||
const preview = path.join(directory, 'preview.webp');
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await captureIosPreviewAsync({ device, directory, preview, options });
|
||||
if (!captureIosScreenshotAsync) {
|
||||
throw new Error(
|
||||
'Cannot capture the iOS session preview: serve-sim screenshot capture is not configured.'
|
||||
);
|
||||
}
|
||||
await captureIosPreviewAsync({ captureIosScreenshotAsync, directory, preview, options });
|
||||
} else {
|
||||
await captureAndroidPreviewAsync({ device, directory, preview, options });
|
||||
}
|
||||
@@ -170,19 +177,20 @@ type CaptureOptions = {
|
||||
};
|
||||
|
||||
async function captureIosPreviewAsync({
|
||||
device,
|
||||
captureIosScreenshotAsync,
|
||||
directory,
|
||||
preview,
|
||||
options,
|
||||
}: {
|
||||
device: string;
|
||||
captureIosScreenshotAsync: (file: string, signal: AbortSignal) => Promise<void>;
|
||||
directory: string;
|
||||
preview: string;
|
||||
options: CaptureOptions;
|
||||
}): Promise<void> {
|
||||
const screenshot = path.join(directory, 'screen.png');
|
||||
const resized = path.join(directory, 'resized.png');
|
||||
await spawn('xcrun', ['simctl', 'io', device, 'screenshot', screenshot], options);
|
||||
await captureIosScreenshotAsync(screenshot, options.signal);
|
||||
options.signal.throwIfAborted();
|
||||
await spawn('sips', ['-Z', String(PREVIEW_MAX_DIMENSION), screenshot, '--out', resized], options);
|
||||
await spawn('cwebp', ['-quiet', '-q', String(PREVIEW_QUALITY), resized, '-o', preview], options);
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
isPreinstalledExpoGoAsync,
|
||||
readIosApplicationIdentifierAsync,
|
||||
} from './iosAppArtifact';
|
||||
import { createServeSimPreviewCapture } from './serveSimPreviewCapture';
|
||||
import { type ServeSimActivityPolling, startServeSimActivityPolling } from './serveSimActivity';
|
||||
import { verifyLocalEgressGuardAsync } from './localEgressGuard';
|
||||
import { type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
@@ -605,6 +606,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
const previewRetirementErrors: unknown[] = [];
|
||||
let finishTask: Promise<void> | null = null;
|
||||
let hostReady = false;
|
||||
let iosPreviewCapture: ReturnType<typeof createServeSimPreviewCapture> | undefined;
|
||||
let sessionPreview: ReturnType<typeof startDeviceRunSessionPreview> | null = null;
|
||||
const previewSetupController = new AbortController();
|
||||
let previewSetupTask: Promise<void> | undefined;
|
||||
@@ -827,6 +829,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
await sessionPreview?.stopAsync();
|
||||
await previewActivity?.stopAsync();
|
||||
},
|
||||
cleanupPreviewCaptureAsync: async () => iosPreviewCapture?.cleanupAfterHostStoppedAsync(),
|
||||
previewServer,
|
||||
screenshots,
|
||||
serverName,
|
||||
@@ -992,6 +995,18 @@ async function startDeviceSessionHostInternalAsync(
|
||||
// readiness on macOS, so it starts after its encoder is installed there.
|
||||
previewSetupTask = (async () => {
|
||||
if (!isAndroid) {
|
||||
iosPreviewCapture = createServeSimPreviewCapture({
|
||||
port,
|
||||
token: previewToken!,
|
||||
device,
|
||||
tmpdir: serveSimTmpdir,
|
||||
});
|
||||
if (!(await iosPreviewCapture.isSupportedAsync(previewSetupController.signal))) {
|
||||
logger.warn(
|
||||
'This serve-sim version does not support automated screenshots; skipping session previews.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
await ensureMacosPreviewEncoderInstalledAsync({
|
||||
env,
|
||||
logger,
|
||||
@@ -1005,6 +1020,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
logger,
|
||||
captureAsync: signal =>
|
||||
captureDeviceRunSessionPreviewAsync({
|
||||
captureIosScreenshotAsync: iosPreviewCapture?.captureAsync,
|
||||
runtimePlatform,
|
||||
device: previewDevice ?? device,
|
||||
env,
|
||||
@@ -1080,6 +1096,7 @@ async function finishDeviceSessionHostAsync(
|
||||
{
|
||||
previewRetirement,
|
||||
stopSessionPreviewAsync,
|
||||
cleanupPreviewCaptureAsync,
|
||||
previewServer,
|
||||
screenshots,
|
||||
serverName,
|
||||
@@ -1091,6 +1108,7 @@ async function finishDeviceSessionHostAsync(
|
||||
}: {
|
||||
previewRetirement: Promise<boolean>;
|
||||
stopSessionPreviewAsync: () => Promise<void>;
|
||||
cleanupPreviewCaptureAsync: () => Promise<void>;
|
||||
previewServer: DetachedProcessHandle;
|
||||
screenshots: { finishAsync(hostStopped: boolean): Promise<void> };
|
||||
serverName: string;
|
||||
@@ -1124,6 +1142,13 @@ async function finishDeviceSessionHostAsync(
|
||||
} catch (err) {
|
||||
logger.warn({ err }, `Could not stop the ${serverName} session host.`);
|
||||
}
|
||||
if (hostStopped) {
|
||||
try {
|
||||
await cleanupPreviewCaptureAsync();
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Could not remove session preview capture files.');
|
||||
}
|
||||
}
|
||||
const previewRetired = await previewRetirement;
|
||||
onStopped(hostStopped && !hostExited && previewRetired);
|
||||
await screenshots.finishAsync(hostStopped);
|
||||
|
||||
@@ -30,13 +30,20 @@ export async function stageServeSimAppAsync(
|
||||
}
|
||||
}
|
||||
|
||||
export async function runServeSimActionAsync({
|
||||
export async function runServeSimActionAsync(
|
||||
options: Parameters<typeof runServeSimActionWithResultAsync>[0]
|
||||
): Promise<void> {
|
||||
await runServeSimActionWithResultAsync(options);
|
||||
}
|
||||
|
||||
export async function runServeSimActionWithResultAsync({
|
||||
port,
|
||||
token,
|
||||
action,
|
||||
params,
|
||||
timeoutMs,
|
||||
signal,
|
||||
drainAfterDispatch = false,
|
||||
}: {
|
||||
port: number;
|
||||
token: string;
|
||||
@@ -44,13 +51,15 @@ export async function runServeSimActionAsync({
|
||||
params: Record<string, unknown>;
|
||||
timeoutMs: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
/** Abort before dispatch; once sent, wait for its reply or deadline because exec-ws cannot cancel it. */
|
||||
drainAfterDispatch?: boolean;
|
||||
}): Promise<string> {
|
||||
signal?.throwIfAborted();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
const socket = new WebSocket(`ws://127.0.0.1:${port}/exec-ws`, [`serve-sim.token.${token}`]);
|
||||
let settled = false;
|
||||
let sent = false;
|
||||
const finish = (error?: unknown) => {
|
||||
const finish = (error?: unknown, stdout = '') => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
@@ -61,10 +70,14 @@ export async function runServeSimActionAsync({
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve();
|
||||
resolve(stdout);
|
||||
}
|
||||
};
|
||||
const aborted = () => {
|
||||
if (!sent || !drainAfterDispatch) {
|
||||
finish(signal?.reason ?? new Error('Serve-sim action aborted.'));
|
||||
}
|
||||
};
|
||||
const aborted = () => finish(signal?.reason ?? new Error('Serve-sim action aborted.'));
|
||||
const timer = setTimeout(
|
||||
() => finish(new SystemError(`serve-sim ${action} timed out.`)),
|
||||
timeoutMs
|
||||
@@ -75,6 +88,9 @@ export async function runServeSimActionAsync({
|
||||
finish(new SystemError(`serve-sim ${action} connection closed before completion.`))
|
||||
);
|
||||
socket.on('message', data => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
let reply;
|
||||
try {
|
||||
reply = JSON.parse(data.toString());
|
||||
@@ -90,7 +106,8 @@ export async function runServeSimActionAsync({
|
||||
? undefined
|
||||
: new SystemError(
|
||||
`serve-sim ${action} failed: ${(typeof reply.error === 'string' && reply.error) || (typeof reply.stderr === 'string' && reply.stderr) || 'invalid response'}`
|
||||
)
|
||||
),
|
||||
typeof reply.stdout === 'string' ? reply.stdout : ''
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { copyFile, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { runServeSimActionAsync, runServeSimActionWithResultAsync } from './serveSimActions';
|
||||
|
||||
export function createServeSimPreviewCapture({
|
||||
port,
|
||||
token,
|
||||
device,
|
||||
tmpdir,
|
||||
}: {
|
||||
port: number;
|
||||
token: string;
|
||||
device: string;
|
||||
tmpdir: string;
|
||||
}) {
|
||||
const ownedFiles = new Set<string>();
|
||||
return {
|
||||
async isSupportedAsync(signal: AbortSignal): Promise<boolean> {
|
||||
const result = await runServeSimActionWithResultAsync({
|
||||
port,
|
||||
token,
|
||||
action: 'actions.describe',
|
||||
params: {},
|
||||
timeoutMs: 30_000,
|
||||
signal,
|
||||
});
|
||||
const catalog: {
|
||||
'screenshot.capture'?: { properties?: { automation?: { type?: unknown } } };
|
||||
} | null = JSON.parse(result);
|
||||
return catalog?.['screenshot.capture']?.properties?.automation?.type === 'boolean';
|
||||
},
|
||||
async captureAsync(destination: string, signal: AbortSignal): Promise<void> {
|
||||
signal.throwIfAborted();
|
||||
const fileName = `serve-sim-screenshot-preview-${randomUUID()}.png`;
|
||||
const staged = path.join(tmpdir, 'serve-sim-screenshots', fileName);
|
||||
ownedFiles.add(staged);
|
||||
// Closing exec-ws does not cancel its host action. Drain a sent capture before honoring
|
||||
// cancellation; on timeout/disconnect, leave its file alone until the host has stopped.
|
||||
await runServeSimActionAsync({
|
||||
port,
|
||||
token,
|
||||
action: 'screenshot.capture',
|
||||
params: { udid: device, fileName, automation: true },
|
||||
timeoutMs: 30_000,
|
||||
signal,
|
||||
drainAfterDispatch: true,
|
||||
});
|
||||
try {
|
||||
signal.throwIfAborted();
|
||||
await copyFile(staged, destination);
|
||||
} finally {
|
||||
await rm(staged, { force: true });
|
||||
ownedFiles.delete(staged);
|
||||
}
|
||||
},
|
||||
/** Call only after the host process group has stopped, including timed-out actions. */
|
||||
async cleanupAfterHostStoppedAsync(): Promise<void> {
|
||||
const results = await Promise.allSettled(
|
||||
[...ownedFiles].map(async file => {
|
||||
await rm(file, { force: true });
|
||||
ownedFiles.delete(file);
|
||||
})
|
||||
);
|
||||
const errors = results
|
||||
.filter(result => result.status === 'rejected')
|
||||
.map(result => result.reason);
|
||||
if (errors.length) {
|
||||
throw new AggregateError(errors, 'Could not remove preview captures.');
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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