Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/src/getting-started-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,8 @@ playwright-cli tab-close [index] # close a tab
### Network

```bash
playwright-cli network # list network requests since page load
playwright-cli requests # list network requests since page load
playwright-cli request <num> # show full details of a single request
playwright-cli route <pattern> [opts] # mock network requests
playwright-cli route-list # list active routes
playwright-cli unroute [pattern] # remove routes
Expand Down
15 changes: 15 additions & 0 deletions packages/isomorphic/mimeType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,21 @@ export function getMimeTypeForPath(path: string): string | null {
return types.get(extension) || null;
}

export function getExtensionForMimeType(contentType: string | undefined): string {
const subtype = (contentType ?? '').split(';')[0].split('/')[1]?.trim().toLowerCase() ?? '';
if (!subtype)
return 'bin';
// image/svg+xml → xml, application/ld+json → json
const tail = subtype.includes('+') ? subtype.split('+').pop()! : subtype;
if (tail === 'plain')
return 'txt';
if (tail === 'javascript' || tail === 'ecmascript')
return 'js';
if (tail === 'jpeg')
return 'jpg';
return tail.replace(/[^a-z0-9]/g, '') || 'bin';
}

const types: Map<string, string> = new Map([
['ez', 'application/andrew-inset'],
['aw', 'application/applixware'],
Expand Down
182 changes: 158 additions & 24 deletions packages/playwright-core/src/tools/backend/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,15 @@
* limitations under the License.
*/

import fs from 'fs';

import * as z from 'zod';

import { getExtensionForMimeType, isTextualMimeType } from '@isomorphic/mimeType';

import { defineTool, defineTabTool } from './tool';

import type { Response as ToolResponse } from './response';
import type * as playwright from '../../..';

const requests = defineTabTool({
Expand All @@ -25,32 +31,66 @@ const requests = defineTabTool({
schema: {
name: 'browser_network_requests',
title: 'List network requests',
description: 'Returns all network requests since loading the page',
description: 'Returns a numbered list of network requests since loading the page. Use browser_network_request with the number to get full details.',
inputSchema: z.object({
static: z.boolean().default(false).describe('Whether to include successful static resources like images, fonts, scripts, etc. Defaults to false.'),
requestBody: z.boolean().default(false).describe('Whether to include request body. Defaults to false.'),
requestHeaders: z.boolean().default(false).describe('Whether to include request headers. Defaults to false.'),
filter: z.string().optional().describe('Only return requests whose URL matches this regexp (e.g. "/api/.*user").'),
filename: z.string().optional().describe('Filename to save the network requests to. If not provided, requests are returned as text.'),
}),
type: 'readOnly',
},

handle: async (tab, params, response) => {
const requests = await tab.requests();
const allRequests = await tab.requests();
const filter = params.filter ? new RegExp(params.filter) : undefined;
const text: string[] = [];
for (const request of requests) {
const lines: string[] = [];
for (let i = 0; i < allRequests.length; i++) {
const request = allRequests[i];
if (!params.static && !isFetch(request) && isSuccessfulResponse(request))
continue;
if (filter) {
filter.lastIndex = 0;
if (!filter.test(request.url()))
continue;
}
text.push(await renderRequest(request, params.requestBody, params.requestHeaders));
lines.push(`${i + 1}. ${renderRequestLine(request)}`);
}
await response.addResult('Network', text.join('\n'), { prefix: 'network', ext: 'log', suggestedFilename: params.filename });
await response.addResult('Network', lines.join('\n'), { prefix: 'network', ext: 'log', suggestedFilename: params.filename });
},
});

const REQUEST_PARTS = ['request-headers', 'request-body', 'response-headers', 'response-body'] as const;
type RequestPart = typeof REQUEST_PARTS[number];

const request = defineTabTool({
capability: 'core',

schema: {
name: 'browser_network_request',
title: 'Show network request details',
description: 'Returns full details (headers and body) of a single network request, or a single part if `part` is set. Use the number from browser_network_requests.',
inputSchema: z.object({
index: z.number().int().min(1).describe('1-based index of the request, as printed by browser_network_requests.'),
part: z.enum(REQUEST_PARTS).optional().describe('Return only this part of the request. Omit to return full details.'),
}),
type: 'readOnly',
},

handle: async (tab, params, response) => {
const allRequests = await tab.requests();
const request = allRequests[params.index - 1];
if (!request) {
response.addError(`Request #${params.index} not found. Use browser_network_requests to see available indexes.`);
return;
}
if (params.part) {
const partText = await renderRequestPart(request, params.part, response);
if (partText !== undefined)
response.addTextResult(partText);
return;
}
const bodyPath = await saveResponseBody(request, response);
response.addTextResult(renderRequestDetails(params.index, request, bodyPath));
},
});

