Skip to content

Commit 78a77a7

Browse files
authored
feat(mcp): refactor network into requests + request commands (#40447)
1 parent 47b75ea commit 78a77a7

12 files changed

Lines changed: 607 additions & 121 deletions

File tree

docs/src/getting-started-cli.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,8 @@ playwright-cli tab-close [index] # close a tab
164164
### Network
165165
166166
```bash
167-
playwright-cli network # list network requests since page load
167+
playwright-cli requests # list network requests since page load
168+
playwright-cli request <num> # show full details of a single request
168169
playwright-cli route <pattern> [opts] # mock network requests
169170
playwright-cli route-list # list active routes
170171
playwright-cli unroute [pattern] # remove routes

packages/isomorphic/mimeType.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,21 @@ export function getMimeTypeForPath(path: string): string | null {
3333
return types.get(extension) || null;
3434
}
3535

36+
export function getExtensionForMimeType(contentType: string | undefined): string {
37+
const subtype = (contentType ?? '').split(';')[0].split('/')[1]?.trim().toLowerCase() ?? '';
38+
if (!subtype)
39+
return 'bin';
40+
// image/svg+xml → xml, application/ld+json → json
41+
const tail = subtype.includes('+') ? subtype.split('+').pop()! : subtype;
42+
if (tail === 'plain')
43+
return 'txt';
44+
if (tail === 'javascript' || tail === 'ecmascript')
45+
return 'js';
46+
if (tail === 'jpeg')
47+
return 'jpg';
48+
return tail.replace(/[^a-z0-9]/g, '') || 'bin';
49+
}
50+
3651
const types: Map<string, string> = new Map([
3752
['ez', 'application/andrew-inset'],
3853
['aw', 'application/applixware'],

packages/playwright-core/src/tools/backend/network.ts

Lines changed: 158 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,15 @@
1414
* limitations under the License.
1515
*/
1616

17+
import fs from 'fs';
18+
1719
import * as z from 'zod';
20+
21+
import { getExtensionForMimeType, isTextualMimeType } from '@isomorphic/mimeType';
22+
1823
import { defineTool, defineTabTool } from './tool';
1924

25+
import type { Response as ToolResponse } from './response';
2026
import type * as playwright from '../../..';
2127

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

3943
handle: async (tab, params, response) => {
40-
const requests = await tab.requests();
44+
const allRequests = await tab.requests();
4145
const filter = params.filter ? new RegExp(params.filter) : undefined;
42-
const text: string[] = [];
43-
for (const request of requests) {
46+
const lines: string[] = [];
47+
for (let i = 0; i < allRequests.length; i++) {
48+
const request = allRequests[i];
4449
if (!params.static && !isFetch(request) && isSuccessfulResponse(request))
4550
continue;
4651
if (filter) {
4752
filter.lastIndex = 0;
4853
if (!filter.test(request.url()))
4954
continue;
5055
}
51-
text.push(await renderRequest(request, params.requestBody, params.requestHeaders));
56+
lines.push(`${i + 1}. ${renderRequestLine(request)}`);
5257
}
53-
await response.addResult('Network', text.join('\n'), { prefix: 'network', ext: 'log', suggestedFilename: params.filename });
58+
await response.addResult('Network', lines.join('\n'), { prefix: 'network', ext: 'log', suggestedFilename: params.filename });
59+
},
60+
});
61+
62+
const REQUEST_PARTS = ['request-headers', 'request-body', 'response-headers', 'response-body'] as const;
63+
type RequestPart = typeof REQUEST_PARTS[number];
64+
65+
const request = defineTabTool({
66+
capability: 'core',
67+
68+
schema: {
69+
name: 'browser_network_request',
70+
title: 'Show network request details',
71+
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.',
72+
inputSchema: z.object({
73+
index: z.number().int().min(1).describe('1-based index of the request, as printed by browser_network_requests.'),
74+
part: z.enum(REQUEST_PARTS).optional().describe('Return only this part of the request. Omit to return full details.'),
75+
}),
76+
type: 'readOnly',
77+
},
78+
79+
handle: async (tab, params, response) => {
80+
const allRequests = await tab.requests();
81+
const request = allRequests[params.index - 1];
82+
if (!request) {
83+
response.addError(`Request #${params.index} not found. Use browser_network_requests to see available indexes.`);
84+
return;
85+
}
86+
if (params.part) {
87+
const partText = await renderRequestPart(request, params.part, response);
88+
if (partText !== undefined)
89+
response.addTextResult(partText);
90+
return;
91+
}
92+
const bodyPath = await saveResponseBody(request, response);
93+
response.addTextResult(renderRequestDetails(params.index, request, bodyPath));
5494
},
5595
});
5696

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

