mirror of
https://github.com/hanzoai/playwright.git
synced 2026-09-24 17:01:53 +00:00
feat(mcp): refactor request output and add --filename option (#40454)
This commit is contained in:
@@ -44,10 +44,13 @@ const requests = defineTabTool({
|
||||
const allRequests = await tab.requests();
|
||||
const filter = params.filter ? new RegExp(params.filter) : undefined;
|
||||
const lines: string[] = [];
|
||||
let hiddenStaticCount = 0;
|
||||
for (let i = 0; i < allRequests.length; i++) {
|
||||
const request = allRequests[i];
|
||||
if (!params.static && !isFetch(request) && isSuccessfulResponse(request))
|
||||
if (!params.static && !isFetch(request) && isSuccessfulResponse(request)) {
|
||||
hiddenStaticCount++;
|
||||
continue;
|
||||
}
|
||||
if (filter) {
|
||||
filter.lastIndex = 0;
|
||||
if (!filter.test(request.url()))
|
||||
@@ -55,6 +58,10 @@ const requests = defineTabTool({
|
||||
}
|
||||
lines.push(`${i + 1}. ${renderRequestLine(request)}`);
|
||||
}
|
||||
if (hiddenStaticCount > 0) {
|
||||
const optionName = tab.context.config.skillMode ? '--static' : '"static"';
|
||||
lines.push(`\nNote: ${hiddenStaticCount} static request${hiddenStaticCount === 1 ? '' : 's'} not shown, run with ${optionName} option to see ${hiddenStaticCount === 1 ? 'it' : 'them'}.`);
|
||||
}
|
||||
await response.addResult('Network', lines.join('\n'), { prefix: 'network', ext: 'log', suggestedFilename: params.filename });
|
||||
},
|
||||
});
|
||||
@@ -72,6 +79,7 @@ const request = defineTabTool({
|
||||
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.'),
|
||||
filename: z.string().optional().describe('Filename to save the result to. If not provided, output is returned as text.'),
|
||||
}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
@@ -84,13 +92,10 @@ const request = defineTabTool({
|
||||
return;
|
||||
}
|
||||
if (params.part) {
|
||||
const partText = await renderRequestPart(request, params.part, response);
|
||||
if (partText !== undefined)
|
||||
response.addTextResult(partText);
|
||||
await renderRequestPart(request, params.part, response, params.filename);
|
||||
return;
|
||||
}
|
||||
const bodyPath = await saveResponseBody(request, response);
|
||||
response.addTextResult(renderRequestDetails(params.index, request, bodyPath));
|
||||
await response.addResult('Request', renderRequestDetails(params.index, request, !!tab.context.config.skillMode), { prefix: 'request', ext: 'log', suggestedFilename: params.filename });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -130,7 +135,7 @@ export function renderRequestLine(request: playwright.Request): string {
|
||||
return line;
|
||||
}
|
||||
|
||||
function renderRequestDetails(index: number, request: playwright.Request, responseBodyPath: string | undefined): string {
|
||||
function renderRequestDetails(index: number, request: playwright.Request, skillMode: boolean): string {
|
||||
const httpResponse = request.existingResponse();
|
||||
const responseHeaders = httpResponse?.headers();
|
||||
const lines: string[] = [];
|
||||
@@ -152,25 +157,35 @@ function renderRequestDetails(index: number, request: playwright.Request, respon
|
||||
|
||||
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}`);
|
||||
}
|
||||
const hints: string[] = [];
|
||||
if (request.postData())
|
||||
hints.push(partHint(skillMode, 'request-body', index));
|
||||
if (canHaveResponseBody(httpResponse))
|
||||
hints.push(partHint(skillMode, 'response-body', index));
|
||||
if (hints.length)
|
||||
lines.push('', ...hints);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function partHint(skillMode: boolean, part: 'request-body' | 'response-body', index: number): string {
|
||||
const subject = part === 'request-body' ? 'request body' : 'response body';
|
||||
return skillMode
|
||||
? `Run \`${part} ${index}\` to read the ${subject}.`
|
||||
: `Call browser_network_request with part="${part}" to read the ${subject}.`;
|
||||
}
|
||||
|
||||
function canHaveResponseBody(httpResponse: playwright.Response | null): httpResponse is playwright.Response {
|
||||
if (!httpResponse)
|
||||
return false;
|
||||
const status = httpResponse.status();
|
||||
// Status codes that cannot have a response body per RFC 7230.
|
||||
return status !== 204 && status !== 304 && !(status >= 100 && status < 200);
|
||||
}
|
||||
|
||||
function appendHeaderSection(lines: string[], title: string, headers: Record<string, string>): void {
|
||||
const entries = Object.entries(headers);
|
||||
if (!entries.length)
|
||||
@@ -188,39 +203,48 @@ function computeDurationMs(request: playwright.Request): number | 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;
|
||||
async function renderRequestPart(request: playwright.Request, part: RequestPart, response: ToolResponse, suggestedFilename: string | undefined): Promise<void> {
|
||||
if (part === 'request-headers') {
|
||||
await response.addResult('Request headers', renderHeaders(request.headers()), { prefix: 'request', ext: 'txt', suggestedFilename });
|
||||
return;
|
||||
}
|
||||
if (part === 'request-body') {
|
||||
const data = request.postData();
|
||||
if (data !== null)
|
||||
await response.addResult('Request body', data, { prefix: 'request', ext: 'txt', suggestedFilename });
|
||||
return;
|
||||
}
|
||||
const httpResponse = request.existingResponse();
|
||||
if (!httpResponse)
|
||||
return undefined;
|
||||
if (part === 'response-headers')
|
||||
return renderHeaders(httpResponse.headers());
|
||||
return;
|
||||
if (part === 'response-headers') {
|
||||
await response.addResult('Response headers', renderHeaders(httpResponse.headers()), { prefix: 'response', ext: 'txt', suggestedFilename });
|
||||
return;
|
||||
}
|
||||
// response-body
|
||||
const contentType = httpResponse.headers()['content-type'];
|
||||
if (isTextualMimeType(contentType ?? '')) {
|
||||
let text: string;
|
||||
try {
|
||||
return await httpResponse.text();
|
||||
text = await httpResponse.text();
|
||||
} catch {
|
||||
return undefined;
|
||||
return;
|
||||
}
|
||||
await response.addResult('Response body', text, { prefix: 'response', ext: 'txt', suggestedFilename });
|
||||
return;
|
||||
}
|
||||
return await saveResponseBody(request, response);
|
||||
const path = await saveResponseBody(request, response, suggestedFilename);
|
||||
if (path !== undefined)
|
||||
response.addTextResult(path);
|
||||
}
|
||||
|
||||
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> {
|
||||
async function saveResponseBody(request: playwright.Request, response: ToolResponse, suggestedFilename?: string): 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))
|
||||
if (!canHaveResponseBody(httpResponse))
|
||||
return undefined;
|
||||
let body: Buffer;
|
||||
try {
|
||||
@@ -231,7 +255,7 @@ async function saveResponseBody(request: playwright.Request, response: ToolRespo
|
||||
if (!body.length)
|
||||
return undefined;
|
||||
const ext = getExtensionForMimeType(httpResponse.headers()['content-type']);
|
||||
const resolved = await response.resolveClientFile({ prefix: 'response', ext }, 'Response body');
|
||||
const resolved = await response.resolveClientFile({ prefix: 'response', ext, suggestedFilename }, 'Response body');
|
||||
await fs.promises.writeFile(resolved.fileName, body);
|
||||
return resolved.relativeName;
|
||||
}
|
||||
|
||||
@@ -828,6 +828,8 @@ const networkRequests = declareCommand({
|
||||
toolParams: ({ static: s, filter, clear }) => clear ? ({}) : ({ static: s, filter }),
|
||||
});
|
||||
|
||||
const filenameOption = z.string().optional().describe('Filename to save the result to. If not provided, output is returned as text.');
|
||||
|
||||
const networkRequest = declareCommand({
|
||||
name: 'request',
|
||||
description: 'Show full details (headers, body, response) of a single network request by its number from the `requests` command.',
|
||||
@@ -835,8 +837,11 @@ const networkRequest = declareCommand({
|
||||
args: z.object({
|
||||
index: numberArg.describe('1-based number of the request as listed by `requests`'),
|
||||
}),
|
||||
options: z.object({
|
||||
filename: filenameOption,
|
||||
}),
|
||||
toolName: 'browser_network_request',
|
||||
toolParams: ({ index }) => ({ index }),
|
||||
toolParams: ({ index, filename }) => ({ index, filename }),
|
||||
});
|
||||
|
||||
const networkRequestHeaders = declareCommand({
|
||||
@@ -846,8 +851,11 @@ const networkRequestHeaders = declareCommand({
|
||||
args: z.object({
|
||||
index: numberArg.describe('1-based number of the request as listed by `requests`'),
|
||||
}),
|
||||
options: z.object({
|
||||
filename: filenameOption,
|
||||
}),
|
||||
toolName: 'browser_network_request',
|
||||
toolParams: ({ index }) => ({ index, part: 'request-headers' }),
|
||||
toolParams: ({ index, filename }) => ({ index, part: 'request-headers', filename }),
|
||||
});
|
||||
|
||||
const networkRequestBody = declareCommand({
|
||||
@@ -857,8 +865,11 @@ const networkRequestBody = declareCommand({
|
||||
args: z.object({
|
||||
index: numberArg.describe('1-based number of the request as listed by `requests`'),
|
||||
}),
|
||||
options: z.object({
|
||||
filename: filenameOption,
|
||||
}),
|
||||
toolName: 'browser_network_request',
|
||||
toolParams: ({ index }) => ({ index, part: 'request-body' }),
|
||||
toolParams: ({ index, filename }) => ({ index, part: 'request-body', filename }),
|
||||
});
|
||||
|
||||
const networkResponseHeaders = declareCommand({
|
||||
@@ -868,8 +879,11 @@ const networkResponseHeaders = declareCommand({
|
||||
args: z.object({
|
||||
index: numberArg.describe('1-based number of the request as listed by `requests`'),
|
||||
}),
|
||||
options: z.object({
|
||||
filename: filenameOption,
|
||||
}),
|
||||
toolName: 'browser_network_request',
|
||||
toolParams: ({ index }) => ({ index, part: 'response-headers' }),
|
||||
toolParams: ({ index, filename }) => ({ index, part: 'response-headers', filename }),
|
||||
});
|
||||
|
||||
const networkResponseBody = declareCommand({
|
||||
@@ -879,8 +893,11 @@ const networkResponseBody = declareCommand({
|
||||
args: z.object({
|
||||
index: numberArg.describe('1-based number of the request as listed by `requests`'),
|
||||
}),
|
||||
options: z.object({
|
||||
filename: filenameOption,
|
||||
}),
|
||||
toolName: 'browser_network_request',
|
||||
toolParams: ({ index }) => ({ index, part: 'response-body' }),
|
||||
toolParams: ({ index, filename }) => ({ index, part: 'response-body', filename }),
|
||||
});
|
||||
|
||||
const tracingStart = declareCommand({
|
||||
|
||||
@@ -51,12 +51,14 @@ test('requests', async ({ cli, server }) => {
|
||||
const { output } = await cli('requests');
|
||||
expect(output).not.toContain(`[GET] ${`${server.PREFIX}/`} => [200] OK`);
|
||||
expect(output).toMatch(new RegExp(String.raw`^\d+\. \[GET\] ${escapeRegExp(`${server.PREFIX}/hello-world`)} => \[200\] OK$`, 'm'));
|
||||
expect(output).toContain('Note: 1 static request not shown, run with --static option to see it.');
|
||||
});
|
||||
|
||||
test('requests --static', async ({ cli, server }) => {
|
||||
await cli('open', server.PREFIX);
|
||||
const { output } = await cli('requests', '--static');
|
||||
expect(output).toMatch(new RegExp(String.raw`^\d+\. \[GET\] ${escapeRegExp(`${server.PREFIX}/`)} => \[200\] OK$`, 'm'));
|
||||
expect(output).not.toContain('not shown');
|
||||
});
|
||||
|
||||
test('requests --filter', async ({ cli, server }) => {
|
||||
@@ -101,37 +103,13 @@ test('request shows full request and response details', async ({ cli, server })
|
||||
expect(output).toContain('status: [200] OK');
|
||||
expect(output).toContain('Request headers');
|
||||
expect(output).toContain('x-custom-header: test-value');
|
||||
expect(output).toContain('Request body');
|
||||
expect(output).toContain('{"key":"value"}');
|
||||
expect(output).toContain('Response headers');
|
||||
expect(output).toContain('x-custom-response: response-value');
|
||||
const bodyMatch = output.match(/Response body\n\s+(\S+\.json)/);
|
||||
expect(bodyMatch).not.toBeNull();
|
||||
const bodyPath = path.resolve(test.info().outputPath(), bodyMatch![1]);
|
||||
expect(fs.readFileSync(bodyPath, 'utf-8')).toBe('{"name":"John Doe"}');
|
||||
});
|
||||
|
||||
test('request saves binary response body to a file', async ({ cli, server }) => {
|
||||
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
server.setContent('/', `
|
||||
<button onclick="fetch('/image.png')">Click me</button>
|
||||
`, 'text/html');
|
||||
server.setRoute('/image.png', (_req, res) => {
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
res.end(pngBytes);
|
||||
});
|
||||
await cli('open', server.PREFIX);
|
||||
await cli('click', 'e2');
|
||||
|
||||
const { output: list } = await cli('requests', '--static');
|
||||
const match = list.match(/^(\d+)\. \[GET\] [^ ]+\/image\.png =>/m);
|
||||
expect(match).not.toBeNull();
|
||||
|
||||
const { output } = await cli('request', match![1]);
|
||||
const bodyMatch = output.match(/Response body\n\s+(\S+\.png)/);
|
||||
expect(bodyMatch).not.toBeNull();
|
||||
const bodyPath = path.resolve(test.info().outputPath(), bodyMatch![1]);
|
||||
expect(fs.readFileSync(bodyPath)).toEqual(pngBytes);
|
||||
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');
|
||||
expect(output).not.toContain('Response body');
|
||||
expect(output).not.toContain('{"key":"value"}');
|
||||
});
|
||||
|
||||
test('per-part commands extract individual parts', async ({ cli, server }) => {
|
||||
@@ -157,6 +135,41 @@ test('per-part commands extract individual parts', async ({ cli, server }) => {
|
||||
expect((await cli('response-body', num)).output).toContain('{"name":"John Doe"}');
|
||||
});
|
||||
|
||||
test('request* and response* commands support --filename', async ({ cli, server }, testInfo) => {
|
||||
server.setContent('/', `
|
||||
<button onclick="fetch('/api', { method: 'POST', body: 'hello' })">Click me</button>
|
||||
`, 'text/html');
|
||||
server.setRoute('/api', (_req, res) => {
|
||||
res.setHeader('X-Custom-Response', 'response-value');
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ name: 'John Doe' }));
|
||||
});
|
||||
await cli('open', server.PREFIX);
|
||||
await cli('click', 'e2');
|
||||
|
||||
const { output: list } = await cli('requests');
|
||||
const match = list.match(/^(\d+)\. \[POST\] [^ ]+\/api =>/m);
|
||||
expect(match).not.toBeNull();
|
||||
const num = match![1];
|
||||
|
||||
const read = (file: string) => fs.readFileSync(testInfo.outputPath(file), 'utf-8');
|
||||
|
||||
expect((await cli('request', num, '--filename=req.log')).output).toContain('[Request](./req.log)');
|
||||
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((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((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"}');
|
||||
});
|
||||
|
||||
test('--raw response-body returns just the body', async ({ cli, server }) => {
|
||||
server.setContent('/', `
|
||||
<button onclick="fetch('/api')">Click me</button>
|
||||
|
||||
@@ -213,7 +213,7 @@ test('request and per-part commands return JSON result', async ({ cli, server })
|
||||
|
||||
const detail = JSON.parse((await cli('--json', 'request', num)).output);
|
||||
expect(detail.result).toContain(`#${num} [POST] ${server.PREFIX}/api`);
|
||||
expect(detail.result).toContain('Response body');
|
||||
expect(detail.result).toContain(`Run \`response-body ${num}\` to read the response body.`);
|
||||
});
|
||||
|
||||
test('request with out-of-range index returns JSON error', async ({ cli, server }) => {
|
||||
|
||||
@@ -49,6 +49,7 @@ test('browser_network_requests', async ({ client, server }) => {
|
||||
expect(response.result).not.toContain(`[GET] ${`${server.PREFIX}/`} => [200] OK`);
|
||||
expect(response.result).toMatch(new RegExp(String.raw`^\d+\. \[GET\] ${escapeRegExp(`${server.PREFIX}/json`)} => \[200\] OK$`, 'm'));
|
||||
expect(response.result).toMatch(new RegExp(String.raw`^\d+\. \[GET\] ${escapeRegExp(`${server.PREFIX}/image.png`)} => \[404\]`, 'm'));
|
||||
expect(response.result).toContain('Note: 1 static request not shown, run with "static" option to see it.');
|
||||
}
|
||||
|
||||
{
|
||||
@@ -61,6 +62,7 @@ test('browser_network_requests', async ({ client, server }) => {
|
||||
expect(response.result).toMatch(new RegExp(String.raw`^\d+\. \[GET\] ${escapeRegExp(`${server.PREFIX}/`)} => \[200\] OK$`, 'm'));
|
||||
expect(response.result).toMatch(new RegExp(String.raw`^\d+\. \[GET\] ${escapeRegExp(`${server.PREFIX}/json`)} => \[200\] OK$`, 'm'));
|
||||
expect(response.result).toMatch(new RegExp(String.raw`^\d+\. \[GET\] ${escapeRegExp(`${server.PREFIX}/image.png`)} => \[404\]`, 'm'));
|
||||
expect(response.result).not.toContain('not shown');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -146,52 +148,13 @@ test('browser_network_request shows full request and response details', async ({
|
||||
expect(detail.result).toContain('mimeType: application/json');
|
||||
expect(detail.result).toContain('Request headers');
|
||||
expect(detail.result).toContain('x-custom-header: test-value');
|
||||
expect(detail.result).toContain('Request body');
|
||||
expect(detail.result).toContain('{"key":"value"}');
|
||||
expect(detail.result).toContain('Response headers');
|
||||
expect(detail.result).toContain('x-custom-response: response-value');
|
||||
|
||||
const bodyMatch = detail.result.match(/Response body\n\s+(\S+\.json)/);
|
||||
expect(bodyMatch).not.toBeNull();
|
||||
const bodyPath = path.resolve(test.info().outputPath(), bodyMatch![1]);
|
||||
expect(fs.readFileSync(bodyPath, 'utf-8')).toBe('{"name":"John Doe"}');
|
||||
});
|
||||
|
||||
test('browser_network_request saves binary response body to a file', async ({ client, server }) => {
|
||||
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
server.setContent('/', `
|
||||
<button onclick="fetch('/image.png')">Click me</button>
|
||||
`, 'text/html');
|
||||
server.setRoute('/image.png', (_req, res) => {
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
res.end(pngBytes);
|
||||
});
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
});
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: { element: 'Click me button', target: 'e2' },
|
||||
});
|
||||
|
||||
const list = parseResponse(await client.callTool({
|
||||
name: 'browser_network_requests',
|
||||
arguments: { static: true },
|
||||
}));
|
||||
const match = list.result.match(/^(\d+)\. \[GET\] [^ ]+\/image\.png =>/m);
|
||||
expect(match).not.toBeNull();
|
||||
|
||||
const detail = parseResponse(await client.callTool({
|
||||
name: 'browser_network_request',
|
||||
arguments: { index: Number(match![1]) },
|
||||
}));
|
||||
const bodyMatch = detail.result.match(/Response body\n\s+(\S+\.png)/);
|
||||
expect(bodyMatch).not.toBeNull();
|
||||
const bodyPath = path.resolve(test.info().outputPath(), bodyMatch![1]);
|
||||
expect(fs.readFileSync(bodyPath)).toEqual(pngBytes);
|
||||
expect(detail.result).toContain('Call browser_network_request with part="request-body" to read the request body.');
|
||||
expect(detail.result).toContain('Call browser_network_request with part="response-body" to read the response body.');
|
||||
expect(detail.result).not.toContain('Request body');
|
||||
expect(detail.result).not.toContain('Response body');
|
||||
expect(detail.result).not.toContain('{"key":"value"}');
|
||||
});
|
||||
|
||||
test('browser_network_request reports failed requests', async ({ client, server }) => {
|
||||
|
||||
Reference in New Issue
Block a user