From 84e2fe82cf75be8d229b0ded2564c0caab23bed2 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Wed, 29 Apr 2026 07:54:44 -0700 Subject: [PATCH] feat(cli): structured JSON output for read-only commands In --json mode, the read-only commands now emit structured payloads instead of the formatted text: - cookie-list, cookie-get: cookie object(s) or null - localstorage-list/get, sessionstorage-list/get: items or value - route-list: array of route entries - request-headers, response-headers: HeadersArray ({name, value}[]) preserving original case and duplicate header names - request-body, response-body: body string (or null) - request: structured RequestDetails (method, url, status, headers, ...) When --filename is set, the JSON result is { file: relativePath }, mirroring the snapshot pattern. The text rendering for non-JSON modes is preserved. --- .../src/tools/backend/cookies.ts | 22 +-- .../src/tools/backend/network.ts | 125 +++++++++++++----- .../src/tools/backend/response.ts | 12 ++ .../src/tools/backend/route.ts | 4 + .../src/tools/backend/webstorage.ts | 44 +++--- tests/mcp/cli-devtools.spec.ts | 12 +- tests/mcp/cli-json.spec.ts | 112 +++++++++++++++- 7 files changed, 263 insertions(+), 68 deletions(-) diff --git a/packages/playwright-core/src/tools/backend/cookies.ts b/packages/playwright-core/src/tools/backend/cookies.ts index 1c52428d79ed0..cd9053431845d 100644 --- a/packages/playwright-core/src/tools/backend/cookies.ts +++ b/packages/playwright-core/src/tools/backend/cookies.ts @@ -40,10 +40,13 @@ const cookieList = defineTool({ if (params.path) cookies = cookies.filter(c => c.path.startsWith(params.path!)); - if (cookies.length === 0) - response.addTextResult('No cookies found'); - else - response.addTextResult(cookies.map(c => `${c.name}=${c.value} (domain: ${c.domain}, path: ${c.path})`).join('\n')); + if (response.json) { + response.setResultJSON(cookies); + return; + } + response.addTextResult(cookies.length === 0 + ? 'No cookies found' + : cookies.map(c => `${c.name}=${c.value} (domain: ${c.domain}, path: ${c.path})`).join('\n')); response.addCode(`await page.context().cookies();`); }, }); @@ -66,10 +69,13 @@ const cookieGet = defineTool({ const cookies = await browserContext.cookies(); const cookie = cookies.find(c => c.name === params.name); - if (!cookie) - response.addTextResult(`Cookie '${params.name}' not found`); - else - response.addTextResult(`${cookie.name}=${cookie.value} (domain: ${cookie.domain}, path: ${cookie.path}, httpOnly: ${cookie.httpOnly}, secure: ${cookie.secure}, sameSite: ${cookie.sameSite})`); + if (response.json) { + response.setResultJSON(cookie ?? null); + return; + } + response.addTextResult(cookie + ? `${cookie.name}=${cookie.value} (domain: ${cookie.domain}, path: ${cookie.path}, httpOnly: ${cookie.httpOnly}, secure: ${cookie.secure}, sameSite: ${cookie.sameSite})` + : `Cookie '${params.name}' not found`); response.addCode(`await page.context().cookies();`); }, }); diff --git a/packages/playwright-core/src/tools/backend/network.ts b/packages/playwright-core/src/tools/backend/network.ts index ce7cd8e959f02..aac07b25af87b 100644 --- a/packages/playwright-core/src/tools/backend/network.ts +++ b/packages/playwright-core/src/tools/backend/network.ts @@ -23,6 +23,7 @@ import { getExtensionForMimeType, isTextualMimeType } from '@isomorphic/mimeType import { defineTool, defineTabTool } from './tool'; import type { Response as ToolResponse } from './response'; +import type { HeadersArray } from '@isomorphic/types'; import type * as playwright from '../../..'; const requests = defineTabTool({ @@ -95,7 +96,12 @@ const request = defineTabTool({ await renderRequestPart(request, params.part, response, params.filename); return; } - await response.addResult('Request', renderRequestDetails(params.index, request, !!tab.context.config.skillMode), { prefix: 'request', ext: 'log', suggestedFilename: params.filename }); + const details = await buildRequestDetails(params.index, request); + if (response.json && !params.filename) { + response.setResultJSON(details); + return; + } + await response.addResult('Request', renderRequestDetailsText(details, !!tab.context.config.skillMode), { prefix: 'request', ext: 'log', suggestedFilename: params.filename }); }, }); @@ -135,36 +141,72 @@ export function renderRequestLine(request: playwright.Request): string { return line; } -function renderRequestDetails(index: number, request: playwright.Request, skillMode: boolean): string { +type RequestDetails = { + index: number; + method: string; + url: string; + resourceType: string; + duration?: number; + mimeType?: string; + status?: number; + statusText?: string; + failure?: string; + requestHeaders: HeadersArray; + responseHeaders?: HeadersArray; + hasRequestBody: boolean; + hasResponseBody: boolean; +}; + +async function buildRequestDetails(index: number, request: playwright.Request): Promise { const httpResponse = request.existingResponse(); - const responseHeaders = httpResponse?.headers(); + const [requestHeaders, responseHeaders] = await Promise.all([ + request.headersArray(), + httpResponse?.headersArray(), + ]); + const contentType = responseHeaders?.find(h => h.name.toLowerCase() === 'content-type')?.value; + return { + index, + method: request.method().toUpperCase(), + url: request.url(), + resourceType: request.resourceType(), + duration: computeDurationMs(request), + mimeType: contentType ? contentType.split(';')[0].trim() : undefined, + status: httpResponse?.status(), + statusText: httpResponse?.statusText(), + failure: request.failure()?.errorText, + requestHeaders, + responseHeaders, + hasRequestBody: request.postData() !== null, + hasResponseBody: canHaveResponseBody(httpResponse), + }; +} + +function renderRequestDetailsText(details: RequestDetails, skillMode: boolean): string { const lines: string[] = []; - lines.push(`#${index} [${request.method().toUpperCase()}] ${request.url()}`); + lines.push(`#${details.index} [${details.method}] ${details.url}`); lines.push(''); lines.push(' General'); - if (httpResponse) - lines.push(` status: [${httpResponse.status()}] ${httpResponse.statusText()}`); - else if (request.failure()) - lines.push(` status: [FAILED] ${request.failure()?.errorText ?? 'Unknown error'}`); - const duration = computeDurationMs(request); - if (duration !== undefined) - lines.push(` duration: ${duration}ms`); - lines.push(` type: ${request.resourceType()}`); - const contentType = responseHeaders?.['content-type']; - if (contentType) - lines.push(` mimeType: ${contentType.split(';')[0].trim()}`); + if (details.status !== undefined) + lines.push(` status: [${details.status}] ${details.statusText}`); + else if (details.failure) + lines.push(` status: [FAILED] ${details.failure}`); + if (details.duration !== undefined) + lines.push(` duration: ${details.duration}ms`); + lines.push(` type: ${details.resourceType}`); + if (details.mimeType) + lines.push(` mimeType: ${details.mimeType}`); - appendHeaderSection(lines, 'Request headers', request.headers()); + appendHeaderSection(lines, 'Request headers', details.requestHeaders); - if (responseHeaders) - appendHeaderSection(lines, 'Response headers', responseHeaders); + if (details.responseHeaders) + appendHeaderSection(lines, 'Response headers', details.responseHeaders); const hints: string[] = []; - if (request.postData()) - hints.push(partHint(skillMode, 'request-body', index)); - if (canHaveResponseBody(httpResponse)) - hints.push(partHint(skillMode, 'response-body', index)); + if (details.hasRequestBody) + hints.push(partHint(skillMode, 'request-body', details.index)); + if (details.hasResponseBody) + hints.push(partHint(skillMode, 'response-body', details.index)); if (hints.length) lines.push('', ...hints); @@ -186,14 +228,13 @@ function canHaveResponseBody(httpResponse: playwright.Response | null): httpResp return status !== 204 && status !== 304 && !(status >= 100 && status < 200); } -function appendHeaderSection(lines: string[], title: string, headers: Record): void { - const entries = Object.entries(headers); - if (!entries.length) +function appendHeaderSection(lines: string[], title: string, headers: HeadersArray): void { + if (!headers.length) return; lines.push(''); lines.push(` ${title}`); - for (const [k, v] of entries) - lines.push(` ${k}: ${v}`); + for (const { name, value } of headers) + lines.push(` ${name}: ${value}`); } function computeDurationMs(request: playwright.Request): number | undefined { @@ -205,11 +246,20 @@ function computeDurationMs(request: playwright.Request): number | undefined { async function renderRequestPart(request: playwright.Request, part: RequestPart, response: ToolResponse, suggestedFilename: string | undefined): Promise { if (part === 'request-headers') { - await response.addResult('Request headers', renderHeaders(request.headers()), { prefix: 'request', ext: 'txt', suggestedFilename }); + const headers = await request.headersArray(); + if (response.json && !suggestedFilename) { + response.setResultJSON(headers); + return; + } + await response.addResult('Request headers', renderHeaders(headers), { prefix: 'request', ext: 'txt', suggestedFilename }); return; } if (part === 'request-body') { const data = request.postData(); + if (response.json && !suggestedFilename) { + response.setResultJSON(data); + return; + } if (data !== null) await response.addResult('Request body', data, { prefix: 'request', ext: 'txt', suggestedFilename }); return; @@ -218,7 +268,12 @@ async function renderRequestPart(request: playwright.Request, part: RequestPart, if (!httpResponse) return; if (part === 'response-headers') { - await response.addResult('Response headers', renderHeaders(httpResponse.headers()), { prefix: 'response', ext: 'txt', suggestedFilename }); + const headers = await httpResponse.headersArray(); + if (response.json && !suggestedFilename) { + response.setResultJSON(headers); + return; + } + await response.addResult('Response headers', renderHeaders(headers), { prefix: 'response', ext: 'txt', suggestedFilename }); return; } // response-body @@ -230,16 +285,22 @@ async function renderRequestPart(request: playwright.Request, part: RequestPart, } catch { return; } + if (response.json && !suggestedFilename) { + response.setResultJSON(text); + return; + } await response.addResult('Response body', text, { prefix: 'response', ext: 'txt', suggestedFilename }); return; } const path = await saveResponseBody(request, response, suggestedFilename); - if (path !== undefined) + if (path !== undefined) { + response.setResultJSON({ file: path }); response.addTextResult(path); + } } -function renderHeaders(headers: Record): string { - return Object.entries(headers).map(([k, v]) => `${k}: ${v}`).join('\n'); +function renderHeaders(headers: HeadersArray): string { + return headers.map(({ name, value }) => `${name}: ${value}`).join('\n'); } async function saveResponseBody(request: playwright.Request, response: ToolResponse, suggestedFilename?: string): Promise { diff --git a/packages/playwright-core/src/tools/backend/response.ts b/packages/playwright-core/src/tools/backend/response.ts index eda678a8468e2..75f7621be88b1 100644 --- a/packages/playwright-core/src/tools/backend/response.ts +++ b/packages/playwright-core/src/tools/backend/response.ts @@ -59,6 +59,7 @@ export class Response { private _imageResults: { data: Buffer, imageType: 'png' | 'jpeg' }[] = []; private _raw: boolean; private _json: boolean; + private _resultJSON: { value: unknown } | undefined; constructor(context: Context, toolName: string, toolArgs: Record, options?: { relativeTo?: string, raw?: boolean, json?: boolean }) { this._context = context; @@ -69,6 +70,10 @@ export class Response { this._raw = this._json || (options?.raw ?? false); } + get json(): boolean { + return this._json; + } + private _computeRelativeTo(fileName: string): string { const rel = path.relative(this._clientWorkspace, fileName); // Prefix bare filenames with `./` so they're not mistaken for living in @@ -97,10 +102,15 @@ export class Response { this._results.push(text); } + setResultJSON(value: unknown) { + this._resultJSON = { value }; + } + async addResult(title: string, data: Buffer | string, file: FilenameTemplate) { if (file.suggestedFilename || typeof data !== 'string') { const resolvedFile = await this.resolveClientFile(file, title); await this.addFileResult(resolvedFile, data); + this.setResultJSON({ file: resolvedFile.relativeName }); } else { this.addTextResult(data); } @@ -183,6 +193,8 @@ export class Response { payload[key] = section.content.join('\n'); } } + if (this._resultJSON !== undefined) + payload.result = this._resultJSON.value; serializedText = JSON.stringify(payload, null, 2); } else { const text: string[] = []; diff --git a/packages/playwright-core/src/tools/backend/route.ts b/packages/playwright-core/src/tools/backend/route.ts index 0080b4a9ec762..8b8d1b10793ab 100644 --- a/packages/playwright-core/src/tools/backend/route.ts +++ b/packages/playwright-core/src/tools/backend/route.ts @@ -98,6 +98,10 @@ const routeList = defineTool({ handle: async (context, params, response) => { const routes = context.routes(); + if (response.json) { + response.setResultJSON(routes.map(({ handler, ...rest }) => rest)); + return; + } if (routes.length === 0) { response.addTextResult('No active routes'); return; diff --git a/packages/playwright-core/src/tools/backend/webstorage.ts b/packages/playwright-core/src/tools/backend/webstorage.ts index 956522811a51e..c8c710b6fc652 100644 --- a/packages/playwright-core/src/tools/backend/webstorage.ts +++ b/packages/playwright-core/src/tools/backend/webstorage.ts @@ -39,10 +39,13 @@ const localStorageList = defineTabTool({ return result; }); - if (items.length === 0) - response.addTextResult('No localStorage items found'); - else - response.addTextResult(items.map(item => `${item.key}=${item.value}`).join('\n')); + if (response.json) { + response.setResultJSON(items); + return; + } + response.addTextResult(items.length === 0 + ? 'No localStorage items found' + : items.map(item => `${item.key}=${item.value}`).join('\n')); response.addCode(`await page.evaluate(() => ({ ...localStorage }));`); }, }); @@ -63,10 +66,13 @@ const localStorageGet = defineTabTool({ handle: async (tab, params, response) => { const value = await tab.page.evaluate(key => localStorage.getItem(key), params.key); - if (value === null) - response.addTextResult(`localStorage key '${params.key}' not found`); - else - response.addTextResult(`${params.key}=${value}`); + if (response.json) { + response.setResultJSON(value); + return; + } + response.addTextResult(value === null + ? `localStorage key '${params.key}' not found` + : `${params.key}=${value}`); response.addCode(`await page.evaluate(() => localStorage.getItem('${params.key}'));`); }, }); @@ -151,10 +157,13 @@ const sessionStorageList = defineTabTool({ return result; }); - if (items.length === 0) - response.addTextResult('No sessionStorage items found'); - else - response.addTextResult(items.map(item => `${item.key}=${item.value}`).join('\n')); + if (response.json) { + response.setResultJSON(items); + return; + } + response.addTextResult(items.length === 0 + ? 'No sessionStorage items found' + : items.map(item => `${item.key}=${item.value}`).join('\n')); response.addCode(`await page.evaluate(() => ({ ...sessionStorage }));`); }, }); @@ -175,10 +184,13 @@ const sessionStorageGet = defineTabTool({ handle: async (tab, params, response) => { const value = await tab.page.evaluate(key => sessionStorage.getItem(key), params.key); - if (value === null) - response.addTextResult(`sessionStorage key '${params.key}' not found`); - else - response.addTextResult(`${params.key}=${value}`); + if (response.json) { + response.setResultJSON(value); + return; + } + response.addTextResult(value === null + ? `sessionStorage key '${params.key}' not found` + : `${params.key}=${value}`); response.addCode(`await page.evaluate(() => sessionStorage.getItem('${params.key}'));`); }, }); diff --git a/tests/mcp/cli-devtools.spec.ts b/tests/mcp/cli-devtools.spec.ts index 7dabb7e0545cc..3d7ae37a586fb 100644 --- a/tests/mcp/cli-devtools.spec.ts +++ b/tests/mcp/cli-devtools.spec.ts @@ -102,9 +102,9 @@ test('request shows full request and response details', async ({ cli, server }) expect(output).toContain('General'); expect(output).toContain('status: [200] OK'); expect(output).toContain('Request headers'); - expect(output).toContain('x-custom-header: test-value'); + expect(output).toMatch(/x-custom-header: test-value/i); expect(output).toContain('Response headers'); - expect(output).toContain('x-custom-response: response-value'); + expect(output).toMatch(/x-custom-response: response-value/i); expect(output).toContain(`Run \`request-body ${match![1]}\` to read the request body.`); expect(output).toContain(`Run \`response-body ${match![1]}\` to read the response body.`); expect(output).not.toContain('Request body'); @@ -129,9 +129,9 @@ test('per-part commands extract individual parts', async ({ cli, server }) => { expect(match).not.toBeNull(); const num = match![1]; - expect((await cli('request-headers', num)).output).toContain('x-custom-header: test-value'); + expect((await cli('request-headers', num)).output).toMatch(/x-custom-header: test-value/i); expect((await cli('request-body', num)).output).toContain('{"key":"value"}'); - expect((await cli('response-headers', num)).output).toContain('x-custom-response: response-value'); + expect((await cli('response-headers', num)).output).toMatch(/x-custom-response: response-value/i); expect((await cli('response-body', num)).output).toContain('{"name":"John Doe"}'); }); @@ -158,13 +158,13 @@ test('request* and response* commands support --filename', async ({ cli, server expect(read('req.log')).toContain(`[POST] ${server.PREFIX}/api`); expect((await cli('request-headers', num, '--filename=req-h.txt')).output).toContain('[Request headers](./req-h.txt)'); - expect(read('req-h.txt')).toContain('content-type: text/plain;charset=UTF-8'); + expect(read('req-h.txt')).toMatch(/content-type: text\/plain;charset=UTF-8/i); expect((await cli('request-body', num, '--filename=req-b.txt')).output).toContain('[Request body](./req-b.txt)'); expect(read('req-b.txt')).toBe('hello'); expect((await cli('response-headers', num, '--filename=res-h.txt')).output).toContain('[Response headers](./res-h.txt)'); - expect(read('res-h.txt')).toContain('x-custom-response: response-value'); + expect(read('res-h.txt')).toMatch(/x-custom-response: response-value/i); expect((await cli('response-body', num, '--filename=res-b.json')).output).toContain('[Response body](./res-b.json)'); expect(read('res-b.json')).toBe('{"name":"John Doe"}'); diff --git a/tests/mcp/cli-json.spec.ts b/tests/mcp/cli-json.spec.ts index b607db3d8f52a..2d5d0f992afc4 100644 --- a/tests/mcp/cli-json.spec.ts +++ b/tests/mcp/cli-json.spec.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import fs from 'fs'; + import { test, expect } from './cli-fixtures'; const SNAPSHOT_FILE = expect.stringMatching(/^\.playwright-cli[\\/]page-[\dTZ:.-]+\.yml$/); @@ -204,16 +206,25 @@ test('request and per-part commands return JSON result', async ({ cli, server }) const { output: list } = await cli('requests'); const num = list.match(/^(\d+)\. \[POST\] [^ ]+\/api =>/m)![1]; - console.error(list); - - expect(JSON.parse((await cli('--json', 'request-headers', num)).output).result).toContain('x-custom-header: test-value'); + expect(JSON.parse((await cli('--json', 'request-headers', num)).output).result).toContainEqual({ name: expect.stringMatching(/^x-custom-header$/i), value: 'test-value' }); expect(JSON.parse((await cli('--json', 'request-body', num)).output)).toEqual({ result: '{"key":"value"}' }); - expect(JSON.parse((await cli('--json', 'response-headers', num)).output).result).toContain('x-custom-response: response-value'); + expect(JSON.parse((await cli('--json', 'response-headers', num)).output).result).toContainEqual({ name: expect.stringMatching(/^x-custom-response$/i), value: 'response-value' }); expect(JSON.parse((await cli('--json', 'response-body', num)).output)).toEqual({ result: '{"name":"John Doe"}' }); const detail = JSON.parse((await cli('--json', 'request', num)).output); - expect(detail.result).toContain(`#${num} [POST] ${server.PREFIX}/api`); - expect(detail.result).toContain(`Run \`response-body ${num}\` to read the response body.`); + expect(detail.result).toMatchObject({ + index: Number(num), + method: 'POST', + url: `${server.PREFIX}/api`, + resourceType: 'fetch', + status: 200, + statusText: 'OK', + mimeType: 'application/json', + hasRequestBody: true, + hasResponseBody: true, + }); + expect(detail.result.requestHeaders).toContainEqual({ name: expect.stringMatching(/^x-custom-header$/i), value: 'test-value' }); + expect(detail.result.responseHeaders).toContainEqual({ name: expect.stringMatching(/^x-custom-response$/i), value: 'response-value' }); }); test('request with out-of-range index returns JSON error', async ({ cli, server }) => { @@ -223,3 +234,92 @@ test('request with out-of-range index returns JSON error', async ({ cli, server expect(parsed.isError).toBe(true); expect(parsed.error).toContain('Request #999 not found'); }); + +test('cookie-list returns array of cookies', async ({ cli, server }, testInfo) => { + const config = { capabilities: ['storage'] }; + await fs.promises.writeFile(testInfo.outputPath('.playwright', 'cli.config.json'), JSON.stringify(config, null, 2)); + await cli('open', server.EMPTY_PAGE); + await cli('cookie-set', 'k1', 'v1'); + await cli('cookie-set', 'k2', 'v2'); + + const { output } = await cli('--json', 'cookie-list'); + const parsed = JSON.parse(output); + expect(parsed.result).toHaveLength(2); + expect(parsed.result).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'k1', value: 'v1', path: '/' }), + expect.objectContaining({ name: 'k2', value: 'v2', path: '/' }), + ])); +}); + +test('cookie-get returns cookie object or null', async ({ cli, server }, testInfo) => { + const config = { capabilities: ['storage'] }; + await fs.promises.writeFile(testInfo.outputPath('.playwright', 'cli.config.json'), JSON.stringify(config, null, 2)); + await cli('open', server.EMPTY_PAGE); + await cli('cookie-set', 'k1', 'v1'); + + const found = JSON.parse((await cli('--json', 'cookie-get', 'k1')).output); + expect(found.result).toMatchObject({ name: 'k1', value: 'v1', path: '/' }); + + const missing = JSON.parse((await cli('--json', 'cookie-get', 'nope')).output); + expect(missing).toEqual({ result: null }); +}); + +test('localstorage-list and localstorage-get return structured JSON', async ({ cli, server }, testInfo) => { + const config = { capabilities: ['storage'] }; + await fs.promises.writeFile(testInfo.outputPath('.playwright', 'cli.config.json'), JSON.stringify(config, null, 2)); + await cli('open', server.EMPTY_PAGE); + await cli('localstorage-set', 'k1', 'v1'); + await cli('localstorage-set', 'k2', 'v2'); + + const list = JSON.parse((await cli('--json', 'localstorage-list')).output); + expect(list.result).toEqual(expect.arrayContaining([ + { key: 'k1', value: 'v1' }, + { key: 'k2', value: 'v2' }, + ])); + + expect(JSON.parse((await cli('--json', 'localstorage-get', 'k1')).output)).toEqual({ result: 'v1' }); + expect(JSON.parse((await cli('--json', 'localstorage-get', 'nope')).output)).toEqual({ result: null }); +}); + +test('sessionstorage-list and sessionstorage-get return structured JSON', async ({ cli, server }, testInfo) => { + const config = { capabilities: ['storage'] }; + await fs.promises.writeFile(testInfo.outputPath('.playwright', 'cli.config.json'), JSON.stringify(config, null, 2)); + await cli('open', server.EMPTY_PAGE); + await cli('sessionstorage-set', 'k1', 'v1'); + + const list = JSON.parse((await cli('--json', 'sessionstorage-list')).output); + expect(list.result).toEqual([{ key: 'k1', value: 'v1' }]); + + expect(JSON.parse((await cli('--json', 'sessionstorage-get', 'k1')).output)).toEqual({ result: 'v1' }); + expect(JSON.parse((await cli('--json', 'sessionstorage-get', 'nope')).output)).toEqual({ result: null }); +}); + +test('route-list returns array of route entries', async ({ cli, server }) => { + await cli('open', server.PREFIX); + await cli('route', '**/api/users', '--status=200', '--body={"ok":true}', '--content-type=application/json'); + + const parsed = JSON.parse((await cli('--json', 'route-list')).output); + expect(parsed.result).toEqual([ + expect.objectContaining({ + pattern: '**/api/users', + status: 200, + body: '{"ok":true}', + contentType: 'application/json', + }), + ]); +}); + +test('request-headers --filename returns file reference in JSON', async ({ cli, server }) => { + server.setContent('/', ` + + `, 'text/html'); + server.setContent('/api', '{}', 'application/json'); + await cli('open', server.PREFIX); + await cli('click', 'e2'); + + const { output: list } = await cli('requests'); + const num = list.match(/^(\d+)\. \[POST\] [^ ]+\/api =>/m)![1]; + + const parsed = JSON.parse((await cli('--json', 'request-headers', num, '--filename=req-h.txt')).output); + expect(parsed.result).toEqual({ file: './req-h.txt' }); +});