From ea79b41fcca75569cc95a0e002425df40532953e 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 | 63 +++- .../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-websocket.spec.ts | 332 ++++++++++++++++++ tests/library/route-web-socket.spec.ts | 67 ++++ tests/library/web-socket.spec.ts | 7 +- 15 files changed, 636 insertions(+), 59 deletions(-) create mode 100644 tests/library/har-websocket.spec.ts 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..e6f22b6db00ba 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,49 @@ 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 = []; + + const eventListeners = [ + 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, () => { + eventsHelper.removeEventListeners(eventListeners); + + if (this._started) + this._delegate.onEntryFinished(harEntry); + }), + ]; + this._eventListeners.push(...eventListeners); + + 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 e043cd4635b0e..2b2ae27e22658 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'; @@ -325,10 +325,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-websocket.spec.ts b/tests/library/har-websocket.spec.ts new file mode 100644 index 0000000000000..0bea2c53c53e5 --- /dev/null +++ b/tests/library/har-websocket.spec.ts @@ -0,0 +1,332 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * Modifications copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { browserTest as it, expect } from '../config/browserTest'; +import fs from 'fs'; +import net from 'net'; +import type { BrowserContext, BrowserContextOptions } from 'playwright-core'; +import type { AddressInfo } from 'net'; +import type { Entry, Log } from '../../packages/trace/src/har'; + +async function pageWithHar(contextFactory: (options?: BrowserContextOptions) => Promise, testInfo: any, options: { outputPath?: string } & Partial> = {}) { + const harPath = testInfo.outputPath(options.outputPath || 'test.har'); + const context = await contextFactory({ recordHar: { path: harPath, ...options }, ignoreHTTPSErrors: true }); + const page = await context.newPage(); + return { + page, + context, + getLog: async () => { + await context.close(); + return JSON.parse(fs.readFileSync(harPath).toString())['log'] as Log; + }, + }; +} + +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(); +}); + +it('should still capture websocket when route passes messages through', async ({ contextFactory, server }, testInfo) => { + server.onceWebSocketConnection(ws => { + ws.on('message', () => ws.send('incoming')); + }); + + const { page, getLog } = await pageWithHar(contextFactory, testInfo); + let routeHandlerCalled = 0; + await page.routeWebSocket(/\/ws$/, ws => { + ++routeHandlerCalled; + const serverRoute = ws.connectToServer(); + ws.onMessage(message => serverRoute.send(message)); + serverRoute.onMessage(message => ws.send(message)); + }); + await page.goto(server.EMPTY_PAGE); + + const wsUrl = `ws://${server.HOST}/ws`; + const messages = await page.evaluate(url => new Promise(resolve => { + const seen: string[] = []; + const ws = new WebSocket(url); + ws.addEventListener('open', () => ws.send('outgoing')); + ws.addEventListener('message', event => { + seen.push(event.data); + ws.close(); + }); + ws.addEventListener('close', () => resolve(seen)); + }), wsUrl); + expect(routeHandlerCalled).toBe(1); + expect(messages).toEqual(['incoming']); + const log = await getLog(); + + const wsEntries = log.entries.filter(e => e.request.url === wsUrl)! as Entry[]; + expect(wsEntries.length).toBe(1); + expect(wsEntries[0]._resourceType).toBe('websocket'); + expect(wsEntries[0].response.status).toBe(101); + expect(wsEntries[0]._webSocketMessages.map(m => ({ type: m.type, data: m.data }))).toEqual([ + { type: 'send', data: 'outgoing' }, + { type: 'receive', data: 'incoming' }, + ]); +}); + +it('should still allow routeWebSocket to fully mock the connection when capturing HAR', async ({ contextFactory, server }, testInfo) => { + const { page, getLog } = await pageWithHar(contextFactory, testInfo); + let routeHandlerCalled = 0; + await page.routeWebSocket(/\/ws$/, ws => { + ++routeHandlerCalled; + ws.onMessage(message => { + if (message === 'ping') + ws.send('pong'); + }); + }); + await page.goto(server.EMPTY_PAGE); + + const wsUrl = `ws://${server.HOST}/ws`; + const messages = await page.evaluate(url => new Promise(resolve => { + const seen: string[] = []; + const ws = new WebSocket(url); + ws.addEventListener('open', () => ws.send('ping')); + ws.addEventListener('message', event => { + seen.push(event.data); + ws.close(); + }); + ws.addEventListener('close', () => resolve(seen)); + }), wsUrl); + expect(routeHandlerCalled).toBe(1); + expect(messages).toEqual(['pong']); + const log = await getLog(); + + // Fully mocked routes never create a native WebSocket, so nothing should be recorded. + const wsEntries = log.entries.filter(e => e.request.url === wsUrl); + expect(wsEntries).toEqual([]); +}); + +it('should still allow routeWebSocket to modify messages when capturing HAR', async ({ contextFactory, server }, testInfo) => { + server.onceWebSocketConnection(ws => { + ws.on('message', message => ws.send(`server-saw-${message.toString()}`)); + }); + + const { page, getLog } = await pageWithHar(contextFactory, testInfo); + let routeHandlerCalled = 0; + await page.routeWebSocket(/\/ws$/, ws => { + ++routeHandlerCalled; + const serverRoute = ws.connectToServer(); + ws.onMessage(message => serverRoute.send(`modified-${message.toString()}`)); + serverRoute.onMessage(message => ws.send(`page-got-${message.toString()}`)); + }); + await page.goto(server.EMPTY_PAGE); + + const wsUrl = `ws://${server.HOST}/ws`; + const messages = await page.evaluate(url => new Promise(resolve => { + const seen: string[] = []; + const ws = new WebSocket(url); + ws.addEventListener('open', () => ws.send('hello')); + ws.addEventListener('message', event => { + seen.push(event.data); + ws.close(); + }); + ws.addEventListener('close', () => resolve(seen)); + }), wsUrl); + expect(routeHandlerCalled).toBe(1); + // The page sees the route-modified server response. + expect(messages).toEqual(['page-got-server-saw-modified-hello']); + const log = await getLog(); + + // HAR records actual wire traffic from the native WebSocket: outgoing messages + // are modified by the client-side route handler before they hit the server, + // and incoming messages are recorded before the server-side route handler modifies them. + const wsEntries = log.entries.filter(e => e.request.url === wsUrl)! as Entry[]; + expect(wsEntries.length).toBe(1); + expect(wsEntries[0]._webSocketMessages.map(m => ({ type: m.type, data: m.data }))).toEqual([ + { type: 'send', data: 'modified-hello' }, + { type: 'receive', data: 'server-saw-modified-hello' }, + ]); +}); diff --git a/tests/library/route-web-socket.spec.ts b/tests/library/route-web-socket.spec.ts index eb0cb80beb726..d968889490b4a 100644 --- a/tests/library/route-web-socket.spec.ts +++ b/tests/library/route-web-socket.spec.ts @@ -18,6 +18,7 @@ import { attachFrame, detachFrame } from '../config/utils'; import { contextTest as test, expect } from '../config/browserTest'; import type { Frame, Page, WebSocketRoute } from '@playwright/test'; import { TestServer } from '../config/testserver'; +import type { WebSocket as WebSocketServer } from 'ws'; declare global { interface Window { @@ -245,6 +246,72 @@ test('should work with ws.close', async ({ page, server }) => { expect(await closedPromise).toEqual({ code: 3009, reason: 'oops' }); }); +test('should observe upstream handshake failure when connectToServer is used', async ({ page, server }) => { + // Exercises the WebSocket-as-network-request path used by Firefox after the + // har-WebSocket plumbing change, where a 4xx handshake response is synthesized + // into a full lifecycle in FFPage._onWebSocketRequestFinished. + const serverCloses: { code: number | undefined, reason: string | undefined }[] = []; + let routeHandlerInvoked = 0; + await page.routeWebSocket(/.*/, ws => { + ++routeHandlerInvoked; + const serverRoute = ws.connectToServer(); + serverRoute.onClose((code, reason) => { + serverCloses.push({ code, reason }); + void ws.close(); + }); + }); + + const upgradePromise = server.waitForUpgrade(); + await setupWS(page, server, 'blob'); + const { socket } = await upgradePromise; + socket.write('HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n'); + socket.destroy(); + + // Server-side route handler observes the close caused by the rejected handshake. + await expect.poll(() => serverCloses.length).toBe(1); + expect(serverCloses[0].code).toBeGreaterThanOrEqual(1000); + + // Once the route closes the page-side socket, the page sees the close. + await expect.poll(() => page.evaluate(() => window.ws.readyState)).toBe(3); + expect(routeHandlerInvoked).toBe(1); +}); + +test('should observe multiple concurrent routed WebSockets with connectToServer', async ({ page, server }) => { + // Exercises FFNetworkManager._webSocketRequestIds tracking by routing two + // simultaneous WebSocket connections through `connectToServer()`. Each request + // gets a distinct id in the new Firefox WebSocket-as-network-request flow. + let routedConnections = 0; + await page.routeWebSocket(/.*/, ws => { + ++routedConnections; + const serverRoute = ws.connectToServer(); + ws.onMessage(message => serverRoute.send(message)); + serverRoute.onMessage(message => ws.send(message)); + }); + + // Echo all incoming messages on whichever connection arrives; re-register after each. + const handleConnection = (ws: WebSocketServer) => { + ws.on('message', data => ws.send(`echo-${data.toString()}`)); + server.onceWebSocketConnection(handleConnection); + }; + server.onceWebSocketConnection(handleConnection); + + await page.goto(server.EMPTY_PAGE); + const results = await page.evaluate(async host => { + const collect = (tag: string) => new Promise(resolve => { + const ws = new WebSocket(`ws://${host}/ws`); + ws.addEventListener('open', () => ws.send(`hi-${tag}`)); + ws.addEventListener('message', event => { + resolve(event.data); + ws.close(); + }); + }); + return Promise.all([collect('a'), collect('b')]); + }, server.HOST); + + expect(results.sort()).toEqual(['echo-hi-a', 'echo-hi-b']); + expect(routedConnections).toBe(2); +}); + test('should pattern match', async ({ page, server }) => { await page.routeWebSocket(/.*\/ws$/, async ws => { ws.connectToServer(); 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 }) => {