From 64d3c80a07a2ab7bc0cfe249c16e01e220f6371c Mon Sep 17 00:00:00 2001 From: Devin Rousso Date: Tue, 26 May 2026 15:22:17 -0600 Subject: [PATCH] feat(har): include `WebSocket` in `.har` add an entry for each `WebSocket` when generating a `.har` also include data for each sent/received frame in a custom property `_webSocketMessages` (for more info see ) both Chrome and WebKit already capture the `wallTime` for the initial request and a `timestamp` for each subsequent message (i.e. diff the `timestamp` relative to the initial `timestamp` and add the `wallTime` in order to determine the current `wallTime`) unfortunately Firefox does not have this so fall back to `Date.now() / 1000` --- .../src/server/chromium/crNetworkManager.ts | 8 +- .../src/server/firefox/ffNetworkManager.ts | 37 +++- .../src/server/firefox/ffPage.ts | 57 +++++- .../src/server/firefox/protocol.d.ts | 2 + packages/playwright-core/src/server/frames.ts | 35 ++-- .../src/server/har/harTracer.ts | 60 +++++- .../playwright-core/src/server/network.ts | 44 ++++- .../src/server/webkit/webview/wvPage.ts | 10 +- .../server/webkit/wkInterceptableRequest.ts | 5 +- .../src/server/webkit/wkPage.ts | 12 +- packages/trace/src/har.ts | 9 + tests/library/channels.spec.ts | 7 +- tests/library/har.spec.ts | 186 +++++++++++++++++- tests/library/web-socket.spec.ts | 7 +- 14 files changed, 419 insertions(+), 60 deletions(-) diff --git a/packages/playwright-core/src/server/chromium/crNetworkManager.ts b/packages/playwright-core/src/server/chromium/crNetworkManager.ts index a5628e83b78aa..82da90b57b492 100644 --- a/packages/playwright-core/src/server/chromium/crNetworkManager.ts +++ b/packages/playwright-core/src/server/chromium/crNetworkManager.ts @@ -76,10 +76,10 @@ export class CRNetworkManager { if (this._page) { sessionInfo.eventListeners.push(...[ eventsHelper.addEventListener(session, 'Network.webSocketCreated', e => this._page!.frameManager.onWebSocketCreated(e.requestId, e.url)), - eventsHelper.addEventListener(session, 'Network.webSocketWillSendHandshakeRequest', e => this._page!.frameManager.onWebSocketRequest(e.requestId)), - eventsHelper.addEventListener(session, 'Network.webSocketHandshakeResponseReceived', e => this._page!.frameManager.onWebSocketResponse(e.requestId, e.response.status, e.response.statusText)), - eventsHelper.addEventListener(session, 'Network.webSocketFrameSent', e => e.response.payloadData && this._page!.frameManager.onWebSocketFrameSent(e.requestId, e.response.opcode, e.response.payloadData)), - eventsHelper.addEventListener(session, 'Network.webSocketFrameReceived', e => e.response.payloadData && this._page!.frameManager.webSocketFrameReceived(e.requestId, e.response.opcode, e.response.payloadData)), + eventsHelper.addEventListener(session, 'Network.webSocketWillSendHandshakeRequest', e => this._page!.frameManager.onWebSocketRequest(e.requestId, headersObjectToArray(e.request.headers, '\n'), e.wallTime, e.timestamp)), + eventsHelper.addEventListener(session, 'Network.webSocketHandshakeResponseReceived', e => this._page!.frameManager.onWebSocketResponse(e.requestId, e.response.status, e.response.statusText, headersObjectToArray(e.response.headers, '\n'))), + eventsHelper.addEventListener(session, 'Network.webSocketFrameSent', e => e.response.payloadData && this._page!.frameManager.onWebSocketFrameSent(e.requestId, e.response.opcode, e.response.payloadData, e.timestamp)), + eventsHelper.addEventListener(session, 'Network.webSocketFrameReceived', e => e.response.payloadData && this._page!.frameManager.webSocketFrameReceived(e.requestId, e.response.opcode, e.response.payloadData, e.timestamp)), eventsHelper.addEventListener(session, 'Network.webSocketClosed', e => this._page!.frameManager.webSocketClosed(e.requestId)), eventsHelper.addEventListener(session, 'Network.webSocketFrameError', e => this._page!.frameManager.webSocketError(e.requestId, e.errorMessage)), ]); diff --git a/packages/playwright-core/src/server/firefox/ffNetworkManager.ts b/packages/playwright-core/src/server/firefox/ffNetworkManager.ts index 535d2fbcd2423..e17afd5c45e69 100644 --- a/packages/playwright-core/src/server/firefox/ffNetworkManager.ts +++ b/packages/playwright-core/src/server/firefox/ffNetworkManager.ts @@ -19,20 +19,21 @@ import { eventsHelper } from '@utils/eventsHelper'; import * as network from '../network'; import type { FFSession } from './ffConnection'; +import type { FFPage } from './ffPage'; import type { HeadersArray } from '../../server/types'; import type { RegisteredListener } from '@utils/eventsHelper'; import type * as frames from '../frames'; -import type { Page } from '../page'; import type * as types from '../types'; import type { Protocol } from './protocol'; export class FFNetworkManager { private _session: FFSession; private _requests: Map; - private _page: Page; + private _page: FFPage; private _eventListeners: RegisteredListener[]; + private _webSocketRequestIds = new Set(); - constructor(session: FFSession, page: Page) { + constructor(session: FFSession, page: FFPage) { this._session = session; this._requests = new Map(); @@ -59,7 +60,7 @@ export class FFNetworkManager { _onRequestWillBeSent(event: Protocol.Network.requestWillBeSentPayload) { const redirectedFrom = event.redirectedFrom ? (this._requests.get(event.redirectedFrom) || null) : null; - const frame = redirectedFrom ? redirectedFrom.request.frame() : (event.frameId ? this._page.frameManager.frame(event.frameId) : null); + const frame = redirectedFrom ? redirectedFrom.request.frame() : (event.frameId ? this._page._page.frameManager.frame(event.frameId) : null); if (!frame) return; // Align with Chromium and WebKit and not expose preflight OPTIONS requests to the client. @@ -67,18 +68,28 @@ export class FFNetworkManager { return; if (redirectedFrom) this._requests.delete(redirectedFrom._id); + // Align with Chromium and WebKit by having WebSocket be handled separately from other network activity. + if (event.cause === 'TYPE_WEBSOCKET') { + this._webSocketRequestIds.add(event.requestId); + this._page._onWebSocketRequestWillBeSent(event.requestId, event.url, event.headers); + return; + } const request = new InterceptableRequest(frame, redirectedFrom, event); let route; if (event.isIntercepted) route = new FFRouteImpl(this._session, request); this._requests.set(request._id, request); - this._page.frameManager.requestStarted(request.request, route); + this._page._page.frameManager.requestStarted(request.request, route); } _onResponseReceived(event: Protocol.Network.responseReceivedPayload) { const request = this._requests.get(event.requestId); - if (!request) + if (!request) { + // Align with Chromium and WebKit by having WebSocket be handled separately from other network activity. + if (this._webSocketRequestIds.has(event.requestId)) + this._page._onWebSocketResponseReceived(event.requestId, event.status, event.statusText, event.headers); return; + } const getResponseBody = async () => { const response = await this._session.send('Network.getResponseBody', { requestId: request._id @@ -124,13 +135,19 @@ export class FFNetworkManager { response.setRawResponseHeaders(null); // Headers size are not available in Firefox. response.setResponseHeadersSize(null); - this._page.frameManager.requestReceivedResponse(response); + this._page._page.frameManager.requestReceivedResponse(response); } _onRequestFinished(event: Protocol.Network.requestFinishedPayload) { const request = this._requests.get(event.requestId); - if (!request) + if (!request) { + // Align with Chromium and WebKit by having WebSocket be handled separately from other network activity. + if (this._webSocketRequestIds.has(event.requestId)) { + this._webSocketRequestIds.delete(event.requestId); + this._page._onWebSocketRequestFinished(event.requestId); + } return; + } const response = request.request._existingResponse()!; response.setTransferSize(event.transferSize); response.setEncodedBodySize(event.encodedBodySize); @@ -145,7 +162,7 @@ export class FFNetworkManager { response._requestFinished(responseEndTime); } response._setHttpVersion(event.protocolVersion ?? null); - this._page.frameManager.reportRequestFinished(request.request, response); + this._page._page.frameManager.reportRequestFinished(request.request, response); } _onRequestFailed(event: Protocol.Network.requestFailedPayload) { @@ -161,7 +178,7 @@ export class FFNetworkManager { response._setHttpVersion(null); } request.request._setFailureText(event.errorCode); - this._page.frameManager.requestFailed(request.request, event.errorCode === 'NS_BINDING_ABORTED'); + this._page._page.frameManager.requestFailed(request.request, event.errorCode === 'NS_BINDING_ABORTED'); } } diff --git a/packages/playwright-core/src/server/firefox/ffPage.ts b/packages/playwright-core/src/server/firefox/ffPage.ts index 661e6122699de..80e9e1b27af6f 100644 --- a/packages/playwright-core/src/server/firefox/ffPage.ts +++ b/packages/playwright-core/src/server/firefox/ffPage.ts @@ -15,10 +15,12 @@ * limitations under the License. */ +import { assert } from '@isomorphic/assert'; import { splitErrorMessage } from '@isomorphic/stackTrace'; import { eventsHelper } from '@utils/eventsHelper'; import * as dialog from '../dialog'; import * as dom from '../dom'; +import * as network from '../network'; import { InitScript } from '../page'; import { Page, Worker } from '../page'; import { FFSession } from './ffConnection'; @@ -53,6 +55,8 @@ export class FFPage implements PageDelegate { private _eventListeners: RegisteredListener[]; private _workers = new Map(); private _initScripts: { initScript: InitScript, worldName?: string }[] = []; + private _webSocketRequests = new Map(); + private _webSocketResponses = new Map(); constructor(session: FFSession, browserContext: FFBrowserContext, opener: FFPage | null) { this._session = session; @@ -64,7 +68,7 @@ export class FFPage implements PageDelegate { this._browserContext = browserContext; this._page = new Page(this, browserContext); this.rawMouse.setPage(this._page); - this._networkManager = new FFNetworkManager(session, this._page); + this._networkManager = new FFNetworkManager(session, this); this._page.on(Page.Events.FrameDetached, frame => this._removeContextsForFrame(frame)); // TODO: remove Page.willOpenNewWindowAsynchronously from the protocol. this._eventListeners = [ @@ -90,6 +94,7 @@ export class FFPage implements PageDelegate { eventsHelper.addEventListener(this._session, 'Page.crashed', this._onCrashed.bind(this)), eventsHelper.addEventListener(this._session, 'Page.webSocketCreated', this._onWebSocketCreated.bind(this)), + eventsHelper.addEventListener(this._session, 'Page.webSocketOpened', this._onWebSocketOpened.bind(this)), eventsHelper.addEventListener(this._session, 'Page.webSocketClosed', this._onWebSocketClosed.bind(this)), eventsHelper.addEventListener(this._session, 'Page.webSocketFrameReceived', this._onWebSocketFrameReceived.bind(this)), eventsHelper.addEventListener(this._session, 'Page.webSocketFrameSent', this._onWebSocketFrameSent.bind(this)), @@ -119,7 +124,51 @@ export class FFPage implements PageDelegate { _onWebSocketCreated(event: Protocol.Page.webSocketCreatedPayload) { this._page.frameManager.onWebSocketCreated(webSocketId(event.frameId, event.wsid), event.requestURL); - this._page.frameManager.onWebSocketRequest(webSocketId(event.frameId, event.wsid)); + } + + _onWebSocketRequestWillBeSent(requestId: string, url: string, headers: types.HeadersArray) { + this._webSocketRequests.set(requestId, { url, headers }); + } + + _onWebSocketResponseReceived(requestId: string, status: number, statusText: string, headers: types.HeadersArray) { + this._webSocketResponses.set(requestId, { status, statusText, headers }); + } + + _onWebSocketRequestFinished(requestId: string) { + const response = this._webSocketResponses.get(requestId); + assert(response); + // If the request does not succeed then the WebSocket will never open, so pretend that it did. + if (response.status >= 400) { + const request = this._webSocketRequests.get(requestId); + assert(request); + + this._webSocketRequests.delete(requestId); + this._webSocketResponses.delete(requestId); + + const url = network.parseURL(request.url); + assert(url); + url.protocol = url.protocol === 'https' ? 'wss' : 'ws'; + + this._page.frameManager.onWebSocketCreated(requestId, url.toString()); + this._page.frameManager.onWebSocketRequest(requestId, request.headers); + this._page.frameManager.onWebSocketResponse(requestId, response.status, response.statusText, response.headers); + this._page.frameManager.webSocketClosed(requestId); + return; + } + } + + _onWebSocketOpened(event: Protocol.Page.webSocketOpenedPayload) { + const request = this._webSocketRequests.get(event.requestId); + assert(request); + + const response = this._webSocketResponses.get(event.requestId); + assert(response); + + this._webSocketRequests.delete(event.requestId); + this._webSocketResponses.delete(event.requestId); + + this._page.frameManager.onWebSocketRequest(webSocketId(event.frameId, event.wsid), request.headers); + this._page.frameManager.onWebSocketResponse(webSocketId(event.frameId, event.wsid), response.status, response.statusText, response.headers); } _onWebSocketClosed(event: Protocol.Page.webSocketClosedPayload) { @@ -129,11 +178,11 @@ export class FFPage implements PageDelegate { } _onWebSocketFrameReceived(event: Protocol.Page.webSocketFrameReceivedPayload) { - this._page.frameManager.webSocketFrameReceived(webSocketId(event.frameId, event.wsid), event.opcode, event.data); + this._page.frameManager.webSocketFrameReceived(webSocketId(event.frameId, event.wsid), event.opcode, event.data, event.timestamp); } _onWebSocketFrameSent(event: Protocol.Page.webSocketFrameSentPayload) { - this._page.frameManager.onWebSocketFrameSent(webSocketId(event.frameId, event.wsid), event.opcode, event.data); + this._page.frameManager.onWebSocketFrameSent(webSocketId(event.frameId, event.wsid), event.opcode, event.data, event.timestamp); } _onExecutionContextCreated(payload: Protocol.Runtime.executionContextCreatedPayload) { diff --git a/packages/playwright-core/src/server/firefox/protocol.d.ts b/packages/playwright-core/src/server/firefox/protocol.d.ts index 6e0822b91e849..9bbe10d8737f8 100644 --- a/packages/playwright-core/src/server/firefox/protocol.d.ts +++ b/packages/playwright-core/src/server/firefox/protocol.d.ts @@ -486,12 +486,14 @@ export namespace Protocol { wsid: string; opcode: number; data: string; + timestamp: number; } export type webSocketFrameReceivedPayload = { frameId: string; wsid: string; opcode: number; data: string; + timestamp: number; } export type screencastFramePayload = { data: string; diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index 92f3113f2611f..88894ea2bff74 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -403,43 +403,56 @@ export class FrameManager { this._webSockets.set(requestId, ws); } - onWebSocketRequest(requestId: string) { + onWebSocketRequest(requestId: string, headers: types.HeadersArray, wallTime?: number, timestamp?: number) { const ws = this._webSockets.get(requestId); - if (ws && ws.markAsNotified()) + if (!ws) + return; + + if (ws.markAsNotified()) this._page.emit(Page.Events.WebSocket, ws); + + ws.requestSent(headers, wallTime, timestamp); } - onWebSocketResponse(requestId: string, status: number, statusText: string) { + onWebSocketResponse(requestId: string, status: number, statusText: string, headers: types.HeadersArray) { const ws = this._webSockets.get(requestId); - if (status < 400) + if (!ws) return; - if (ws) + + ws.responseReceived(status, statusText, headers); + if (status >= 400) ws.error(`${statusText}: ${status}`); } - onWebSocketFrameSent(requestId: string, opcode: number, data: string) { + onWebSocketFrameSent(requestId: string, opcode: number, data: string, timestamp: number) { const ws = this._webSockets.get(requestId); if (ws) - ws.frameSent(opcode, data); + ws.frameSent(opcode, data, timestamp); } - webSocketFrameReceived(requestId: string, opcode: number, data: string) { + webSocketFrameReceived(requestId: string, opcode: number, data: string, timestamp: number) { const ws = this._webSockets.get(requestId); if (ws) - ws.frameReceived(opcode, data); + ws.frameReceived(opcode, data, timestamp); } webSocketClosed(requestId: string) { const ws = this._webSockets.get(requestId); - if (ws) + if (ws) { + if (ws.markAsNotified()) + this._page.emit(Page.Events.WebSocket, ws); ws.closed(); + } this._webSockets.delete(requestId); } webSocketError(requestId: string, errorMessage: string): void { const ws = this._webSockets.get(requestId); - if (ws) + if (ws) { + if (ws.markAsNotified()) + this._page.emit(Page.Events.WebSocket, ws); ws.error(errorMessage); + } } private _fireInternalFrameNavigation(frame: Frame, event: NavigationEvent) { diff --git a/packages/playwright-core/src/server/har/harTracer.ts b/packages/playwright-core/src/server/har/harTracer.ts index 4281e7b686e21..04a3957283644 100644 --- a/packages/playwright-core/src/server/har/harTracer.ts +++ b/packages/playwright-core/src/server/har/harTracer.ts @@ -30,9 +30,10 @@ import { helper } from '../helper'; import * as network from '../network'; import { nullProgress } from '../progress'; +import { Page } from '../page'; + import type { RegisteredListener } from '@utils/eventsHelper'; import type { APIRequestEvent, APIRequestFinishedEvent } from '../fetch'; -import type { Page } from '../page'; import type { Worker } from '../page'; import type { HeadersArray, LifecycleEvent } from '../types'; import type * as har from '@trace/har'; @@ -102,7 +103,10 @@ export class HarTracer { ]; if (this._context instanceof BrowserContext) { this._eventListeners.push( - eventsHelper.addEventListener(this._context, BrowserContext.Events.Page, (page: Page) => this._createPageEntryIfNeeded(page)), + eventsHelper.addEventListener(this._context, BrowserContext.Events.Page, (page: Page) => { + this._addPageEventListeners(page); + this._createPageEntryIfNeeded(page); + }), 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)), @@ -111,11 +115,21 @@ export class HarTracer { eventsHelper.addEventListener(this._context, BrowserContext.Events.RequestFulfilled, request => this._onRequestFulfilled(request)), eventsHelper.addEventListener(this._context, BrowserContext.Events.RequestContinued, request => this._onRequestContinued(request)), ); - for (const page of this._context.pages()) + for (const page of this._context.pages()) { + this._addPageEventListeners(page); this._createPageEntryIfNeeded(page); + } } } + private _addPageEventListeners(page: Page) { + if (this._page && page !== this._page) + return; + this._eventListeners.push( + eventsHelper.addEventListener(page, Page.Events.WebSocket, (webSocket: network.WebSocket) => this._onWebSocket(page, webSocket)), + ); + } + private _shouldIncludeEntryWithUrl(urlString: string) { return !this._options.urlFilter || urlMatches(this._baseURL, urlString, this._options.urlFilter); } @@ -418,6 +432,46 @@ export class HarTracer { harEntry._wasContinued = true; } + private _onWebSocket(page: Page, webSocket: network.WebSocket) { + if (!this._shouldIncludeEntryWithUrl(webSocket.url())) + return; + const url = network.parseURL(webSocket.url()); + if (!url) + return; + + const pageEntry = this._createPageEntryIfNeeded(page); + const harEntry = createHarEntry(pageEntry?.id, 'GET', url, page.mainFrame().guid, this._options); + harEntry._resourceType = 'websocket'; + harEntry._webSocketMessages = []; + + this._eventListeners.push( + eventsHelper.addEventListener(webSocket, network.WebSocket.Events.Request, ({ headers }: { headers: HeadersArray }) => { + this._recordRequestHeadersAndCookies(harEntry, headers); + }), + eventsHelper.addEventListener(webSocket, network.WebSocket.Events.Response, ({ status, statusText, headers }: { status: number, statusText: string, headers: HeadersArray }) => { + harEntry.response.status = status; + harEntry.response.statusText = statusText; + this._recordResponseHeaders(harEntry, headers); + }), + eventsHelper.addEventListener(webSocket, network.WebSocket.Events.FrameSent, ({ opcode, data, timestamp }: { opcode: number, data: string, timestamp: number }) => { + harEntry._webSocketMessages!.push({ type: 'send', time: timestamp, opcode, data }); + }), + eventsHelper.addEventListener(webSocket, network.WebSocket.Events.FrameReceived, ({ opcode, data, timestamp }: { opcode: number, data: string, timestamp: number }) => { + harEntry._webSocketMessages!.push({ type: 'receive', time: timestamp, opcode, data }); + }), + eventsHelper.addEventListener(webSocket, network.WebSocket.Events.SocketError, (errorMessage: string) => { + harEntry.response._failureText = errorMessage; + }), + eventsHelper.addEventListener(webSocket, network.WebSocket.Events.Close, () => { + if (this._started) + this._delegate.onEntryFinished(harEntry); + }), + ); + + if (this._started) + this._delegate.onEntryStarted(harEntry); + } + 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 ce78b508798aa..7e944987753d3 100644 --- a/packages/playwright-core/src/server/network.ts +++ b/packages/playwright-core/src/server/network.ts @@ -725,12 +725,20 @@ export class Response extends SdkObject { export class WebSocket extends SdkObject { private _url: string; private _notified = false; + private _requestWallTime: number | undefined; + private _requestTimestamp: number | undefined; + private _status: number | undefined; + private _statusText: string | undefined; + private _requestHeaders: HeadersArray | undefined; + private _responseHeaders: HeadersArray | undefined; static Events = { Close: 'close', SocketError: 'socketerror', FrameReceived: 'framereceived', FrameSent: 'framesent', + Request: 'request', + Response: 'response', }; constructor(parent: SdkObject, url: string) { @@ -752,12 +760,40 @@ export class WebSocket extends SdkObject { return this._url; } - frameSent(opcode: number, data: string) { - this.emit(WebSocket.Events.FrameSent, { opcode, data }); + requestSent(headers: HeadersArray, wallTime?: number, timestamp?: number) { + this._requestWallTime = wallTime; + this._requestTimestamp = timestamp; + + this.emit(WebSocket.Events.Request, { headers }); + } + + responseReceived(status: number, statusText: string, headers: HeadersArray) { + this.emit(WebSocket.Events.Response, { status, statusText, headers }); } - frameReceived(opcode: number, data: string) { - this.emit(WebSocket.Events.FrameReceived, { opcode, data }); + private _toWallTime(timestamp: number): number { + // The timestamp of each frame is relative to the timestamp (and walltime) of the initial request in Chromium and WebKit. + if (this._requestWallTime !== undefined && this._requestTimestamp !== undefined) + return this._requestWallTime + (timestamp - this._requestTimestamp); + + // The timestamp is already a walltime in Firefox. + return timestamp; + } + + frameSent(opcode: number, data: string, timestamp: number) { + this.emit(WebSocket.Events.FrameSent, { + opcode, + data, + timestamp: this._toWallTime(timestamp), + }); + } + + frameReceived(opcode: number, data: string, timestamp: number) { + this.emit(WebSocket.Events.FrameReceived, { + opcode, + data, + timestamp: this._toWallTime(timestamp), + }); } error(errorMessage: string) { diff --git a/packages/playwright-core/src/server/webkit/webview/wvPage.ts b/packages/playwright-core/src/server/webkit/webview/wvPage.ts index ac5f195c82530..1f1aef2f79a8f 100644 --- a/packages/playwright-core/src/server/webkit/webview/wvPage.ts +++ b/packages/playwright-core/src/server/webkit/webview/wvPage.ts @@ -18,7 +18,7 @@ import { PNG } from 'pngjs'; import jpegjs from 'jpeg-js'; import { assert } from '@isomorphic/assert'; -import { headersArrayToObject } from '@isomorphic/headers'; +import { headersArrayToObject, headersObjectToArray } from '@isomorphic/headers'; import { ManualPromise } from '@isomorphic/manualPromise'; import { splitErrorMessage } from '@isomorphic/stackTrace'; import { debugLogger } from '@utils/debugLogger'; @@ -323,10 +323,10 @@ export class WVPage implements PageDelegate { eventsHelper.addEventListener(session, 'Network.loadingFinished', e => this._onLoadingFinished(e)), eventsHelper.addEventListener(session, 'Network.loadingFailed', e => this._onLoadingFailed(session, e)), eventsHelper.addEventListener(session, 'Network.webSocketCreated', e => this._page.frameManager.onWebSocketCreated(e.requestId, e.url)), - eventsHelper.addEventListener(session, 'Network.webSocketWillSendHandshakeRequest', e => this._page.frameManager.onWebSocketRequest(e.requestId)), - eventsHelper.addEventListener(session, 'Network.webSocketHandshakeResponseReceived', e => this._page.frameManager.onWebSocketResponse(e.requestId, e.response.status, e.response.statusText)), - eventsHelper.addEventListener(session, 'Network.webSocketFrameSent', e => e.response.payloadData && this._page.frameManager.onWebSocketFrameSent(e.requestId, e.response.opcode, e.response.payloadData)), - eventsHelper.addEventListener(session, 'Network.webSocketFrameReceived', e => e.response.payloadData && this._page.frameManager.webSocketFrameReceived(e.requestId, e.response.opcode, e.response.payloadData)), + eventsHelper.addEventListener(session, 'Network.webSocketWillSendHandshakeRequest', e => this._page.frameManager.onWebSocketRequest(e.requestId, headersObjectToArray(e.request.headers), e.walltime, e.timestamp)), + eventsHelper.addEventListener(session, 'Network.webSocketHandshakeResponseReceived', e => this._page.frameManager.onWebSocketResponse(e.requestId, e.response.status, e.response.statusText, headersObjectToArray(e.response.headers, ','))), + eventsHelper.addEventListener(session, 'Network.webSocketFrameSent', e => e.response.payloadData && this._page.frameManager.onWebSocketFrameSent(e.requestId, e.response.opcode, e.response.payloadData, e.timestamp)), + eventsHelper.addEventListener(session, 'Network.webSocketFrameReceived', e => e.response.payloadData && this._page.frameManager.webSocketFrameReceived(e.requestId, e.response.opcode, e.response.payloadData, e.timestamp)), eventsHelper.addEventListener(session, 'Network.webSocketClosed', e => this._page.frameManager.webSocketClosed(e.requestId)), eventsHelper.addEventListener(session, 'Network.webSocketFrameError', e => this._page.frameManager.webSocketError(e.requestId, e.errorMessage)), ]; diff --git a/packages/playwright-core/src/server/webkit/wkInterceptableRequest.ts b/packages/playwright-core/src/server/webkit/wkInterceptableRequest.ts index c0d0e1dde28fa..8fc68d2fa2592 100644 --- a/packages/playwright-core/src/server/webkit/wkInterceptableRequest.ts +++ b/packages/playwright-core/src/server/webkit/wkInterceptableRequest.ts @@ -42,6 +42,8 @@ const errorReasons: { [reason: string]: Protocol.Network.ResourceErrorType } = { 'failed': 'General', }; +export const wkSetCookieSeparator = process.platform === 'darwin' ? ',' : 'playwright-set-cookie-separator'; + export class WKInterceptableRequest { private _session: WKSession; private _requestId: string; @@ -83,8 +85,7 @@ export class WKInterceptableRequest { requestStart: timingPayload ? wkMillisToRoundishMillis(timingPayload.requestStart) : -1, responseStart: timingPayload ? wkMillisToRoundishMillis(timingPayload.responseStart) : -1, }; - const setCookieSeparator = process.platform === 'darwin' ? ',' : 'playwright-set-cookie-separator'; - const response = new network.Response(this.request, responsePayload.status, responsePayload.statusText, headersObjectToArray(responsePayload.headers, ',', setCookieSeparator), timing, getResponseBody, responsePayload.source === 'service-worker'); + const response = new network.Response(this.request, responsePayload.status, responsePayload.statusText, headersObjectToArray(responsePayload.headers, ',', wkSetCookieSeparator), timing, getResponseBody, responsePayload.source === 'service-worker'); // No raw response headers in WebKit, use "provisional" ones. response.setRawResponseHeaders(null); diff --git a/packages/playwright-core/src/server/webkit/wkPage.ts b/packages/playwright-core/src/server/webkit/wkPage.ts index 731c02989be67..acc220a4b38a1 100644 --- a/packages/playwright-core/src/server/webkit/wkPage.ts +++ b/packages/playwright-core/src/server/webkit/wkPage.ts @@ -17,7 +17,7 @@ import { PNG } from 'pngjs'; import jpegjs from 'jpeg-js'; -import { headersArrayToObject } from '@isomorphic/headers'; +import { headersArrayToObject, headersObjectToArray } from '@isomorphic/headers'; import { splitErrorMessage } from '@isomorphic/stackTrace'; import { eventsHelper } from '@utils/eventsHelper'; import { hostPlatform } from '@utils/hostPlatform'; @@ -31,7 +31,7 @@ import { Page, PageBinding } from '../page'; import { WKSession } from './wkConnection'; import { createHandle, WKExecutionContext } from './wkExecutionContext'; import { RawKeyboardImpl, RawMouseImpl, RawTouchscreenImpl } from './wkInput'; -import { WKInterceptableRequest, WKRouteImpl } from './wkInterceptableRequest'; +import { WKInterceptableRequest, WKRouteImpl, wkSetCookieSeparator } from './wkInterceptableRequest'; import { WKProvisionalPage } from './wkProvisionalPage'; import { WKWorkers } from './wkWorkers'; import { translatePathToWSL } from './webkit'; @@ -397,10 +397,10 @@ export class WKPage implements PageDelegate { eventsHelper.addEventListener(this._session, 'Network.loadingFinished', e => this._onLoadingFinished(e)), eventsHelper.addEventListener(this._session, 'Network.loadingFailed', e => this._onLoadingFailed(this._session, e)), eventsHelper.addEventListener(this._session, 'Network.webSocketCreated', e => this._page.frameManager.onWebSocketCreated(e.requestId, e.url)), - eventsHelper.addEventListener(this._session, 'Network.webSocketWillSendHandshakeRequest', e => this._page.frameManager.onWebSocketRequest(e.requestId)), - eventsHelper.addEventListener(this._session, 'Network.webSocketHandshakeResponseReceived', e => this._page.frameManager.onWebSocketResponse(e.requestId, e.response.status, e.response.statusText)), - eventsHelper.addEventListener(this._session, 'Network.webSocketFrameSent', e => e.response.payloadData && this._page.frameManager.onWebSocketFrameSent(e.requestId, e.response.opcode, e.response.payloadData)), - eventsHelper.addEventListener(this._session, 'Network.webSocketFrameReceived', e => e.response.payloadData && this._page.frameManager.webSocketFrameReceived(e.requestId, e.response.opcode, e.response.payloadData)), + eventsHelper.addEventListener(this._session, 'Network.webSocketWillSendHandshakeRequest', e => this._page.frameManager.onWebSocketRequest(e.requestId, headersObjectToArray(e.request.headers), e.walltime, e.timestamp)), + eventsHelper.addEventListener(this._session, 'Network.webSocketHandshakeResponseReceived', e => this._page.frameManager.onWebSocketResponse(e.requestId, e.response.status, e.response.statusText, headersObjectToArray(e.response.headers, ',', wkSetCookieSeparator))), + eventsHelper.addEventListener(this._session, 'Network.webSocketFrameSent', e => e.response.payloadData && this._page.frameManager.onWebSocketFrameSent(e.requestId, e.response.opcode, e.response.payloadData, e.timestamp)), + eventsHelper.addEventListener(this._session, 'Network.webSocketFrameReceived', e => e.response.payloadData && this._page.frameManager.webSocketFrameReceived(e.requestId, e.response.opcode, e.response.payloadData, e.timestamp)), eventsHelper.addEventListener(this._session, 'Network.webSocketClosed', e => this._page.frameManager.webSocketClosed(e.requestId)), eventsHelper.addEventListener(this._session, 'Network.webSocketFrameError', e => this._page.frameManager.webSocketError(e.requestId, e.errorMessage)), ]; diff --git a/packages/trace/src/har.ts b/packages/trace/src/har.ts index fd62f7873c392..63d81852af9ff 100644 --- a/packages/trace/src/har.ts +++ b/packages/trace/src/har.ts @@ -72,6 +72,15 @@ export type Entry = { _wasFulfilled?: boolean; _wasContinued?: boolean; _apiRequest?: boolean; + _resourceType?: string; + _webSocketMessages?: WebSocketMessage[]; +}; + +export type WebSocketMessage = { + type: 'send' | 'receive'; + time: number; + opcode: number; + data: string; }; export type Request = { diff --git a/tests/library/channels.spec.ts b/tests/library/channels.spec.ts index 7bff98362dc15..04875b70953df 100644 --- a/tests/library/channels.spec.ts +++ b/tests/library/channels.spec.ts @@ -221,7 +221,7 @@ it('should not generate dispatchers for subresources w/o listeners', async ({ pa }); }); -it('should work with the domain module', async ({ browserType, server, browserName, channel }) => { +it('should work with the domain module', async ({ browserType, server, channel }) => { const local = domain.create(); local.run(() => { }); let err; @@ -241,10 +241,7 @@ it('should work with the domain module', async ({ browserType, server, browserNa new WebSocket('ws://' + host + '/bogus-ws'); }, server.HOST); const message = await result; - if (browserName === 'firefox') - expect(message).toBe('CLOSE_ABNORMAL'); - else - expect(message).toContain(channel?.includes('msedge') ? '' : ': 400'); + expect(message).toContain(channel?.includes('msedge') ? '' : ': 400'); await browser.close(); diff --git a/tests/library/har.spec.ts b/tests/library/har.spec.ts index 121726b52aa28..8e6e601c851ee 100644 --- a/tests/library/har.spec.ts +++ b/tests/library/har.spec.ts @@ -17,10 +17,11 @@ import { browserTest as it, expect } from '../config/browserTest'; import fs from 'fs'; +import net from 'net'; import path from 'path'; import type { BrowserContext, BrowserContextOptions } from 'playwright-core'; import type { AddressInfo } from 'net'; -import type { Log } from '../../packages/trace/src/har'; +import type { Entry, Log } from '../../packages/trace/src/har'; import { parseHar } from '../config/utils'; import { TestServer } from '../config/testserver'; import { utils } from '../../packages/playwright-core/lib/coreBundle'; @@ -1021,3 +1022,186 @@ it.describe('tracing.startHar', () => { await context.close(); }); }); + +it.describe('WebSocket', () => { + it('should only have one websocket entry', async ({ contextFactory, server, browserName }, testInfo) => { + server.onceWebSocketConnection(ws => { + ws.on('message', () => ws.close()); + }); + const { page, getLog } = await pageWithHar(contextFactory, testInfo); + await page.goto(server.EMPTY_PAGE); + const wsUrl = `ws://${server.HOST}/ws`; + const closed = page.evaluate(url => new Promise(resolve => { + const ws = new WebSocket(url); + ws.addEventListener('open', () => ws.send('ping')); + ws.addEventListener('close', () => resolve()); + }), wsUrl); + await closed; + const log = await getLog(); + const wsEntries = log.entries.filter(e => e.request.url.endsWith(`://${server.HOST}/ws`))! as Entry[]; + expect(wsEntries.length).toBe(1); + + const wsEntry = wsEntries[0]; + expect(wsEntry._resourceType).toBe('websocket'); + }); + + it('should include websocket handshake headers and status', async ({ contextFactory, server, browserName }, testInfo) => { + server.onceWebSocketConnection(ws => { + ws.on('message', () => ws.close()); + }); + + const { page, getLog } = await pageWithHar(contextFactory, testInfo); + await page.goto(server.EMPTY_PAGE); + + const wsUrl = `ws://${server.HOST}/ws`; + const closed = page.evaluate(url => new Promise(resolve => { + const ws = new WebSocket(url); + ws.addEventListener('open', () => ws.send('ping')); + ws.addEventListener('close', () => resolve()); + }), wsUrl); + await closed; + const log = await getLog(); + + const wsEntry = log.entries.find(e => e.request.url === wsUrl)! as Entry; + expect(wsEntry._resourceType).toBe('websocket'); + expect(wsEntry.response.status).toBe(101); + expect(wsEntry.response.statusText).toBe('Switching Protocols'); + + const requestHeaderNames = wsEntry.request.headers.map(h => h.name.toLowerCase()); + expect(requestHeaderNames).toContain('upgrade'); + expect(requestHeaderNames).toContain('connection'); + expect(requestHeaderNames).toContain('sec-websocket-key'); + expect(requestHeaderNames).toContain('sec-websocket-version'); + const upgradeHeader = wsEntry.request.headers.find(h => h.name.toLowerCase() === 'upgrade')!; + expect(upgradeHeader.value.toLowerCase()).toBe('websocket'); + + const responseHeaderNames = wsEntry.response.headers.map(h => h.name.toLowerCase()); + expect(responseHeaderNames).toContain('upgrade'); + expect(responseHeaderNames).toContain('connection'); + expect(responseHeaderNames).toContain('sec-websocket-accept'); + }); + + it('should include websocket messages', async ({ contextFactory, server }, testInfo) => { + server.onceWebSocketConnection(ws => { + ws.on('message', () => ws.send('incoming')); + }); + + const { page, getLog } = await pageWithHar(contextFactory, testInfo); + await page.goto(server.EMPTY_PAGE); + + const beforeMs = Date.now(); + const wsUrl = `ws://${server.HOST}/ws`; + const closed = page.evaluate(url => new Promise(resolve => { + const ws = new WebSocket(url); + ws.addEventListener('open', () => ws.send('outgoing')); + ws.addEventListener('message', () => ws.close()); + ws.addEventListener('close', () => resolve()); + }), wsUrl); + await closed; + const afterMs = Date.now(); + const log = await getLog(); + + const wsEntry = log.entries.find(e => e.request.url === wsUrl)! as Entry; + expect(wsEntry._resourceType).toBe('websocket'); + expect(wsEntry.response.status).toBe(101); + expect(wsEntry.response.statusText).toBe('Switching Protocols'); + + const messages = wsEntry._webSocketMessages; + expect(messages.map(m => ({ type: m.type, opcode: m.opcode, data: m.data }))).toEqual([ + { type: 'send', opcode: 1, data: 'outgoing' }, + { type: 'receive', opcode: 1, data: 'incoming' }, + ]); + for (const m of messages) { + expect(m.time).toBeGreaterThanOrEqual(beforeMs / 1000 - 1); + expect(m.time).toBeLessThanOrEqual(afterMs / 1000 + 1); + } + expect(messages[0].time).toBeLessThanOrEqual(messages[1].time); + }); + + it('should include binary websocket messages', async ({ contextFactory, server }, testInfo) => { + const incoming = [0x01, 0x02, 0x03, 0x04]; + const outgoing = [0x05, 0x06, 0x07, 0x08]; + + server.onceWebSocketConnection(ws => { + ws.on('message', () => ws.send(Buffer.from(incoming))); + }); + + const { page, getLog } = await pageWithHar(contextFactory, testInfo); + await page.goto(server.EMPTY_PAGE); + + const wsUrl = `ws://${server.HOST}/ws`; + const closed = page.evaluate(({ url, outgoing }) => new Promise(resolve => { + const ws = new WebSocket(url); + ws.binaryType = 'arraybuffer'; + ws.addEventListener('open', () => ws.send(new Uint8Array(outgoing))); + ws.addEventListener('message', () => ws.close()); + ws.addEventListener('close', () => resolve()); + }), { url: wsUrl, outgoing }); + await closed; + const log = await getLog(); + + const wsEntry = log.entries.find(e => e.request.url === wsUrl)! as Entry; + expect(wsEntry._resourceType).toBe('websocket'); + expect(wsEntry.response.status).toBe(101); + expect(wsEntry.response.statusText).toBe('Switching Protocols'); + + const messages = wsEntry._webSocketMessages; + expect(messages.length).toBe(2); + expect(messages[0].type).toBe('send'); + expect(messages[0].opcode).toBe(2); + expect([...Buffer.from(messages[0].data, 'base64')]).toEqual(outgoing); + expect(messages[1].type).toBe('receive'); + expect(messages[1].opcode).toBe(2); + expect([...Buffer.from(messages[1].data, 'base64')]).toEqual(incoming); + }); + + it('should record websocket connection failure', async ({ contextFactory, server }, testInfo) => { + // Reserve a port and immediately release it so the WebSocket connect attempt is refused. + const portReservation = net.createServer(); + await new Promise(resolve => portReservation.listen(0, '127.0.0.1', () => resolve())); + const port = (portReservation.address() as AddressInfo).port; + await new Promise(resolve => portReservation.close(() => resolve())); + + const { page, getLog } = await pageWithHar(contextFactory, testInfo); + await page.goto(server.EMPTY_PAGE); + + const wsUrl = `ws://127.0.0.1:${port}/ws-connect-fail`; + await page.evaluate(url => new Promise(resolve => { + const ws = new WebSocket(url); + ws.addEventListener('close', () => resolve()); + ws.addEventListener('error', () => resolve()); + }), wsUrl); + const log = await getLog(); + + const wsEntry = log.entries.find(e => e.request.url === wsUrl)! as Entry; + expect(wsEntry._resourceType).toBe('websocket'); + expect(wsEntry.response._failureText).toBeTruthy(); + }); + + it('should record websocket handshake failure', async ({ contextFactory, server, browserName }, testInfo) => { + const { page, getLog } = await pageWithHar(contextFactory, testInfo); + await page.goto(server.EMPTY_PAGE); + + const wsUrl = `ws://${server.HOST}/ws-handshake-fail`; + const upgradePromise = server.waitForUpgrade(); + const wsClose = page.evaluate(url => new Promise(resolve => { + const ws = new WebSocket(url); + ws.addEventListener('close', () => resolve()); + ws.addEventListener('error', () => resolve()); + }), wsUrl); + const { socket } = await upgradePromise; + socket.write('HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n'); + socket.destroy(); + await wsClose; + const log = await getLog(); + + const wsEntry = log.entries.find(e => e.request.url === wsUrl)! as Entry; + expect(wsEntry._resourceType).toBe('websocket'); + if (browserName !== 'chromium') { + // Chromium only reports an error instead of giving a status code and text. + expect(wsEntry.response.status).toBe(403); + expect(wsEntry.response.statusText).toBe('Forbidden'); + } + expect(wsEntry.response._failureText).toBeTruthy(); + }); +}); diff --git a/tests/library/web-socket.spec.ts b/tests/library/web-socket.spec.ts index 4f49148794e3e..cacd0865e3ac2 100644 --- a/tests/library/web-socket.spec.ts +++ b/tests/library/web-socket.spec.ts @@ -137,7 +137,7 @@ it('should emit binary frame events', async ({ page, server }) => { expect(sent[1][i]).toBe(i); }); -it('should emit error', async ({ page, server, browserName, channel }) => { +it('should emit error', async ({ page, server, channel }) => { let callback; const result = new Promise(f => callback = f); page.on('websocket', ws => ws.on('socketerror', callback)); @@ -145,10 +145,7 @@ it('should emit error', async ({ page, server, browserName, channel }) => { new WebSocket('ws://' + host + '/bogus-ws'); }, server.HOST); const message = await result; - if (browserName === 'firefox') - expect(message).toBe('CLOSE_ABNORMAL'); - else - expect(message).toContain(channel?.includes('msedge') ? '' : ': 400'); + expect(message).toContain(channel?.includes('msedge') ? '' : ': 400'); }); it('should not have stray error events', async ({ page, server }) => {