From 9ff658f95d1a5afb7f4e8c96079c4f993519bc5d Mon Sep 17 00:00:00 2001 From: Devin Rousso Date: Thu, 23 Jul 2026 11:48:02 -0600 Subject: [PATCH] fix(har): exclude API requests from browser exports browser HAR recording listened to the context request client and mixed API calls with page traffic make API request listener registration opt out for browser HAR while preserving tracing and standalone request HAR --- .../src/server/har/harRecorder.ts | 3 +- .../src/server/har/harTracer.ts | 14 ++- tests/library/har.spec.ts | 115 ++---------------- tests/library/trace-viewer.spec.ts | 67 ++++++++++ 4 files changed, 90 insertions(+), 109 deletions(-) diff --git a/packages/playwright-core/src/server/har/harRecorder.ts b/packages/playwright-core/src/server/har/harRecorder.ts index 78ce1fe0f8d74..cedd3b4894b49 100644 --- a/packages/playwright-core/src/server/har/harRecorder.ts +++ b/packages/playwright-core/src/server/har/harRecorder.ts @@ -18,9 +18,9 @@ import path from 'path'; import { SerializedFS } from '@utils/serializedFS'; import { Artifact } from '../artifact'; +import { APIRequestContext } from '../fetch'; import { HarTracer } from './harTracer'; -import type { APIRequestContext } from '../fetch'; import type { BrowserContext } from '../browserContext'; import type { HarTracerDelegate } from './harTracer'; import type { Page } from '../page'; @@ -52,6 +52,7 @@ export class HarRecorder implements HarTracerDelegate { const content = options.content || 'embed'; this._tracer = new HarTracer(context, page, this, { content, + includeAPIRequests: context instanceof APIRequestContext, slimMode: options.mode === 'minimal', includeTraceInfo: false, recordRequestOverrides: true, diff --git a/packages/playwright-core/src/server/har/harTracer.ts b/packages/playwright-core/src/server/har/harTracer.ts index c888a33307450..4f61dd8ff9d12 100644 --- a/packages/playwright-core/src/server/har/harTracer.ts +++ b/packages/playwright-core/src/server/har/harTracer.ts @@ -50,6 +50,7 @@ export interface HarTracerDelegate { type HarTracerOptions = { content: 'omit' | 'attach' | 'embed'; + includeAPIRequests?: boolean; includeTraceInfo: boolean; recordRequestOverrides: boolean; waitForContentOnStop: boolean; @@ -105,11 +106,14 @@ export class HarTracer { return; this._options.omitScripts = options.omitScripts; this._started = true; - const apiRequest = this._context instanceof APIRequestContext ? this._context : this._context.fetchRequest; - this._eventListeners = [ - eventsHelper.addEventListener(apiRequest, APIRequestContext.Events.Request, (event: APIRequestEvent) => this._onAPIRequest(event)), - eventsHelper.addEventListener(apiRequest, APIRequestContext.Events.RequestFinished, (event: APIRequestFinishedEvent) => this._onAPIRequestFinished(event)), - ]; + this._eventListeners = []; + if (this._options.includeAPIRequests !== false) { + const apiRequest = this._context instanceof APIRequestContext ? this._context : this._context.fetchRequest; + this._eventListeners.push( + eventsHelper.addEventListener(apiRequest, APIRequestContext.Events.Request, (event: APIRequestEvent) => this._onAPIRequest(event)), + eventsHelper.addEventListener(apiRequest, APIRequestContext.Events.RequestFinished, (event: APIRequestFinishedEvent) => this._onAPIRequestFinished(event)), + ); + } if (this._context instanceof BrowserContext) { this._eventListeners.push( eventsHelper.addEventListener(this._context, BrowserContext.Events.Page, (page: Page) => this._createPageEntryIfNeeded(page)), diff --git a/tests/library/har.spec.ts b/tests/library/har.spec.ts index 143a3186b1dc0..caf92b160720f 100644 --- a/tests/library/har.spec.ts +++ b/tests/library/har.spec.ts @@ -642,7 +642,6 @@ it('should have security details', async ({ contextFactory, httpsServer, browser const { page, getLog } = await pageWithHar(contextFactory, testInfo); await page.goto(httpsServer.EMPTY_PAGE); - await page.request.get(httpsServer.EMPTY_PAGE); const log = await getLog(); const { serverIPAddress, _serverPort: port, _securityDetails: securityDetails } = log.entries[0]; expect(serverIPAddress).toMatch(/^127\.0\.0\.1|\[::1\]/); @@ -651,8 +650,6 @@ it('should have security details', async ({ contextFactory, httpsServer, browser expect(securityDetails).toEqual({ protocol: 'TLS 1.3', subjectName: 'playwright-test', validFrom: 1691708270, validTo: 2007068270 }); else expect(securityDetails).toEqual({ issuer: 'playwright-test', protocol: 'TLS 1.3', subjectName: 'playwright-test', validFrom: 1691708270, validTo: 2007068270 }); - - expect(log.entries[1]._securityDetails).toEqual({ issuer: 'playwright-test', protocol: 'TLSv1.3', subjectName: 'playwright-test', validFrom: 1691708270, validTo: 2007068270 }); }); it('should have connection details for redirects', async ({ contextFactory, server, browserName, mode }, testInfo) => { @@ -811,103 +808,12 @@ it('should have different hars for concurrent contexts', async ({ contextFactory } }); -it('should include API request', async ({ contextFactory, server }, testInfo) => { - const { page, getLog } = await pageWithHar(contextFactory, testInfo); - const url = server.PREFIX + '/simple.json'; - const response = await page.request.post(url, { - headers: { cookie: 'a=b; c=d' }, - data: { foo: 'bar' } - }); - const responseBody = await response.body(); - const log = await getLog(); - expect(log.entries.length).toBe(1); - const entry = log.entries[0]; - expect(entry.request.url).toBe(url); - expect(entry.request.method).toBe('POST'); - expect(entry.request.httpVersion).toBe('HTTP/1.1'); - expect(entry.request.cookies).toEqual([ - { - 'name': 'a', - 'value': 'b' - }, - { - 'name': 'c', - 'value': 'd' - } - ]); - expect(entry.request.headers.length).toBeGreaterThan(1); - expect(entry.request.headers.find(h => h.name.toLowerCase() === 'user-agent')).toBeTruthy(); - expect(entry.request.headers.find(h => h.name.toLowerCase() === 'content-type')?.value).toBe('application/json'); - expect(entry.request.headers.find(h => h.name.toLowerCase() === 'content-length')?.value).toBe('13'); - expect(entry.request.bodySize).toBe(13); - - expect(entry.response.status).toBe(200); - expect(entry.response.headers.find(h => h.name.toLowerCase() === 'content-type')?.value).toContain('application/json'); - expect(entry.response.content.size).toBe(15); - expect(entry.response.content.text).toBe(responseBody.toString()); - expect(entry.response.bodySize).toBe(15); - - expect(entry.time).toBeGreaterThan(0); - expect(entry.timings).toEqual(expect.objectContaining({ - blocked: -1, - connect: expect.any(Number), - dns: expect.any(Number), - receive: expect.any(Number), - send: expect.any(Number), - ssl: expect.any(Number), - wait: expect.any(Number), - })); - - expect(entry.serverIPAddress).toBeDefined(); - expect(entry._serverPort).toEqual(server.PORT); -}); - -it('should correctly record API request cookies with equals sign in value', async ({ contextFactory, server }, testInfo) => { - const { page, getLog } = await pageWithHar(contextFactory, testInfo); - const url = server.PREFIX + '/simple.json'; - await page.request.get(url, { - headers: { cookie: 'token=abc=xyz; other=val' }, - }); - const log = await getLog(); - expect(log.entries[0].request.cookies).toEqual([ - { name: 'token', value: 'abc=xyz' }, - { name: 'other', value: 'val' }, - ]); -}); - -it('should respect minimal mode for API Requests', async ({ contextFactory, server }, testInfo) => { - const { page, getLog } = await pageWithHar(contextFactory, testInfo, { mode: 'minimal' }); - const url = server.PREFIX + '/simple.json'; - await page.request.post(url, { - headers: { cookie: 'a=b; c=d' }, - data: { foo: 'bar' } - }); - const { entries } = await getLog(); - expect(entries).toHaveLength(1); - const [entry] = entries; - expect(entry.timings).toEqual({ receive: -1, send: -1, wait: -1 }); - expect(entry.serverIPAddress).toBeUndefined(); - expect(entry._serverPort).toBeUndefined(); - expect(entry.request.cookies).toEqual([]); - expect(entry.request.bodySize).toBe(-1); - expect(entry.response.bodySize).toBe(-1); -}); - -it('should include redirects from API request', async ({ contextFactory, server }, testInfo) => { - server.setRedirect('/redirect-me', '/simple.json'); +it('should exclude API requests', async ({ contextFactory, server }, testInfo) => { const { page, getLog } = await pageWithHar(contextFactory, testInfo); - await page.request.post(server.PREFIX + '/redirect-me', { - headers: { cookie: 'a=b; c=d' }, - data: { foo: 'bar' } - }); + await page.goto(server.EMPTY_PAGE); + await page.request.get(server.PREFIX + '/simple.json'); const log = await getLog(); - expect(log.entries.length).toBe(2); - const [redirect, json] = log.entries; - expect(redirect.request.url).toBe(server.PREFIX + '/redirect-me'); - expect(json.request.url).toBe(server.PREFIX + '/simple.json'); - - expect(redirect.timings).toBeDefined(); - expect(json.timings).toBeDefined(); + expect(log.entries.map(entry => entry.request.url)).toEqual([server.EMPTY_PAGE]); }); it('should not hang on resources served from cache', async ({ contextFactory, server, browserName, isBidi }, testInfo) => { @@ -965,13 +871,14 @@ it('should not hang on slow chunked response', async ({ browserName, browser, co expect(log.browser!.version).toBe(browser.version()); }); -it('should support HAR larger than 512MB', async ({ contextFactory, server, browserName }, testInfo) => { +it('should support HAR larger than 512MB', async ({ playwright, server, browserName }, testInfo) => { it.skip(browserName !== 'chromium', 'serializer is browser-agnostic; one browser is enough'); it.slow(); it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/36707' }); const harPath = testInfo.outputPath('test.har'); - const context = await contextFactory({ recordHar: { path: harPath } }); + const request = await playwright.request.newContext(); + await request.tracing.startHar(harPath); // 30 x 20MB textual responses push the HAR JSON past V8's ~512MB max // string length. Each body still fits in a single string; only the @@ -982,8 +889,9 @@ it('should support HAR larger than 512MB', async ({ contextFactory, server, brow res.end(body); }); for (let i = 0; i < 30; i++) - await context.request.get(`${server.PREFIX}/large`); - await context.close(); + await request.get(`${server.PREFIX}/large`); + await request.tracing.stopHar(); + await request.dispose(); const stats = fs.statSync(harPath); expect(stats.size).toBeGreaterThan(512 * 1024 * 1024); @@ -1057,12 +965,13 @@ it('should record resource type', async ({ contextFactory, server, asset }, test }); it.describe('tracing.startHar', () => { - it('should record a HAR with options', async ({ contextFactory, server }, testInfo) => { + it('should record a HAR with options and exclude API requests', async ({ contextFactory, server }, testInfo) => { const context = await contextFactory(); const harPath = testInfo.outputPath('tracing.har'); await context.tracing.startHar(harPath, { mode: 'minimal', urlFilter: '**/one-style.css' }); const page = await context.newPage(); await page.goto(server.PREFIX + '/one-style.html'); + await context.request.get(server.PREFIX + '/one-style.css?FAIL_IF_CAPTURED'); await context.tracing.stopHar(); await context.close(); diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts index fd023642c6b19..81d65b8e8cc1d 100644 --- a/tests/library/trace-viewer.spec.ts +++ b/tests/library/trace-viewer.spec.ts @@ -490,6 +490,73 @@ test('should have network requests', async ({ showTraceViewer }) => { await expect(traceViewer.networkRequests.filter({ hasText: '404GET404text' })).toHaveCSS('background-color', 'rgb(242, 222, 222)'); }); +test('should show context API request details', async ({ page, runAndTrace, server }) => { + server.setRoute('/api-details', (_, response) => { + setTimeout(() => { + response.writeHead(200, { + 'Content-Type': 'application/json', + 'X-Response': 'response-value', + }); + response.end(JSON.stringify({ result: 'ok' })); + }, 50); + }); + const traceViewer = await runAndTrace(async () => { + await page.request.post(server.PREFIX + '/api-details', { + headers: { + 'cookie': 'token=abc=xyz; other=val', + 'x-request': 'request-value', + }, + data: { foo: 'bar' }, + }); + }); + await traceViewer.selectAction('POST "/api-details"'); + await traceViewer.showNetworkTab(); + const request = traceViewer.networkRequests.filter({ hasText: 'api-details' }); + await expect(request).toHaveCount(1); + await expect(request.locator('.grid-view-column-method')).toHaveText('POST'); + await expect(request.locator('.grid-view-column-status')).toHaveText('200'); + await expect(request.locator('.grid-view-column-route')).toHaveText('api'); + expect(parseMillis(await request.locator('.grid-view-column-duration').innerText())).toBeGreaterThan(0); + + await request.click(); + const headersPanel = traceViewer.networkTab.getByRole('tabpanel', { name: 'Headers' }); + await expect(headersPanel.getByRole('region', { name: 'General' })).toContainText(server.PREFIX + '/api-details'); + await expect(headersPanel.getByRole('region', { name: 'General' })).toContainText(/MethodPOST/); + await expect(headersPanel.getByRole('region', { name: 'General' })).toContainText(/Status Code\s*200 OK/); + await expect(headersPanel.getByRole('region', { name: 'General' })).toContainText(/Duration\d+ms/); + const requestHeaders = headersPanel.getByRole('region', { name: /Request Headers/ }); + await expect(requestHeaders.getByText('token=abc=xyz; other=val')).toBeVisible(); + await expect(requestHeaders.getByText('request-value')).toBeVisible(); + await expect(requestHeaders.getByText('application/json', { exact: true })).toBeVisible(); + await expect(headersPanel.getByRole('region', { name: /Response Headers/ }).getByText('response-value')).toBeVisible(); + + await traceViewer.networkTab.getByRole('tab', { name: 'Payload' }).click(); + const payloadPanel = traceViewer.networkTab.getByRole('tabpanel', { name: 'Payload' }); + await expect(payloadPanel).toContainText('foo'); + await expect(payloadPanel).toContainText('bar'); + + await traceViewer.networkTab.getByRole('tab', { name: 'Response' }).click(); + const responsePanel = traceViewer.networkTab.getByRole('tabpanel', { name: 'Response' }); + await expect(responsePanel).toContainText('result'); + await expect(responsePanel).toContainText('ok'); +}); + +test('should show context API request redirects', async ({ page, runAndTrace, server }) => { + server.setRedirect('/api-redirect', '/simple.json'); + const traceViewer = await runAndTrace(async () => { + await page.request.get(server.PREFIX + '/api-redirect'); + }); + await traceViewer.selectAction('GET "/api-redirect"'); + await traceViewer.showNetworkTab(); + await expect(traceViewer.networkRequests).toHaveCount(2); + const redirect = traceViewer.networkRequests.filter({ hasText: 'api-redirect' }); + await expect(redirect.locator('.grid-view-column-status')).toHaveText('302'); + await expect(redirect.locator('.grid-view-column-route')).toHaveText('api'); + const response = traceViewer.networkRequests.filter({ hasText: 'simple.json' }); + await expect(response.locator('.grid-view-column-status')).toHaveText('200'); + await expect(response.locator('.grid-view-column-route')).toHaveText('api'); +}); + test('should highlight network request on timeline on hover', async ({ showTraceViewer }) => { const traceViewer = await showTraceViewer(traceFile); await traceViewer.selectAction('Navigate');