/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import assert from 'node:assert'; import {mkdtemp, readFile, rm, writeFile} from 'node:fs/promises'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {afterEach, beforeEach, describe, it} from 'node:test'; import {NetworkFormatter} from '../../src/formatters/NetworkFormatter.js'; import type {HTTPRequest} from '../../src/third_party/index.js'; import {getMockRequest, getMockResponse} from '../utils.js'; describe('NetworkFormatter', () => { let tmpDir: string; beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'network-formatter-test-')); }); afterEach(async () => { await rm(tmpDir, {recursive: true, force: true}); }); describe('toString', () => { it('works', async () => { const request = getMockRequest(); const formatter = await NetworkFormatter.from(request, { requestId: 1, saveFile: async () => ({filename: ''}), redactNetworkHeaders: false, }); 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, saveFile: async () => ({filename: ''}), redactNetworkHeaders: false, }); 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, saveFile: async () => ({filename: ''}), redactNetworkHeaders: false, }); assert.equal( formatter.toString(), 'reqid=1 GET http://example.com [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, saveFile: async () => ({filename: ''}), redactNetworkHeaders: false, }); assert.equal( formatter.toString(), 'reqid=1 GET http://example.com [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, saveFile: async () => ({filename: ''}), redactNetworkHeaders: false, }); assert.equal( formatter.toString(), 'reqid=1 GET http://example.com [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, saveFile: async () => ({filename: ''}), redactNetworkHeaders: false, }); assert.equal( formatter.toString(), 'reqid=1 GET http://example.com [Error in Network]', ); }); it('marks requests selected in DevTools UI', async () => { const request = getMockRequest(); const formatter = await NetworkFormatter.from(request, { requestId: 1, selectedInDevToolsUI: true, saveFile: async () => ({filename: ''}), redactNetworkHeaders: false, }); 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, saveFile: async () => ({filename: ''}), redactNetworkHeaders: false, }); 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, saveFile: async () => ({filename: ''}), redactNetworkHeaders: false, }); 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, saveFile: async () => ({filename: ''}), redactNetworkHeaders: false, }); const result = formatter.toStringDetailed(); assert.match(result, /some text/); }); it('should save bodies to file when file paths are provided', async () => { const request = { method: () => 'POST', url: () => 'http://example.com', headers: () => ({}), hasPostData: () => true, postData: () => 'request body', response: () => ({ status: () => 200, headers: () => ({}), buffer: async () => Buffer.from('response body'), }), failure: () => null, redirectChain: () => [], fetchPostData: async () => undefined, } as unknown as HTTPRequest; const reqPath = join(tmpDir, 'test_req_' + Date.now()); const resPath = join(tmpDir, 'test_res_' + Date.now()); const formatter = await NetworkFormatter.from(request, { fetchData: true, requestFilePath: reqPath, responseFilePath: resPath, saveFile: async (data, filename) => { await writeFile(filename, data); return {filename}; }, redactNetworkHeaders: false, }); const json = formatter.toJSONDetailed() as { requestBody: string; responseBody: string; requestBodyFilePath: string; responseBodyFilePath: string; }; assert.strictEqual(json.requestBodyFilePath, reqPath); assert.strictEqual(json.responseBodyFilePath, resPath); assert.strictEqual(json.requestBody, undefined); assert.strictEqual(json.responseBody, undefined); }); it('should not truncate large bodies when saving to file', async () => { const largeBody = 'a'.repeat(10005); const request = { method: () => 'POST', url: () => 'http://example.com', headers: () => ({}), hasPostData: () => true, postData: () => largeBody, response: () => ({ status: () => 200, headers: () => ({}), buffer: async () => Buffer.from(largeBody), }), failure: () => null, redirectChain: () => [], fetchPostData: async () => undefined, } as unknown as HTTPRequest; const reqPath = join(tmpDir, 'test_req_large_' + Date.now()); const resPath = join(tmpDir, 'test_res_large_' + Date.now()); await NetworkFormatter.from(request, { fetchData: true, requestFilePath: reqPath, responseFilePath: resPath, saveFile: async (data, filename) => { await writeFile(filename, data); return {filename}; }, redactNetworkHeaders: false, }); const reqContent = await readFile(reqPath, 'utf8'); const resContent = await readFile(resPath, 'utf8'); assert.strictEqual(reqContent, largeBody); assert.strictEqual(resContent, largeBody); }); 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, saveFile: async () => ({filename: ''}), redactNetworkHeaders: false, }); const result = formatter.toStringDetailed(); assert.match(result, /"response":"body"/); }); it('handles redirect chain', async t => { const redirectRequest = getMockRequest({ url: 'http://example.com/redirect', }); const request = getMockRequest({ redirectChain: [redirectRequest], }); const formatter = await NetworkFormatter.from(request, { requestId: 1, requestIdResolver: () => 2, saveFile: async () => ({filename: ''}), redactNetworkHeaders: false, }); const result = formatter.toStringDetailed(); t.assert.snapshot(result); }); it('renders the redirect chain in the same order in text and JSON', async () => { // A >=2 element chain makes ordering observable (a single redirect hides // the bug). toStringDetailed() and toJSONDetailed() are emitted from the // same get_network_request call, so they must agree on the order. const first = getMockRequest({url: 'http://example.com/first'}); const second = getMockRequest({url: 'http://example.com/second'}); const request = getMockRequest({ url: 'http://example.com/final', redirectChain: [first, second], }); const formatter = await NetworkFormatter.from(request, { requestId: 1, requestIdResolver: () => 2, saveFile: async () => ({filename: ''}), redactNetworkHeaders: false, }); const text = formatter.toStringDetailed(); const json = formatter.toJSONDetailed(); const textOrder = [ ...text.matchAll(/http:\/\/example\.com\/(first|second)/g), ].map(m => m[0]); const jsonOrder = (json.redirectChain ?? []).map(entry => entry.url); assert.deepStrictEqual( textOrder, jsonOrder, `redirect chain order differs between text (${JSON.stringify(textOrder)}) ` + `and JSON (${JSON.stringify(jsonOrder)})`, ); }); it('shows saved to file message in toStringDetailed', async () => { const request = { method: () => 'POST', url: () => 'http://example.com', headers: () => ({}), hasPostData: () => true, postData: () => 'request body', response: () => ({ status: () => 200, headers: () => ({}), buffer: async () => Buffer.from('response body'), }), failure: () => null, redirectChain: () => [], fetchPostData: async () => undefined, } as unknown as HTTPRequest; const reqPath = join(tmpDir, 'req.txt'); const resPath = join(tmpDir, 'res.txt'); const formatter = await NetworkFormatter.from(request, { fetchData: true, requestFilePath: reqPath, responseFilePath: resPath, saveFile: async (data, filename) => { await writeFile(filename, data); return {filename}; }, redactNetworkHeaders: false, }); const result = formatter.toStringDetailed(); assert.ok(result.includes(`Saved to ${reqPath}.`)); assert.ok(result.includes(`Saved to ${resPath}.`)); }); it('handles missing bodies with filepath', async () => { const request = { method: () => 'POST', url: () => 'http://example.com', headers: () => ({}), hasPostData: () => true, // Claim we have data postData: () => null, // But returns null response: () => ({ status: () => 200, headers: () => ({}), buffer: async () => { throw new Error('Body not available'); }, }), failure: () => null, redirectChain: () => [], fetchPostData: async () => { throw new Error('Body not available'); }, } as unknown as HTTPRequest; const reqPath = join(tmpDir, 'req_missing.txt'); const resPath = join(tmpDir, 'res_missing.txt'); const formatter = await NetworkFormatter.from(request, { fetchData: true, requestFilePath: reqPath, responseFilePath: resPath, saveFile: async (data, filename) => { await writeFile(filename, data); return {filename}; }, redactNetworkHeaders: false, }); const result = formatter.toStringDetailed(); assert.ok( result.includes( `### Response Body\n`, ), ); }); }); describe('toJSON', () => { it('returns structured data', async () => { const request = getMockRequest(); const formatter = await NetworkFormatter.from(request, { requestId: 1, selectedInDevToolsUI: true, saveFile: async () => ({filename: ''}), redactNetworkHeaders: false, }); 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, saveFile: async () => ({filename: ''}), redactNetworkHeaders: false, }); const result = formatter.toJSONDetailed(); assert.deepEqual(result, { requestId: 1, method: 'GET', url: 'http://example.com', status: '200', selectedInDevToolsUI: undefined, requestHeaders: { 'content-size': '10', }, requestBody: 'request', requestBodyFilePath: undefined, responseHeaders: {}, responseBody: 'response', responseBodyFilePath: undefined, failure: undefined, redirectChain: undefined, }); }); it('redacts headers', async () => { const response = getMockResponse({ headers: { 'set-cookie': 'secret=123', 'content-type': 'text/plain', }, }); response.buffer = () => Promise.resolve(Buffer.from('response')); const request = getMockRequest({ response, headers: { cookie: 'secret=123', 'user-agent': 'test', }, }); const formatter = await NetworkFormatter.from(request, { requestId: 1, fetchData: true, saveFile: async () => ({filename: ''}), redactNetworkHeaders: true, }); const result = formatter.toJSONDetailed(); assert.deepEqual(result.requestHeaders, { cookie: '', 'user-agent': 'test', }); assert.deepEqual(result.responseHeaders, { 'set-cookie': '', 'content-type': 'text/plain', }); }); it('returns file paths in structured detailed data', async () => { const request = { method: () => 'POST', url: () => 'http://example.com', headers: () => ({}), hasPostData: () => true, postData: () => 'request body', response: () => ({ status: () => 200, headers: () => ({}), buffer: async () => Buffer.from('response body'), }), failure: () => null, redirectChain: () => [], fetchPostData: async () => undefined, } as unknown as HTTPRequest; const reqPath = join(tmpDir, 'req_json.txt'); const resPath = join(tmpDir, 'res_json.txt'); const formatter = await NetworkFormatter.from(request, { fetchData: true, requestFilePath: reqPath, responseFilePath: resPath, saveFile: async (data, filename) => { await writeFile(filename, data); return {filename}; }, redactNetworkHeaders: false, }); const result = formatter.toJSONDetailed() as { requestBodyFilePath: string; responseBodyFilePath: string; requestBody?: string; responseBody?: string; }; assert.strictEqual(result.requestBodyFilePath, reqPath); assert.strictEqual(result.responseBodyFilePath, resPath); assert.strictEqual(result.requestBody, undefined); assert.strictEqual(result.responseBody, undefined); }); }); });