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');