Compare commits

...
4 Commits
Author SHA1 Message Date
Expo CI 5d31ecc2bc v24.10.0 2026-10-02 20:53:41 +00:00
Gabe DebesandClaude Opus 5.5 890286a08d [eas-cli] add --network-capture to eas simulator (#4308)
* [eas-cli] add network capture flags to eas simulator

* [eas-cli] regenerate GraphQL for network capture inputs

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* [eas-cli] move the network capture changelog entry to main

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-02 13:31:43 -07:00
Expo CI 33ff59a75e bump STAGING and PRODUCTION to 24.9.0 in cli-versions.json 2026-10-02 17:41:52 +00:00
Expo CI c2bbba6e78 update CHANGELOG.md for v24.9.0 2026-10-02 17:35:45 +00:00
11 changed files with 704 additions and 154 deletions

No files matched your search

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