Expand Down Expand Up @@ -80,27 +120,120 @@ export function isFetch(request: playwright.Request): boolean {
return ['fetch', 'xhr'].includes(request.resourceType());
}

export async function renderRequest(request: playwright.Request, includeBody = false, includeHeaders = false): Promise<string> {
export function renderRequestLine(request: playwright.Request): string {
const response = request.existingResponse();

const result: string[] = [];
result.push(`[${request.method().toUpperCase()}] ${request.url()}`);
let line = `[${request.method().toUpperCase()}] ${request.url()}`;
if (response)
result.push(` => [${response.status()}] ${response.statusText()}`);
line += ` => [${response.status()}] ${response.statusText()}`;
else if (request.failure())
result.push(` => [FAILED] ${request.failure()?.errorText ?? 'Unknown error'}`);
if (includeHeaders) {
const headers = request.headers();
const headerLines = Object.entries(headers).map(([k, v]) => ` ${k}: ${v}`).join('\n');
if (headerLines)
result.push(`\n Request headers:\n${headerLines}`);
line += ` => [FAILED] ${request.failure()?.errorText ?? 'Unknown error'}`;
return line;
}

function renderRequestDetails(index: number, request: playwright.Request, responseBodyPath: string | undefined): string {
const httpResponse = request.existingResponse();
const responseHeaders = httpResponse?.headers();
const lines: string[] = [];
lines.push(`#${index} [${request.method().toUpperCase()}] ${request.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()}`);

appendHeaderSection(lines, 'Request headers', request.headers());

const postData = request.postData();
if (postData) {
lines.push('');
lines.push(' Request body');
lines.push(` ${postData}`);
}

if (responseHeaders)
appendHeaderSection(lines, 'Response headers', responseHeaders);

if (responseBodyPath) {
lines.push('');
lines.push(' Response body');
lines.push(` ${responseBodyPath}`);
}

return lines.join('\n');
}

function appendHeaderSection(lines: string[], title: string, headers: Record<string, string>): void {
const entries = Object.entries(headers);
if (!entries.length)
return;
lines.push('');
lines.push(` ${title}`);
for (const [k, v] of entries)
lines.push(` ${k}: ${v}`);
}

function computeDurationMs(request: playwright.Request): number | undefined {
const timing = request.timing();
if (!timing || timing.responseEnd < 0)
return undefined;
return Math.round(timing.responseEnd);
}

async function renderRequestPart(request: playwright.Request, part: RequestPart, response: ToolResponse): Promise<string | undefined> {
if (part === 'request-headers')
return renderHeaders(request.headers());
if (part === 'request-body')
return request.postData() ?? undefined;
const httpResponse = request.existingResponse();
if (!httpResponse)
return undefined;
if (part === 'response-headers')
return renderHeaders(httpResponse.headers());
// response-body
const contentType = httpResponse.headers()['content-type'];
if (isTextualMimeType(contentType ?? '')) {
try {
return await httpResponse.text();
} catch {
return undefined;
}
}
if (includeBody) {
const postData = request.postData();
if (postData)
result.push(`\n Request body: ${postData}`);
return await saveResponseBody(request, response);
}

function renderHeaders(headers: Record<string, string>): string {
return Object.entries(headers).map(([k, v]) => `${k}: ${v}`).join('\n');
}

async function saveResponseBody(request: playwright.Request, response: ToolResponse): Promise<string | undefined> {
const httpResponse = request.existingResponse();
if (!httpResponse)
return undefined;
const status = httpResponse.status();
// Status codes that cannot have a response body per RFC 7230.
if (status === 204 || status === 304 || (status >= 100 && status < 200))
return undefined;
let body: Buffer;
try {
body = await httpResponse.body();
} catch {
return undefined;
}
return result.join('');
if (!body.length)
return undefined;
const ext = getExtensionForMimeType(httpResponse.headers()['content-type']);
const resolved = await response.resolveClientFile({ prefix: 'response', ext }, 'Response body');
await fs.promises.writeFile(resolved.fileName, body);
return resolved.relativeName;
}

const networkStateSet = defineTool({
Expand All @@ -127,6 +260,7 @@ const networkStateSet = defineTool({

export default [
requests,
request,
networkClear,
networkStateSet,
];
5 changes: 3 additions & 2 deletions packages/playwright-core/src/tools/cli-client/skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,8 @@ playwright-cli unroute
```bash
playwright-cli console
playwright-cli console warning
playwright-cli network
playwright-cli requests
playwright-cli request 5
playwright-cli run-code "async page => await page.context().grantPermissions(['geolocation'])"
playwright-cli run-code --filename=script.js
playwright-cli tracing-start
Expand Down Expand Up @@ -351,7 +352,7 @@ playwright-cli open https://example.com
playwright-cli click e4
playwright-cli fill e7 "test"
playwright-cli console
playwright-cli network
playwright-cli requests
playwright-cli close
```

Expand Down
72 changes: 65 additions & 7 deletions packages/playwright-core/src/tools/cli-daemon/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -815,19 +815,72 @@ const consoleList = declareCommand({
});

const networkRequests = declareCommand({
name: 'network',
description: 'List all network requests since loading the page',
category: 'devtools',
name: 'requests',
description: 'List all network requests since loading the page. Each request is numbered for use with the `request` command.',
category: 'network',
args: z.object({}),
options: z.object({
static: z.boolean().optional().describe('Whether to include successful static resources like images, fonts, scripts, etc. Defaults to false.'),
['request-body']: z.boolean().optional().describe('Whether to include request body. Defaults to false.'),
['request-headers']: z.boolean().optional().describe('Whether to include request headers. Defaults to false.'),
filter: z.string().optional().describe('Only return requests whose URL matches this regexp (e.g. "/api/.*user").'),
clear: z.boolean().optional().describe('Whether to clear the network list'),
}),
toolName: ({ clear }) => clear ? 'browser_network_clear' : 'browser_network_requests',
toolParams: ({ static: s, 'request-body': requestBody, 'request-headers': requestHeaders, filter, clear }) => clear ? ({}) : ({ static: s, requestBody, requestHeaders, filter }),
toolParams: ({ static: s, filter, clear }) => clear ? ({}) : ({ static: s, filter }),
});

const networkRequest = declareCommand({
name: 'request',
description: 'Show full details (headers, body, response) of a single network request by its number from the `requests` command.',
category: 'network',
args: z.object({
index: numberArg.describe('1-based number of the request as listed by `requests`'),
}),
toolName: 'browser_network_request',
toolParams: ({ index }) => ({ index }),
});

const networkRequestHeaders = declareCommand({
name: 'request-headers',
description: 'Print only the request headers for a single network request by its number from the `requests` command.',
category: 'network',
args: z.object({
index: numberArg.describe('1-based number of the request as listed by `requests`'),
}),
toolName: 'browser_network_request',
toolParams: ({ index }) => ({ index, part: 'request-headers' }),
});

const networkRequestBody = declareCommand({
name: 'request-body',
description: 'Print only the request body for a single network request by its number from the `requests` command.',
category: 'network',
args: z.object({
index: numberArg.describe('1-based number of the request as listed by `requests`'),
}),
toolName: 'browser_network_request',
toolParams: ({ index }) => ({ index, part: 'request-body' }),
});

const networkResponseHeaders = declareCommand({
name: 'response-headers',
description: 'Print only the response headers for a single network request by its number from the `requests` command.',
category: 'network',
args: z.object({
index: numberArg.describe('1-based number of the request as listed by `requests`'),
}),
toolName: 'browser_network_request',
toolParams: ({ index }) => ({ index, part: 'response-headers' }),
});

const networkResponseBody = declareCommand({
name: 'response-body',
description: 'Print the response body for a single network request by its number from the `requests` command. Textual bodies are inlined; binary bodies are saved to a file and the path is printed.',
category: 'network',
args: z.object({
index: numberArg.describe('1-based number of the request as listed by `requests`'),
}),
toolName: 'browser_network_request',
toolParams: ({ index }) => ({ index, part: 'response-body' }),
});

const tracingStart = declareCommand({
Expand Down Expand Up @@ -1092,6 +1145,12 @@ const commandsArray: AnyCommandSchema[] = [
sessionStorageClear,

// network category
networkRequests,
networkRequest,
networkRequestHeaders,
networkRequestBody,
networkResponseHeaders,
networkResponseBody,
routeMock,
routeList,
unroute,
Expand All @@ -1105,7 +1164,6 @@ const commandsArray: AnyCommandSchema[] = [
installBrowser,

// devtools category
networkRequests,
tracingStart,
tracingStop,
videoStart,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ tools:
- playwright-test/browser_console_messages
- playwright-test/browser_evaluate
- playwright-test/browser_generate_locator
- playwright-test/browser_network_request
- playwright-test/browser_network_requests
- playwright-test/browser_snapshot
- playwright-test/test_debug
Expand Down
Loading
Loading