Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
146 changes: 122 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 } 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,56 @@ 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 = 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. 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.'),
}),
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;
}
const bodyPath = await saveResponseBody(request, response);
response.addTextResult(renderRequestDetails(params.index, request, bodyPath));
},
});

Expand Down Expand Up @@ -80,27 +110,94 @@ 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}`);
}
if (includeBody) {
const postData = request.postData();
if (postData)
result.push(`\n Request body: ${postData}`);

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 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 +224,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
24 changes: 17 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,28 @@ 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 tracingStart = declareCommand({
Expand Down Expand Up @@ -1092,6 +1101,8 @@ const commandsArray: AnyCommandSchema[] = [
sessionStorageClear,

// network category
networkRequests,
networkRequest,
routeMock,
routeList,
unroute,
Expand All @@ -1105,7 +1116,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
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ tools:
- playwright-test/browser_hover
- playwright-test/browser_navigate
- playwright-test/browser_navigate_back
- playwright-test/browser_network_request
- playwright-test/browser_network_requests
- playwright-test/browser_press_key
- playwright-test/browser_run_code
Expand Down
1 change: 1 addition & 0 deletions tests/mcp/capabilities.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ test('test snapshot tool list', async ({ client }) => {
'browser_close',
'browser_navigate_back',
'browser_navigate',
'browser_network_request',
'browser_network_requests',
'browser_press_key',
'browser_resize',
Expand Down
Loading
Loading