83-
export async function renderRequest(request: playwright.Request, includeBody = false, includeHeaders = false): Promise<string> {
123+
export function renderRequestLine(request: playwright.Request): string {
84124
const response = request.existingResponse();
85-
86-
const result: string[] = [];
87-
result.push(`[${request.method().toUpperCase()}] ${request.url()}`);
125+
let line = `[${request.method().toUpperCase()}] ${request.url()}`;
88126
if (response)
89-
result.push(` => [${response.status()}] ${response.statusText()}`);
127+
line += ` => [${response.status()}] ${response.statusText()}`;
90128
else if (request.failure())
91-
result.push(` => [FAILED] ${request.failure()?.errorText ?? 'Unknown error'}`);
92-
if (includeHeaders) {
93-
const headers = request.headers();
94-
const headerLines = Object.entries(headers).map(([k, v]) => ` ${k}: ${v}`).join('\n');
95-
if (headerLines)
96-
result.push(`\n Request headers:\n${headerLines}`);
129+
line += ` => [FAILED] ${request.failure()?.errorText ?? 'Unknown error'}`;
130+
return line;
131+
}
132+
133+
function renderRequestDetails(index: number, request: playwright.Request, responseBodyPath: string | undefined): string {
134+
const httpResponse = request.existingResponse();
135+
const responseHeaders = httpResponse?.headers();
136+
const lines: string[] = [];
137+
lines.push(`#${index} [${request.method().toUpperCase()}] ${request.url()}`);
138+
139+
lines.push('');
140+
lines.push(' General');
141+
if (httpResponse)
142+
lines.push(` status: [${httpResponse.status()}] ${httpResponse.statusText()}`);
143+
else if (request.failure())
144+
lines.push(` status: [FAILED] ${request.failure()?.errorText ?? 'Unknown error'}`);
145+
const duration = computeDurationMs(request);
146+
if (duration !== undefined)
147+
lines.push(` duration: ${duration}ms`);
148+
lines.push(` type: ${request.resourceType()}`);
149+
const contentType = responseHeaders?.['content-type'];
150+
if (contentType)
151+
lines.push(` mimeType: ${contentType.split(';')[0].trim()}`);
152+
153+
appendHeaderSection(lines, 'Request headers', request.headers());
154+
155+
const postData = request.postData();
156+
if (postData) {
157+
lines.push('');
158+
lines.push(' Request body');
159+
lines.push(` ${postData}`);
160+
}
161+
162+
if (responseHeaders)
163+
appendHeaderSection(lines, 'Response headers', responseHeaders);
164+
165+
if (responseBodyPath) {
166+
lines.push('');
167+
lines.push(' Response body');
168+
lines.push(` ${responseBodyPath}`);
169+
}
170+
171+
return lines.join('\n');
172+
}
173+
174+
function appendHeaderSection(lines: string[], title: string, headers: Record<string, string>): void {
175+
const entries = Object.entries(headers);
176+
if (!entries.length)
177+
return;
178+
lines.push('');
179+
lines.push(` ${title}`);
180+
for (const [k, v] of entries)
181+
lines.push(` ${k}: ${v}`);
182+
}
183+
184+
function computeDurationMs(request: playwright.Request): number | undefined {
185+
const timing = request.timing();
186+
if (!timing || timing.responseEnd < 0)
187+
return undefined;
188+
return Math.round(timing.responseEnd);
189+
}
190+
191+
async function renderRequestPart(request: playwright.Request, part: RequestPart, response: ToolResponse): Promise<string | undefined> {
192+
if (part === 'request-headers')
193+
return renderHeaders(request.headers());
194+
if (part === 'request-body')
195+
return request.postData() ?? undefined;
196+
const httpResponse = request.existingResponse();
197+
if (!httpResponse)
198+
return undefined;
199+
if (part === 'response-headers')
200+
return renderHeaders(httpResponse.headers());
201+
// response-body
202+
const contentType = httpResponse.headers()['content-type'];
203+
if (isTextualMimeType(contentType ?? '')) {
204+
try {
205+
return await httpResponse.text();
206+
} catch {
207+
return undefined;
208+
}
97209
}
98-
if (includeBody) {
99-
const postData = request.postData();
100-
if (postData)
101-
result.push(`\n Request body: ${postData}`);
210+
return await saveResponseBody(request, response);
211+
}
212+
213+
function renderHeaders(headers: Record<string, string>): string {
214+
return Object.entries(headers).map(([k, v]) => `${k}: ${v}`).join('\n');
215+
}
216+
217+
async function saveResponseBody(request: playwright.Request, response: ToolResponse): Promise<string | undefined> {
218+
const httpResponse = request.existingResponse();
219+
if (!httpResponse)
220+
return undefined;
221+
const status = httpResponse.status();
222+
// Status codes that cannot have a response body per RFC 7230.
223+
if (status === 204 || status === 304 || (status >= 100 && status < 200))
224+
return undefined;
225+
let body: Buffer;
226+
try {
227+
body = await httpResponse.body();
228+
} catch {
229+
return undefined;
102230
}
103-
return result.join('');
231+
if (!body.length)
232+
return undefined;
233+
const ext = getExtensionForMimeType(httpResponse.headers()['content-type']);
234+
const resolved = await response.resolveClientFile({ prefix: 'response', ext }, 'Response body');
235+
await fs.promises.writeFile(resolved.fileName, body);
236+
return resolved.relativeName;
104237
}
105238

106239
const networkStateSet = defineTool({
@@ -127,6 +260,7 @@ const networkStateSet = defineTool({
127260

128261
export default [
129262
requests,
263+
request,
130264
networkClear,
131265
networkStateSet,
132266
];

packages/playwright-core/src/tools/cli-client/skill/SKILL.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,8 @@ playwright-cli unroute
153153
```bash
154154
playwright-cli console
155155
playwright-cli console warning
156-
playwright-cli network
156+
playwright-cli requests
157+
playwright-cli request 5
157158
playwright-cli run-code "async page => await page.context().grantPermissions(['geolocation'])"
158159
playwright-cli run-code --filename=script.js
159160
playwright-cli tracing-start
@@ -351,7 +352,7 @@ playwright-cli open https://example.com
351352
playwright-cli click e4
352353
playwright-cli fill e7 "test"
353354
playwright-cli console
354-
playwright-cli network
355+
playwright-cli requests
355356
playwright-cli close
356357
```
357358

packages/playwright-core/src/tools/cli-daemon/commands.ts

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -815,19 +815,72 @@ const consoleList = declareCommand({
815815
});
816816

817817
const networkRequests = declareCommand({
818-
name: 'network',
819-
description: 'List all network requests since loading the page',
820-
category: 'devtools',
818+
name: 'requests',
819+
description: 'List all network requests since loading the page. Each request is numbered for use with the `request` command.',
820+
category: 'network',
821821
args: z.object({}),
822822
options: z.object({
823823
static: z.boolean().optional().describe('Whether to include successful static resources like images, fonts, scripts, etc. Defaults to false.'),
824-
['request-body']: z.boolean().optional().describe('Whether to include request body. Defaults to false.'),
825-
['request-headers']: z.boolean().optional().describe('Whether to include request headers. Defaults to false.'),
826824
filter: z.string().optional().describe('Only return requests whose URL matches this regexp (e.g. "/api/.*user").'),
827825
clear: z.boolean().optional().describe('Whether to clear the network list'),
828826
}),
829827
toolName: ({ clear }) => clear ? 'browser_network_clear' : 'browser_network_requests',
830-
toolParams: ({ static: s, 'request-body': requestBody, 'request-headers': requestHeaders, filter, clear }) => clear ? ({}) : ({ static: s, requestBody, requestHeaders, filter }),
828+
toolParams: ({ static: s, filter, clear }) => clear ? ({}) : ({ static: s, filter }),
829+
});
830+
831+
const networkRequest = declareCommand({
832+
name: 'request',
833+
description: 'Show full details (headers, body, response) of a single network request by its number from the `requests` command.',
834+
category: 'network',
835+
args: z.object({
836+
index: numberArg.describe('1-based number of the request as listed by `requests`'),
837+
}),
838+
toolName: 'browser_network_request',
839+
toolParams: ({ index }) => ({ index }),
840+
});
841+
842+
const networkRequestHeaders = declareCommand({
843+
name: 'request-headers',
844+
description: 'Print only the request headers for a single network request by its number from the `requests` command.',
845+
category: 'network',
846+
args: z.object({
847+
index: numberArg.describe('1-based number of the request as listed by `requests`'),
848+
}),
849+
toolName: 'browser_network_request',
850+
toolParams: ({ index }) => ({ index, part: 'request-headers' }),
851+
});
852+
853+
const networkRequestBody = declareCommand({
854+
name: 'request-body',
855+
description: 'Print only the request body for a single network request by its number from the `requests` command.',
856+
category: 'network',
857+
args: z.object({
858+
index: numberArg.describe('1-based number of the request as listed by `requests`'),
859+
}),
860+
toolName: 'browser_network_request',
861+
toolParams: ({ index }) => ({ index, part: 'request-body' }),
862+
});
863+
864+
const networkResponseHeaders = declareCommand({
865+
name: 'response-headers',
866+
description: 'Print only the response headers for a single network request by its number from the `requests` command.',
867+
category: 'network',
868+
args: z.object({
869+
index: numberArg.describe('1-based number of the request as listed by `requests`'),
870+
}),
871+
toolName: 'browser_network_request',
872+
toolParams: ({ index }) => ({ index, part: 'response-headers' }),
873+
});
874+
875+
const networkResponseBody = declareCommand({
876+
name: 'response-body',
877+
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.',
878+
category: 'network',
879+
args: z.object({
880+
index: numberArg.describe('1-based number of the request as listed by `requests`'),
881+
}),
882+
toolName: 'browser_network_request',
883+
toolParams: ({ index }) => ({ index, part: 'response-body' }),
831884
});
832885

833886
const tracingStart = declareCommand({
@@ -1092,6 +1145,12 @@ const commandsArray: AnyCommandSchema[] = [
10921145
sessionStorageClear,
10931146

10941147
// network category
1148+
networkRequests,
1149+
networkRequest,
1150+
networkRequestHeaders,
1151+
networkRequestBody,
1152+
networkResponseHeaders,
1153+
networkResponseBody,
10951154
routeMock,
10961155
routeList,
10971156
unroute,
@@ -1105,7 +1164,6 @@ const commandsArray: AnyCommandSchema[] = [
11051164
installBrowser,
11061165

11071166
// devtools category
1108-
networkRequests,
11091167
tracingStart,
11101168
tracingStop,
11111169
videoStart,

packages/playwright/src/agents/playwright-test-healer.agent.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ tools:
99
- playwright-test/browser_console_messages
1010
- playwright-test/browser_evaluate
1111
- playwright-test/browser_generate_locator
12+
- playwright-test/browser_network_request
1213
- playwright-test/browser_network_requests
1314
- playwright-test/browser_snapshot
1415
- playwright-test/test_debug

0 commit comments

Comments
 (0)