diff --git a/package-lock.json b/package-lock.json index f608c8c74..6b661090a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2709,9 +2709,9 @@ "license": "BSD-3-Clause" }, "node_modules/diff": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz", - "integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", "dev": true, "license": "BSD-3-Clause", "engines": { diff --git a/src/McpResponse.ts b/src/McpResponse.ts index 9c859d43b..17a8c098e 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -10,13 +10,7 @@ import { formatConsoleEventShort, formatConsoleEventVerbose, } from './formatters/consoleFormatter.js'; -import { - getFormattedHeaderValue, - getFormattedResponseBody, - getFormattedRequestBody, - getShortDescriptionForRequest, - getStatusFromRequest, -} from './formatters/networkFormatter.js'; +import {NetworkFormatter} from './formatters/NetworkFormatter.js'; import {SnapshotFormatter} from './formatters/SnapshotFormatter.js'; import type {McpContext} from './McpContext.js'; import {DevTools} from './third_party/index.js'; @@ -215,22 +209,17 @@ export class McpResponse implements Response { } } - const bodies: { - requestBody?: string; - responseBody?: string; - } = {}; - + let detailedNetworkRequest: NetworkFormatter | undefined; if (this.#attachedNetworkRequestId) { const request = context.getNetworkRequestById( this.#attachedNetworkRequestId, ); - - bodies.requestBody = await getFormattedRequestBody(request); - - const response = request.response(); - if (response) { - bodies.responseBody = await getFormattedResponseBody(response); - } + const formatter = await NetworkFormatter.from(request, { + requestId: this.#attachedNetworkRequestId, + requestIdResolver: req => context.getNetworkRequestStableId(req), + fetchData: true, + }); + detailedNetworkRequest = formatter; } let consoleData: ConsoleMessageData | undefined; @@ -341,11 +330,49 @@ export class McpResponse implements Response { ).filter(item => item !== null); } + let networkRequests: NetworkFormatter[] | undefined; + if (this.#networkRequestsOptions?.include) { + let requests = context.getNetworkRequests( + this.#networkRequestsOptions?.includePreservedRequests, + ); + + // Apply resource type filtering if specified + if (this.#networkRequestsOptions.resourceTypes?.length) { + const normalizedTypes = new Set( + this.#networkRequestsOptions.resourceTypes, + ); + requests = requests.filter(request => { + const type = request.resourceType(); + return normalizedTypes.has(type); + }); + } + + if (requests.length) { + const data = this.#dataWithPagination( + requests, + this.#networkRequestsOptions.pagination, + ); + + networkRequests = await Promise.all( + data.items.map(request => + NetworkFormatter.from(request, { + requestId: context.getNetworkRequestStableId(request), + selectedInDevToolsUI: + context.getNetworkRequestStableId(request) === + this.#networkRequestsOptions?.networkRequestIdInDevToolsUI, + fetchData: false, + }), + ), + ); + } + } + return this.format(toolName, context, { - bodies, consoleData, consoleListData, snapshot, + detailedNetworkRequest, + networkRequests, }); } @@ -353,13 +380,11 @@ export class McpResponse implements Response { toolName: string, context: McpContext, data: { - bodies: { - requestBody?: string; - responseBody?: string; - }; consoleData: ConsoleMessageData | undefined; consoleListData: ConsoleMessageData[] | undefined; snapshot: SnapshotFormatter | string | undefined; + detailedNetworkRequest?: NetworkFormatter; + networkRequests?: NetworkFormatter[]; }, ): {content: Array; structuredContent: object} { const response = [`# ${toolName} response`]; @@ -407,6 +432,8 @@ Call ${handleDialog.name} to handle it before continuing.`); snapshot?: object; snapshotFilePath?: string; tabId?: string; + networkRequest?: object; + networkRequests?: object[]; } = {}; if (this.#tabId) { @@ -424,7 +451,11 @@ Call ${handleDialog.name} to handle it before continuing.`); } } - response.push(...this.#formatNetworkRequestData(context, data.bodies)); + if (data.detailedNetworkRequest) { + response.push(data.detailedNetworkRequest.toStringDetailed()); + structuredContent.networkRequest = + data.detailedNetworkRequest.toJSONDetailed(); + } response.push(...this.#formatConsoleData(context, data.consoleData)); if (this.#networkRequestsOptions?.include) { @@ -445,20 +476,17 @@ Call ${handleDialog.name} to handle it before continuing.`); response.push('## Network requests'); if (requests.length) { - const data = this.#dataWithPagination( + const paginationData = this.#dataWithPagination( requests, this.#networkRequestsOptions.pagination, ); - response.push(...data.info); - for (const request of data.items) { - response.push( - getShortDescriptionForRequest( - request, - context.getNetworkRequestStableId(request), - context.getNetworkRequestStableId(request) === - this.#networkRequestsOptions?.networkRequestIdInDevToolsUI, - ), - ); + response.push(...paginationData.info); + if (data.networkRequests) { + structuredContent.networkRequests = []; + for (const formatter of data.networkRequests) { + response.push(formatter.toString()); + structuredContent.networkRequests.push(formatter.toJSON()); + } } } else { response.push('No requests found.'); @@ -539,65 +567,6 @@ Call ${handleDialog.name} to handle it before continuing.`); return response; } - #formatNetworkRequestData( - context: McpContext, - data: { - requestBody?: string; - responseBody?: string; - }, - ): string[] { - const response: string[] = []; - const id = this.#attachedNetworkRequestId; - if (!id) { - return response; - } - - const httpRequest = context.getNetworkRequestById(id); - response.push(`## Request ${httpRequest.url()}`); - response.push(`Status: ${getStatusFromRequest(httpRequest)}`); - response.push(`### Request Headers`); - for (const line of getFormattedHeaderValue(httpRequest.headers())) { - response.push(line); - } - - if (data.requestBody) { - response.push(`### Request Body`); - response.push(data.requestBody); - } - - const httpResponse = httpRequest.response(); - if (httpResponse) { - response.push(`### Response Headers`); - for (const line of getFormattedHeaderValue(httpResponse.headers())) { - response.push(line); - } - } - - if (data.responseBody) { - response.push(`### Response Body`); - response.push(data.responseBody); - } - - const httpFailure = httpRequest.failure(); - if (httpFailure) { - response.push(`### Request failed with`); - response.push(httpFailure.errorText); - } - - const redirectChain = httpRequest.redirectChain(); - if (redirectChain.length) { - response.push(`### Redirect chain`); - let indent = 0; - for (const request of redirectChain.reverse()) { - response.push( - `${' '.repeat(indent)}${getShortDescriptionForRequest(request, context.getNetworkRequestStableId(request))}`, - ); - indent++; - } - } - return response; - } - resetResponseLineForTesting() { this.#textResponseLines = []; } diff --git a/src/formatters/NetworkFormatter.ts b/src/formatters/NetworkFormatter.ts new file mode 100644 index 000000000..001a5fec5 --- /dev/null +++ b/src/formatters/NetworkFormatter.ts @@ -0,0 +1,225 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + * */ + +import {isUtf8} from 'node:buffer'; + +import type {HTTPRequest, HTTPResponse} from '../third_party/index.js'; + +const BODY_CONTEXT_SIZE_LIMIT = 10000; + +export interface NetworkFormatterOptions { + requestId?: number | string; + selectedInDevToolsUI?: boolean; + requestIdResolver?: (request: HTTPRequest) => number | string; + fetchData?: boolean; +} + +export class NetworkFormatter { + #request: HTTPRequest; + #options: NetworkFormatterOptions; + #requestBody?: string; + #responseBody?: string; + + private constructor( + request: HTTPRequest, + options: NetworkFormatterOptions = {}, + ) { + this.#request = request; + this.#options = options; + } + + static async from( + request: HTTPRequest, + options: NetworkFormatterOptions = {}, + ): Promise { + const instance = new NetworkFormatter(request, options); + if (options.fetchData) { + await instance.#loadDetailedData(); + } + return instance; + } + + async #loadDetailedData(): Promise { + // Load Request Body + if (this.#request.hasPostData()) { + const data = this.#request.postData(); + if (data) { + this.#requestBody = getSizeLimitedString(data, BODY_CONTEXT_SIZE_LIMIT); + } else { + try { + const fetchData = await this.#request.fetchPostData(); + if (fetchData) { + this.#requestBody = getSizeLimitedString( + fetchData, + BODY_CONTEXT_SIZE_LIMIT, + ); + } + } catch { + this.#requestBody = ''; + } + } + } + + // Load Response Body + const response = this.#request.response(); + if (response) { + this.#responseBody = await this.#getFormattedResponseBody( + response, + BODY_CONTEXT_SIZE_LIMIT, + ); + } + } + + toString(): string { + // TODO truncate the URL + return `reqid=${this.#options.requestId} ${this.#request.method()} ${this.#request.url()} ${this.#getStatusFromRequest(this.#request)}${this.#options.selectedInDevToolsUI ? ` [selected in the DevTools Network panel]` : ''}`; + } + + toStringDetailed(): string { + const response: string[] = []; + response.push(`## Request ${this.#request.url()}`); + response.push(`Status: ${this.#getStatusFromRequest(this.#request)}`); + response.push(`### Request Headers`); + for (const line of this.#getFormattedHeaderValue(this.#request.headers())) { + response.push(line); + } + + if (this.#requestBody) { + response.push(`### Request Body`); + response.push(this.#requestBody); + } + + const httpResponse = this.#request.response(); + if (httpResponse) { + response.push(`### Response Headers`); + for (const line of this.#getFormattedHeaderValue( + httpResponse.headers(), + )) { + response.push(line); + } + } + + if (this.#responseBody) { + response.push(`### Response Body`); + response.push(this.#responseBody); + } + + const httpFailure = this.#request.failure(); + if (httpFailure) { + response.push(`### Request failed with`); + response.push(httpFailure.errorText); + } + + const redirectChain = this.#request.redirectChain(); + if (redirectChain.length) { + response.push(`### Redirect chain`); + let indent = 0; + for (const request of redirectChain.reverse()) { + const id = this.#options.requestIdResolver + ? this.#options.requestIdResolver(request) + : undefined; + // We create a temporary synchronous instance just for toString + const formatter = new NetworkFormatter(request, { + requestId: id, + }); + response.push(`${' '.repeat(indent)}${formatter.toString()}`); + indent++; + } + } + return response.join('\n'); + } + + toJSON(): object { + return { + requestId: this.#options.requestId, + method: this.#request.method(), + url: this.#request.url(), + status: this.#getStatusFromRequest(this.#request), + selectedInDevToolsUI: this.#options.selectedInDevToolsUI, + }; + } + + toJSONDetailed(): object { + const redirectChain = this.#request.redirectChain(); + const formattedRedirectChain = redirectChain.reverse().map(request => { + const id = this.#options.requestIdResolver + ? this.#options.requestIdResolver(request) + : undefined; + const formatter = new NetworkFormatter(request, { + requestId: id, + }); + return formatter.toJSON(); + }); + + return { + ...this.toJSON(), + requestHeaders: this.#request.headers(), + requestBody: this.#requestBody, + responseHeaders: this.#request.response()?.headers(), + responseBody: this.#responseBody, + failure: this.#request.failure()?.errorText, + redirectChain: formattedRedirectChain.length + ? formattedRedirectChain + : undefined, + }; + } + + #getStatusFromRequest(request: HTTPRequest): string { + const httpResponse = request.response(); + const failure = request.failure(); + let status: string; + if (httpResponse) { + const responseStatus = httpResponse.status(); + status = + responseStatus >= 200 && responseStatus <= 299 + ? `[success - ${responseStatus}]` + : `[failed - ${responseStatus}]`; + } else if (failure) { + status = `[failed - ${failure.errorText}]`; + } else { + status = '[pending]'; + } + return status; + } + + #getFormattedHeaderValue(headers: Record): string[] { + const response: string[] = []; + for (const [name, value] of Object.entries(headers)) { + response.push(`- ${name}:${value}`); + } + return response; + } + + async #getFormattedResponseBody( + httpResponse: HTTPResponse, + sizeLimit = BODY_CONTEXT_SIZE_LIMIT, + ): Promise { + try { + const responseBuffer = await httpResponse.buffer(); + + if (isUtf8(responseBuffer)) { + const responseAsTest = responseBuffer.toString('utf-8'); + + if (responseAsTest.length === 0) { + return ''; + } + + return getSizeLimitedString(responseAsTest, sizeLimit); + } + + return ''; + } catch { + return ''; + } + } +} + +function getSizeLimitedString(text: string, sizeLimit: number) { + if (text.length > sizeLimit) { + return text.substring(0, sizeLimit) + '... '; + } + return text; +} diff --git a/src/formatters/networkFormatter.ts b/src/formatters/networkFormatter.ts deleted file mode 100644 index 74ba11842..000000000 --- a/src/formatters/networkFormatter.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {isUtf8} from 'node:buffer'; - -import type {HTTPRequest, HTTPResponse} from '../third_party/index.js'; - -const BODY_CONTEXT_SIZE_LIMIT = 10000; - -export function getShortDescriptionForRequest( - request: HTTPRequest, - id: number, - selectedInDevToolsUI = false, -): string { - // TODO truncate the URL - return `reqid=${id} ${request.method()} ${request.url()} ${getStatusFromRequest(request)}${selectedInDevToolsUI ? ` [selected in the DevTools Network panel]` : ''}`; -} - -export function getStatusFromRequest(request: HTTPRequest): string { - const httpResponse = request.response(); - const failure = request.failure(); - let status: string; - if (httpResponse) { - const responseStatus = httpResponse.status(); - status = - responseStatus >= 200 && responseStatus <= 299 - ? `[success - ${responseStatus}]` - : `[failed - ${responseStatus}]`; - } else if (failure) { - status = `[failed - ${failure.errorText}]`; - } else { - status = '[pending]'; - } - return status; -} - -export function getFormattedHeaderValue( - headers: Record, -): string[] { - const response: string[] = []; - for (const [name, value] of Object.entries(headers)) { - response.push(`- ${name}:${value}`); - } - return response; -} - -export async function getFormattedResponseBody( - httpResponse: HTTPResponse, - sizeLimit = BODY_CONTEXT_SIZE_LIMIT, -): Promise { - try { - const responseBuffer = await httpResponse.buffer(); - - if (isUtf8(responseBuffer)) { - const responseAsTest = responseBuffer.toString('utf-8'); - - if (responseAsTest.length === 0) { - return ``; - } - - return `${getSizeLimitedString(responseAsTest, sizeLimit)}`; - } - - return ``; - } catch { - return ``; - } -} - -export async function getFormattedRequestBody( - httpRequest: HTTPRequest, - sizeLimit: number = BODY_CONTEXT_SIZE_LIMIT, -): Promise { - if (httpRequest.hasPostData()) { - const data = httpRequest.postData(); - - if (data) { - return `${getSizeLimitedString(data, sizeLimit)}`; - } - - try { - const fetchData = await httpRequest.fetchPostData(); - - if (fetchData) { - return `${getSizeLimitedString(fetchData, sizeLimit)}`; - } - } catch { - return ``; - } - } - - return; -} - -function getSizeLimitedString(text: string, sizeLimit: number) { - if (text.length > sizeLimit) { - return `${text.substring(0, sizeLimit) + '... '}`; - } - - return `${text}`; -} diff --git a/tests/McpContext.test.js.snapshot b/tests/McpContext.test.js.snapshot new file mode 100644 index 000000000..1d3fe137b --- /dev/null +++ b/tests/McpContext.test.js.snapshot @@ -0,0 +1,27 @@ +exports[`McpContext > should include detailed network request in structured content 1`] = ` +{ + "networkRequest": { + "requestId": 456, + "method": "GET", + "url": "http://example.com/detail", + "status": "[pending]", + "requestHeaders": { + "content-size": "10" + } + } +} +`; + +exports[`McpContext > should include network requests in structured content 1`] = ` +{ + "networkRequests": [ + { + "requestId": 123, + "method": "GET", + "url": "http://example.com/api", + "status": "[pending]", + "selectedInDevToolsUI": false + } + ] +} +`; diff --git a/tests/McpContext.test.ts b/tests/McpContext.test.ts index e1c34f52c..cd143f000 100644 --- a/tests/McpContext.test.ts +++ b/tests/McpContext.test.ts @@ -11,7 +11,7 @@ import sinon from 'sinon'; import type {TraceResult} from '../src/trace-processing/parse.js'; -import {html, withMcpContext} from './utils.js'; +import {getMockRequest, html, withMcpContext} from './utils.js'; describe('McpContext', () => { it('list pages', async () => { @@ -101,4 +101,37 @@ describe('McpContext', () => { }, ); }); + it('should include network requests in structured content', async t => { + await withMcpContext(async (response, context) => { + const mockRequest = getMockRequest({ + url: 'http://example.com/api', + stableId: 123, + }); + + sinon.stub(context, 'getNetworkRequests').returns([mockRequest]); + sinon.stub(context, 'getNetworkRequestStableId').returns(123); + + response.setIncludeNetworkRequests(true); + const result = await response.handle('test', context); + + t.assert.snapshot?.(JSON.stringify(result.structuredContent, null, 2)); + }); + }); + + it('should include detailed network request in structured content', async t => { + await withMcpContext(async (response, context) => { + const mockRequest = getMockRequest({ + url: 'http://example.com/detail', + stableId: 456, + }); + + sinon.stub(context, 'getNetworkRequestById').returns(mockRequest); + sinon.stub(context, 'getNetworkRequestStableId').returns(456); + + response.attachNetworkRequest(456); + const result = await response.handle('test', context); + + t.assert.snapshot?.(JSON.stringify(result.structuredContent, null, 2)); + }); + }); }); diff --git a/tests/formatters/NetworkFormatter.test.ts b/tests/formatters/NetworkFormatter.test.ts new file mode 100644 index 000000000..aa8f26618 --- /dev/null +++ b/tests/formatters/NetworkFormatter.test.ts @@ -0,0 +1,230 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import {describe, it} from 'node:test'; + +import {NetworkFormatter} from '../../src/formatters/NetworkFormatter.js'; +import {getMockRequest, getMockResponse} from '../utils.js'; + +describe('NetworkFormatter', () => { + describe('toString', () => { + it('works', async () => { + const request = getMockRequest(); + const formatter = await NetworkFormatter.from(request, {requestId: 1}); + + assert.equal( + formatter.toString(), + 'reqid=1 GET http://example.com [pending]', + ); + }); + it('shows correct method', async () => { + const request = getMockRequest({method: 'POST'}); + const formatter = await NetworkFormatter.from(request, {requestId: 1}); + + assert.equal( + formatter.toString(), + 'reqid=1 POST http://example.com [pending]', + ); + }); + it('shows correct status for request with response code in 200', async () => { + const response = getMockResponse(); + const request = getMockRequest({response}); + const formatter = await NetworkFormatter.from(request, {requestId: 1}); + + assert.equal( + formatter.toString(), + 'reqid=1 GET http://example.com [success - 200]', + ); + }); + it('shows correct status for request with response code in 100', async () => { + const response = getMockResponse({ + status: 199, + }); + const request = getMockRequest({response}); + const formatter = await NetworkFormatter.from(request, {requestId: 1}); + + assert.equal( + formatter.toString(), + 'reqid=1 GET http://example.com [failed - 199]', + ); + }); + it('shows correct status for request with response code above 200', async () => { + const response = getMockResponse({ + status: 300, + }); + const request = getMockRequest({response}); + const formatter = await NetworkFormatter.from(request, {requestId: 1}); + + assert.equal( + formatter.toString(), + 'reqid=1 GET http://example.com [failed - 300]', + ); + }); + it('shows correct status for request that failed', async () => { + const request = getMockRequest({ + failure() { + return { + errorText: 'Error in Network', + }; + }, + }); + const formatter = await NetworkFormatter.from(request, {requestId: 1}); + + assert.equal( + formatter.toString(), + 'reqid=1 GET http://example.com [failed - Error in Network]', + ); + }); + + it('marks requests selected in DevTools UI', async () => { + const request = getMockRequest(); + const formatter = await NetworkFormatter.from(request, { + requestId: 1, + selectedInDevToolsUI: true, + }); + + assert.equal( + formatter.toString(), + 'reqid=1 GET http://example.com [pending] [selected in the DevTools Network panel]', + ); + }); + }); + + describe('toStringDetailed', () => { + it('works with request body from fetchPostData', async () => { + const request = getMockRequest({ + hasPostData: true, + postData: undefined, + fetchPostData: Promise.resolve('test'), + }); + const formatter = await NetworkFormatter.from(request, { + requestId: 200, + fetchData: true, + }); + const result = formatter.toStringDetailed(); + assert.match(result, /test/); + }); + + it('works with request body from postData', async () => { + const request = getMockRequest({ + postData: JSON.stringify({ + request: 'body', + }), + hasPostData: true, + }); + const formatter = await NetworkFormatter.from(request, { + requestId: 200, + fetchData: true, + }); + const result = formatter.toStringDetailed(); + + assert.match( + result, + new RegExp( + JSON.stringify({ + request: 'body', + }), + ), + ); + }); + + it('truncates request body', async () => { + const request = getMockRequest({ + postData: 'some text that is longer than expected', + hasPostData: true, + }); + const formatter = await NetworkFormatter.from(request, { + requestId: 20, + fetchData: true, + }); + const result = formatter.toStringDetailed(); + assert.match(result, /some text/); + }); + + it('handles response body', async () => { + const response = getMockResponse(); + response.buffer = () => { + return Promise.resolve(Buffer.from(JSON.stringify({response: 'body'}))); + }; + const request = getMockRequest({response}); + + const formatter = await NetworkFormatter.from(request, { + requestId: 200, + fetchData: true, + }); + const result = formatter.toStringDetailed(); + + assert.match(result, /"response":"body"/); + }); + + it('handles redirect chain', async () => { + const redirectRequest = getMockRequest({ + url: 'http://example.com/redirect', + }); + const request = getMockRequest({ + redirectChain: [redirectRequest], + }); + const formatter = await NetworkFormatter.from(request, { + requestId: 1, + requestIdResolver: () => 2, + }); + const result = formatter.toStringDetailed(); + assert.match(result, /Redirect chain/); + assert.match(result, /reqid=2/); + }); + }); + + describe('toJSON', () => { + it('returns structured data', async () => { + const request = getMockRequest(); + const formatter = await NetworkFormatter.from(request, { + requestId: 1, + selectedInDevToolsUI: true, + }); + const result = formatter.toJSON(); + assert.deepEqual(result, { + requestId: 1, + method: 'GET', + url: 'http://example.com', + status: '[pending]', + selectedInDevToolsUI: true, + }); + }); + }); + + describe('toJSONDetailed', () => { + it('returns structured detailed data', async () => { + const response = getMockResponse(); + response.buffer = () => Promise.resolve(Buffer.from('response')); + const request = getMockRequest({ + response, + postData: 'request', + hasPostData: true, + }); + const formatter = await NetworkFormatter.from(request, { + requestId: 1, + fetchData: true, + }); + const result = formatter.toJSONDetailed(); + assert.deepEqual(result, { + requestId: 1, + method: 'GET', + url: 'http://example.com', + status: '[success - 200]', + selectedInDevToolsUI: undefined, + requestHeaders: { + 'content-size': '10', + }, + requestBody: 'request', + responseHeaders: {}, + responseBody: 'response', + failure: undefined, + redirectChain: undefined, + }); + }); + }); +}); diff --git a/tests/formatters/networkFormatter.test.ts b/tests/formatters/networkFormatter.test.ts deleted file mode 100644 index b6edd56ad..000000000 --- a/tests/formatters/networkFormatter.test.ts +++ /dev/null @@ -1,248 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import assert from 'node:assert'; -import {describe, it} from 'node:test'; - -import {ProtocolError} from 'puppeteer-core'; - -import { - getFormattedHeaderValue, - getFormattedRequestBody, - getFormattedResponseBody, - getShortDescriptionForRequest, -} from '../../src/formatters/networkFormatter.js'; -import {getMockRequest, getMockResponse} from '../utils.js'; - -describe('networkFormatter', () => { - describe('getShortDescriptionForRequest', () => { - it('works', async () => { - const request = getMockRequest(); - const result = getShortDescriptionForRequest(request, 1); - - assert.equal(result, 'reqid=1 GET http://example.com [pending]'); - }); - it('shows correct method', async () => { - const request = getMockRequest({method: 'POST'}); - const result = getShortDescriptionForRequest(request, 1); - - assert.equal(result, 'reqid=1 POST http://example.com [pending]'); - }); - it('shows correct status for request with response code in 200', async () => { - const response = getMockResponse(); - const request = getMockRequest({response}); - const result = getShortDescriptionForRequest(request, 1); - - assert.equal(result, 'reqid=1 GET http://example.com [success - 200]'); - }); - it('shows correct status for request with response code in 100', async () => { - const response = getMockResponse({ - status: 199, - }); - const request = getMockRequest({response}); - const result = getShortDescriptionForRequest(request, 1); - - assert.equal(result, 'reqid=1 GET http://example.com [failed - 199]'); - }); - it('shows correct status for request with response code above 200', async () => { - const response = getMockResponse({ - status: 300, - }); - const request = getMockRequest({response}); - const result = getShortDescriptionForRequest(request, 1); - - assert.equal(result, 'reqid=1 GET http://example.com [failed - 300]'); - }); - it('shows correct status for request that failed', async () => { - const request = getMockRequest({ - failure() { - return { - errorText: 'Error in Network', - }; - }, - }); - const result = getShortDescriptionForRequest(request, 1); - - assert.equal( - result, - 'reqid=1 GET http://example.com [failed - Error in Network]', - ); - }); - - it('marks requests selected in DevTools UI', async () => { - const request = getMockRequest(); - const result = getShortDescriptionForRequest(request, 1, true); - - assert.equal( - result, - 'reqid=1 GET http://example.com [pending] [selected in the DevTools Network panel]', - ); - }); - }); - - describe('getFormattedHeaderValue', () => { - it('works', () => { - const result = getFormattedHeaderValue({ - key: 'value', - }); - - assert.deepEqual(result, ['- key:value']); - }); - it('with multiple', () => { - const result = getFormattedHeaderValue({ - key: 'value', - key2: 'value2', - key3: 'value3', - key4: 'value4', - }); - - assert.deepEqual(result, [ - '- key:value', - '- key2:value2', - '- key3:value3', - '- key4:value4', - ]); - }); - it('with non', () => { - const result = getFormattedHeaderValue({}); - - assert.deepEqual(result, []); - }); - }); - - describe('getFormattedRequestBody', () => { - it('shows data from fetchPostData if postData is undefined', async () => { - const request = getMockRequest({ - hasPostData: true, - postData: undefined, - fetchPostData: Promise.resolve('test'), - }); - - const result = await getFormattedRequestBody(request, 200); - - assert.strictEqual(result, 'test'); - }); - it('shows not available when no postData available', async () => { - const request = getMockRequest({ - hasPostData: false, - }); - - const result = await getFormattedRequestBody(request, 200); - - assert.strictEqual(result, undefined); - }); - it('shows request body when postData is available', async () => { - const request = getMockRequest({ - postData: JSON.stringify({ - request: 'body', - }), - hasPostData: true, - }); - - const result = await getFormattedRequestBody(request, 200); - - assert.strictEqual( - result, - `${JSON.stringify({ - request: 'body', - })}`, - ); - }); - it('shows trunkated string correctly with postData', async () => { - const request = getMockRequest({ - postData: 'some text that is longer than expected', - hasPostData: true, - }); - - const result = await getFormattedRequestBody(request, 20); - - assert.strictEqual(result, 'some text that is lo... '); - }); - it('shows trunkated string correctly with fetchPostData', async () => { - const request = getMockRequest({ - fetchPostData: Promise.resolve( - 'some text that is longer than expected', - ), - postData: undefined, - hasPostData: true, - }); - - const result = await getFormattedRequestBody(request, 20); - - assert.strictEqual(result, 'some text that is lo... '); - }); - it('shows not available on exception', async () => { - const request = getMockRequest({ - hasPostData: true, - postData: undefined, - fetchPostData: Promise.reject(new ProtocolError()), - }); - - const result = await getFormattedRequestBody(request, 200); - - assert.strictEqual(result, ''); - }); - }); - - describe('getFormattedResponseBody', () => { - it('handles empty buffer correctly', async () => { - const response = getMockResponse(); - response.buffer = () => { - return Promise.resolve(Buffer.from('')); - }; - - const result = await getFormattedResponseBody(response, 200); - - assert.strictEqual(result, ''); - }); - it('handles base64 text correctly', async () => { - const binaryBuffer = Buffer.from([ - 0xde, 0xad, 0xbe, 0xef, 0x00, 0x41, 0x42, 0x43, - ]); - const response = getMockResponse(); - response.buffer = () => { - return Promise.resolve(binaryBuffer); - }; - - const result = await getFormattedResponseBody(response, 200); - - assert.strictEqual(result, ''); - }); - it('handles the text limit correctly', async () => { - const response = getMockResponse(); - response.buffer = () => { - return Promise.resolve( - Buffer.from('some text that is longer than expected'), - ); - }; - - const result = await getFormattedResponseBody(response, 20); - - assert.strictEqual(result, 'some text that is lo... '); - }); - it('handles the text format correctly', async () => { - const response = getMockResponse(); - response.buffer = () => { - return Promise.resolve(Buffer.from(JSON.stringify({response: 'body'}))); - }; - - const result = await getFormattedResponseBody(response, 200); - - assert.strictEqual(result, `${JSON.stringify({response: 'body'})}`); - }); - it('handles error correctly', async () => { - const response = getMockResponse(); - response.buffer = () => { - // CDP Error simulation - return Promise.reject(new ProtocolError()); - }; - - const result = await getFormattedResponseBody(response, 200); - - assert.strictEqual(result, ''); - }); - }); -}); diff --git a/tests/utils.ts b/tests/utils.ts index 1c73fc5be..f571ee721 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -101,6 +101,7 @@ export async function withMcpContext( export function getMockRequest( options: { + url?: string; method?: string; response?: HTTPResponse; failure?: HTTPRequest['failure']; @@ -111,11 +112,12 @@ export function getMockRequest( stableId?: number; navigationRequest?: boolean; frame?: Frame; + redirectChain?: HTTPRequest[]; } = {}, ): HTTPRequest { return { url() { - return 'http://example.com'; + return options.url ?? 'http://example.com'; }, method() { return options.method ?? 'GET'; @@ -144,7 +146,7 @@ export function getMockRequest( }; }, redirectChain(): HTTPRequest[] { - return []; + return options.redirectChain ?? []; }, isNavigationRequest() { return options.navigationRequest ?? false; @@ -165,6 +167,9 @@ export function getMockResponse( status() { return options.status ?? 200; }, + headers(): Record { + return {}; + }, } as HTTPResponse; }