diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index cd61469049..e0515d3f3e 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -52,6 +52,9 @@ export abstract class BrowserContext extends SdkObject { Response: 'response', RequestFailed: 'requestfailed', RequestFinished: 'requestfinished', + RequestAborted: 'requestaborted', + RequestFulfilled: 'requestfulfilled', + RequestContinued: 'requestcontinued', BeforeClose: 'beforeclose', VideoStarted: 'videostarted', }; diff --git a/packages/playwright-core/src/server/har/harTracer.ts b/packages/playwright-core/src/server/har/harTracer.ts index 09b175e9bd..9d224914c1 100644 --- a/packages/playwright-core/src/server/har/harTracer.ts +++ b/packages/playwright-core/src/server/har/harTracer.ts @@ -101,7 +101,11 @@ export class HarTracer { eventsHelper.addEventListener(this._context, BrowserContext.Events.Request, (request: network.Request) => this._onRequest(request)), eventsHelper.addEventListener(this._context, BrowserContext.Events.RequestFinished, ({ request, response }) => this._onRequestFinished(request, response).catch(() => {})), eventsHelper.addEventListener(this._context, BrowserContext.Events.RequestFailed, request => this._onRequestFailed(request)), - eventsHelper.addEventListener(this._context, BrowserContext.Events.Response, (response: network.Response) => this._onResponse(response))); + eventsHelper.addEventListener(this._context, BrowserContext.Events.Response, (response: network.Response) => this._onResponse(response)), + eventsHelper.addEventListener(this._context, BrowserContext.Events.RequestAborted, request => this._onRequestAborted(request)), + eventsHelper.addEventListener(this._context, BrowserContext.Events.RequestFulfilled, request => this._onRequestFulfilled(request)), + eventsHelper.addEventListener(this._context, BrowserContext.Events.RequestContinued, request => this._onRequestContinued(request)), + ); } } @@ -377,6 +381,24 @@ export class HarTracer { this._delegate.onEntryFinished(harEntry); } + private _onRequestAborted(request: network.Request) { + const harEntry = this._entryForRequest(request); + if (harEntry) + harEntry._wasAborted = true; + } + + private _onRequestFulfilled(request: network.Request) { + const harEntry = this._entryForRequest(request); + if (harEntry) + harEntry._wasFulfilled = true; + } + + private _onRequestContinued(request: network.Request) { + const harEntry = this._entryForRequest(request); + if (harEntry) + harEntry._wasContinued = true; + } + private _storeResponseContent(buffer: Buffer | undefined, content: har.Content, resourceType: string) { if (!buffer) { content.size = 0; diff --git a/packages/playwright-core/src/server/network.ts b/packages/playwright-core/src/server/network.ts index 8d74f93a5b..e9e4b1ee2d 100644 --- a/packages/playwright-core/src/server/network.ts +++ b/packages/playwright-core/src/server/network.ts @@ -25,6 +25,7 @@ import { SdkObject } from './instrumentation'; import type { HeadersArray, NameValue } from '../common/types'; import { APIRequestContext } from './fetch'; import type { NormalizedContinueOverrides } from './types'; +import { BrowserContext } from './browserContext'; export function filterCookies(cookies: channels.NetworkCookie[], urls: string[]): channels.NetworkCookie[] { const parsedURLs = urls.map(s => new URL(s)); @@ -257,6 +258,7 @@ export class Route extends SdkObject { async abort(errorCode: string = 'failed') { this._startHandling(); await this._delegate.abort(errorCode); + this._request._context.emit(BrowserContext.Events.RequestAborted, this._request); this._endHandling(); } @@ -289,6 +291,7 @@ export class Route extends SdkObject { body, isBase64, }); + this._request._context.emit(BrowserContext.Events.RequestFulfilled, this._request); this._endHandling(); } @@ -320,6 +323,8 @@ export class Route extends SdkObject { } this._request._setOverrides(overrides); await this._delegate.continue(this._request, overrides); + if (Object.values(overrides).some(value => value !== undefined)) + this._request._context.emit(BrowserContext.Events.RequestContinued, this._request); this._endHandling(); } diff --git a/packages/trace-viewer/src/ui/networkResourceDetails.css b/packages/trace-viewer/src/ui/networkResourceDetails.css index 89d67f2175..4185456dae 100644 --- a/packages/trace-viewer/src/ui/networkResourceDetails.css +++ b/packages/trace-viewer/src/ui/networkResourceDetails.css @@ -34,18 +34,21 @@ flex: 1; } -.network-request-title-status, -.network-request-title-method { - padding-right: 5px; +.network-request-title-status { + padding: 0 2px; + border-radius: 4px; + margin: 2px; + line-height: 20px; } .network-request-title-status.status-failure { - background-color: var(--red); - color: var(--white); + color: var(--vscode-statusBar-foreground); + background-color: var(--vscode-statusBarItem-errorBackground); } -.network-request-title-status.status-neutral { - background-color: var(--white); +.network-request-title-status.status-route { + color: var(--vscode-statusBar-foreground); + background-color: var(--vscode-statusBar-background); } .network-request-title-url { @@ -54,6 +57,10 @@ flex: 1; } +.network-request-title-content-type { + margin-left: 6px; +} + .network-request-details { width: 100%; user-select: text; diff --git a/packages/trace-viewer/src/ui/networkResourceDetails.tsx b/packages/trace-viewer/src/ui/networkResourceDetails.tsx index 69310402e1..84619874c4 100644 --- a/packages/trace-viewer/src/ui/networkResourceDetails.tsx +++ b/packages/trace-viewer/src/ui/networkResourceDetails.tsx @@ -18,6 +18,7 @@ import type { ResourceSnapshot } from '@trace/snapshot'; import { Expandable } from '@web/components/expandable'; import * as React from 'react'; import './networkResourceDetails.css'; +import type { Entry } from '@trace/har'; export const NetworkResourceDetails: React.FunctionComponent<{ resource: ResourceSnapshot, @@ -64,64 +65,28 @@ export const NetworkResourceDetails: React.FunctionComponent<{ readResources(); }, [expanded, resource]); - function formatBody(body: string | null, contentType: string): string { - if (body === null) - return 'Loading...'; + const { routeStatus, requestContentType, resourceName, contentType } = React.useMemo(() => { + const routeStatus = formatRouteStatus(resource); + const requestContentTypeHeader = resource.request.headers.find(q => q.name === 'Content-Type'); + const requestContentType = requestContentTypeHeader ? requestContentTypeHeader.value : ''; + const resourceName = resource.request.url.substring(resource.request.url.lastIndexOf('/') + 1); + let contentType = resource.response.content.mimeType; + const charset = contentType.match(/^(.*);\s*charset=.*$/); + if (charset) + contentType = charset[1]; + return { routeStatus, requestContentType, resourceName, contentType }; + }, [resource]); - const bodyStr = body; - - if (bodyStr === '') - return ''; - - if (contentType.includes('application/json')) { - try { - return JSON.stringify(JSON.parse(bodyStr), null, 2); - } catch (err) { - return bodyStr; - } - } - - if (contentType.includes('application/x-www-form-urlencoded')) - return decodeURIComponent(bodyStr); - - return bodyStr; - } - - function formatStatus(status: number): string { - if (status >= 200 && status < 400) - return 'status-success'; - - if (status >= 400) - return 'status-failure'; - - return 'status-neutral'; - } - - const requestContentTypeHeader = resource.request.headers.find(q => q.name === 'Content-Type'); - const requestContentType = requestContentTypeHeader ? requestContentTypeHeader.value : ''; - const resourceName = resource.request.url.substring(resource.request.url.lastIndexOf('/') + 1); - - let contentType = resource.response.content.mimeType; - const charset = contentType.match(/^(.*);\s*charset=.*$/); - if (charset) - contentType = charset[1]; - - const renderTitle = () => { - if (resource.response._failureText) { - return
-
{resource.response._failureText}
-
{resource.request.method}
-
{resource.request.url}
-
; - } else { - return
-
{resource.response.status}
-
{resource.request.method}
-
{resourceName}
-
{contentType}
-
; - } - }; + const renderTitle = React.useCallback(() => { + return
+ {routeStatus &&
{routeStatus}
} + {resource.response._failureText &&
{resource.response._failureText}
} + {!resource.response._failureText &&
{resource.response.status}
} +
{resource.request.method}
+
{resourceName}
+
{contentType}
+
; + }, [contentType, resource, resourceName, routeStatus]); return
setSelected(index)}> @@ -144,3 +109,43 @@ export const NetworkResourceDetails: React.FunctionComponent<{
; }; + +function formatStatus(status: number): string { + if (status >= 200 && status < 400) + return 'status-success'; + if (status >= 400) + return 'status-failure'; + return ''; +} + +function formatBody(body: string | null, contentType: string): string { + if (body === null) + return 'Loading...'; + + const bodyStr = body; + if (bodyStr === '') + return ''; + + if (contentType.includes('application/json')) { + try { + return JSON.stringify(JSON.parse(bodyStr), null, 2); + } catch (err) { + return bodyStr; + } + } + + if (contentType.includes('application/x-www-form-urlencoded')) + return decodeURIComponent(bodyStr); + + return bodyStr; +} + +function formatRouteStatus(request: Entry): string { + if (request._wasAborted) + return 'aborted'; + if (request._wasContinued) + return 'continued'; + if (request._wasFulfilled) + return 'fulfilled'; + return ''; +} diff --git a/packages/trace/src/har.ts b/packages/trace/src/har.ts index dcbc4ae2db..c273dc06ce 100644 --- a/packages/trace/src/har.ts +++ b/packages/trace/src/har.ts @@ -68,6 +68,9 @@ export type Entry = { _monotonicTime?: number; _serverPort?: number; _securityDetails?: SecurityDetails; + _wasAborted?: boolean; + _wasFulfilled?: boolean; + _wasContinued?: boolean; }; export type Request = { diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts index 26bcafb2cb..a1642d2382 100644 --- a/tests/library/trace-viewer.spec.ts +++ b/tests/library/trace-viewer.spec.ts @@ -31,7 +31,8 @@ test.beforeAll(async function recordTrace({ browser, browserName, browserType, s const context = await browser.newContext(); await context.tracing.start({ name: 'test', screenshots: true, snapshots: true, sources: true }); const page = await context.newPage(); - await page.goto('data:text/html,Hello world'); + await page.route('**/style.css', route => route.abort()); + await page.goto(`data:text/html,Hello world`); await page.setContent(''); await expect(page.locator('button')).toHaveText('Click'); await expect(page.getByTestId('amazing-btn')).toBeHidden(); @@ -101,6 +102,7 @@ test('should open simple trace viewer', async ({ showTraceViewer }) => { const traceViewer = await showTraceViewer([traceFile]); await expect(traceViewer.actionTitles).toHaveText([ /browserContext.newPage/, + /page.route/, /page.gotodata:text\/html,Hello world<\/html>/, /page.setContent/, /expect.toHaveTextlocator\('button'\)/, @@ -113,6 +115,7 @@ test('should open simple trace viewer', async ({ showTraceViewer }) => { /page.waitForResponse/, /page.waitForTimeout/, /page.gotohttp:\/\/localhost:\d+\/frames\/frame.html/, + /route.abort/, /page.setViewportSize/, ]); }); @@ -216,9 +219,9 @@ test('should have network requests', async ({ showTraceViewer }) => { const traceViewer = await showTraceViewer([traceFile]); await traceViewer.selectAction('http://localhost'); await traceViewer.showNetworkTab(); - await expect(traceViewer.networkRequests).toContainText(['200GETframe.htmltext/html']); - await expect(traceViewer.networkRequests).toContainText(['200GETstyle.csstext/css']); - await expect(traceViewer.networkRequests).toContainText(['200GETscript.jsapplication/javascript']); + await expect(traceViewer.networkRequests).toContainText([/200GETframe.htmltext\/html/]); + await expect(traceViewer.networkRequests).toContainText([/aborted.*style.cssx-unknown/]); + await expect(traceViewer.networkRequests).toContainText([/200GETscript.jsapplication\/javascript/]); }); test('should show snapshot URL', async ({ page, runAndTrace, server }) => {