diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000000000..231189577e90c --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,9 @@ +## PR Review Guidelines + +When reviewing pull requests: + +- Only comment on semantically meaningful issues: bugs, incorrect logic, security problems, or API contract violations. +- Skip style, formatting, naming, and whitespace observations unless they cause functional problems. +- Keep each comment short — one or two sentences maximum. +- Do not write long descriptions or summaries of what the code does. +- Do not suggest refactors or improvements unrelated to the PR's stated goal. diff --git a/packages/playwright-core/src/tools/backend/network.ts b/packages/playwright-core/src/tools/backend/network.ts index 2e3040528678e..4097422b63357 100644 --- a/packages/playwright-core/src/tools/backend/network.ts +++ b/packages/playwright-core/src/tools/backend/network.ts @@ -27,7 +27,10 @@ const requests = defineTabTool({ title: 'List network requests', description: 'Returns all network requests since loading the page', inputSchema: z.object({ - includeStatic: z.boolean().default(false).describe('Whether to include successful static resources like images, fonts, scripts, etc. Defaults to false.'), + 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', @@ -35,11 +38,17 @@ const requests = defineTabTool({ handle: async (tab, params, response) => { const requests = await tab.requests(); + const filter = params.filter ? new RegExp(params.filter) : undefined; const text: string[] = []; for (const request of requests) { - if (!params.includeStatic && !isFetch(request) && isSuccessfulResponse(request)) + if (!params.static && !isFetch(request) && isSuccessfulResponse(request)) continue; - text.push(await renderRequest(request)); + if (filter) { + filter.lastIndex = 0; + if (!filter.test(request.url())) + continue; + } + text.push(await renderRequest(request, params.requestBody, params.requestHeaders)); } await response.addResult('Network', text.join('\n'), { prefix: 'network', ext: 'log', suggestedFilename: params.filename }); }, @@ -71,16 +80,27 @@ export function isFetch(request: playwright.Request): boolean { return ['fetch', 'xhr'].includes(request.resourceType()); } -export async function renderRequest(request: playwright.Request): Promise { +export async function renderRequest(request: playwright.Request, includeBody = false, includeHeaders = false): Promise { const response = request.existingResponse(); const result: string[] = []; result.push(`[${request.method().toUpperCase()}] ${request.url()}`); if (response) - result.push(`=> [${response.status()}] ${response.statusText()}`); + result.push(` => [${response.status()}] ${response.statusText()}`); else if (request.failure()) - result.push(`=> [FAILED] ${request.failure()?.errorText ?? 'Unknown error'}`); - return result.join(' '); + 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}`); + } + if (includeBody) { + const postData = request.postData(); + if (postData) + result.push(`\n Request body: ${postData}`); + } + return result.join(''); } const networkStateSet = defineTool({ diff --git a/packages/playwright-core/src/tools/cli-daemon/commands.ts b/packages/playwright-core/src/tools/cli-daemon/commands.ts index 98e691f02f579..f410a7856090e 100644 --- a/packages/playwright-core/src/tools/cli-daemon/commands.ts +++ b/packages/playwright-core/src/tools/cli-daemon/commands.ts @@ -743,10 +743,13 @@ const networkRequests = declareCommand({ 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: includeStatic, clear }) => clear ? ({}) : ({ includeStatic }), + toolParams: ({ static: s, 'request-body': requestBody, 'request-headers': requestHeaders, filter, clear }) => clear ? ({}) : ({ static: s, requestBody, requestHeaders, filter }), }); const tracingStart = declareCommand({ diff --git a/tests/mcp/cli-devtools.spec.ts b/tests/mcp/cli-devtools.spec.ts index 8bdd8e3397004..12086d8d26be9 100644 --- a/tests/mcp/cli-devtools.spec.ts +++ b/tests/mcp/cli-devtools.spec.ts @@ -64,6 +64,59 @@ test('network --static', async ({ cli, server }) => { expect(attachments[0].data.toString()).toContain(`[GET] ${`${server.PREFIX}/`} => [200] OK`); }); +test('network --filter', async ({ cli, server }) => { + server.setContent('/', ``, 'text/html'); + await cli('open', server.PREFIX); + + const { attachments } = await cli('network', '--filter=/api/', '--static'); + expect(attachments[0].data.toString()).toContain(`${server.PREFIX}/api/users`); + expect(attachments[0].data.toString()).toContain(`${server.PREFIX}/api/orders`); + expect(attachments[0].data.toString()).not.toContain(`${server.PREFIX}/static/image.png`); +}); + +test('network --request-body', async ({ cli, server }) => { + server.setContent('/', ` + + `, 'text/html'); + server.setContent('/api', '{}', 'application/json'); + await cli('open', server.PREFIX); + await cli('click', 'e2'); + + { + const { attachments } = await cli('network'); + expect(attachments[0].data.toString()).not.toContain('Request body:'); + } + + { + const { attachments } = await cli('network', '--request-body'); + expect(attachments[0].data.toString()).toContain(`[POST] ${server.PREFIX}/api => [200] OK`); + expect(attachments[0].data.toString()).toContain('Request body: {"key":"value"}'); + } +}); + +test('network --request-headers', async ({ cli, server }) => { + server.setContent('/', ` + + `, 'text/html'); + server.setContent('/api', '{}', 'application/json'); + await cli('open', server.PREFIX); + await cli('click', 'e2'); + + { + const { attachments } = await cli('network'); + expect(attachments[0].data.toString()).not.toContain('Request headers:'); + } + + { + const { attachments } = await cli('network', '--request-headers'); + expect(attachments[0].data.toString()).toContain(`[GET] ${server.PREFIX}/api => [200] OK`); + expect(attachments[0].data.toString()).toContain('Request headers:'); + expect(attachments[0].data.toString()).toContain('x-custom-header: test-value'); + } +}); + test('network --clear', async ({ cli, server }) => { await cli('open', server.PREFIX); await cli('eval', '() => fetch("/hello-world")'); diff --git a/tests/mcp/network.spec.ts b/tests/mcp/network.spec.ts index 9bfdbe337beb9..94ee4cad6e06f 100644 --- a/tests/mcp/network.spec.ts +++ b/tests/mcp/network.spec.ts @@ -52,7 +52,7 @@ test('browser_network_requests', async ({ client, server }) => { const response = parseResponse(await client.callTool({ name: 'browser_network_requests', arguments: { - includeStatic: true, + static: true, }, })); expect(response.result).toContain(`[GET] ${`${server.PREFIX}/`} => [200] OK`); @@ -60,3 +60,98 @@ test('browser_network_requests', async ({ client, server }) => { expect(response.result).toContain(`[GET] ${`${server.PREFIX}/image.png`} => [404]`); } }); + +test('browser_network_requests filter', async ({ client, server }) => { + server.setContent('/', ``, 'text/html'); + + await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + }); + + { + const response = parseResponse(await client.callTool({ + name: 'browser_network_requests', + arguments: { filter: '/api/', static: true }, + })); + expect(response.result).toContain(`${server.PREFIX}/api/users`); + expect(response.result).toContain(`${server.PREFIX}/api/orders`); + expect(response.result).not.toContain(`${server.PREFIX}/static/image.png`); + } +}); + +test('browser_network_requests includes request headers', async ({ client, server }) => { + server.setContent('/', ` + + `, 'text/html'); + server.setContent('/api', '{}', 'application/json'); + + await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + }); + + await client.callTool({ + name: 'browser_click', + arguments: { element: 'Click me button', ref: 'e2' }, + }); + + { + const response = parseResponse(await client.callTool({ + name: 'browser_network_requests', + })); + expect(response.result).not.toContain('Request headers:'); + } + + { + const response = parseResponse(await client.callTool({ + name: 'browser_network_requests', + arguments: { requestHeaders: true }, + })); + expect(response.result).toContain(`[GET] ${server.PREFIX}/api => [200] OK`); + expect(response.result).toContain('Request headers:'); + expect(response.result).toContain('x-custom-header: test-value'); + } +}); + +test('browser_network_requests includes request payload', async ({ client, server }) => { + server.setContent('/', ` + + `, 'text/html'); + + server.setContent('/api', '{}', 'application/json'); + + await client.callTool({ + name: 'browser_navigate', + arguments: { + url: server.PREFIX, + }, + }); + + await client.callTool({ + name: 'browser_click', + arguments: { + element: 'Click me button', + ref: 'e2', + }, + }); + + { + const response = parseResponse(await client.callTool({ + name: 'browser_network_requests', + })); + expect(response.result).toContain(`[POST] ${server.PREFIX}/api => [200] OK`); + expect(response.result).not.toContain(`Request body:`); + } + + { + const response = parseResponse(await client.callTool({ + name: 'browser_network_requests', + arguments: { requestBody: true }, + })); + expect(response.result).toContain(`[POST] ${server.PREFIX}/api => [200] OK`); + expect(response.result).toContain(`Request body: {"key":"value"}`); + } +});