Skip to content

Commit 4d9e532

Browse files
committed
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 <https://developer.chrome.com/blog/new-in-devtools-76#websocket>) 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`
1 parent 17c003c commit 4d9e532

16 files changed

Lines changed: 394 additions & 52 deletions

File tree

browser_patches/firefox/juggler/content/FrameTree.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,7 @@ class Frame {
422422
wsid: webSocketSerialID + '',
423423
opcode: frame.opCode,
424424
data: frame.opCode !== 1 ? btoa(frame.payload) : frame.payload,
425+
timestamp: frame.timeStamp / 1_000_000,
425426
});
426427
this._webSocketListener = {
427428
QueryInterface: ChromeUtils.generateQI([Ci.nsIWebSocketEventListener, ]),
@@ -492,6 +493,7 @@ class Frame {
492493
wsid: webSocketSerialID + '',
493494
opcode: frame.opCode,
494495
data: frame.opCode !== 1 ? btoa(frame.payload) : frame.payload,
496+
timestamp: frame.timeStamp / 1_000_000,
495497
});
496498
},
497499
};

browser_patches/firefox/juggler/protocol/Protocol.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -716,12 +716,14 @@ const Page = {
716716
wsid: t.String,
717717
opcode: t.Number,
718718
data: t.String,
719+
timestamp: t.Number,
719720
},
720721
'webSocketFrameReceived': {
721722
frameId: t.String,
722723
wsid: t.String,
723724
opcode: t.Number,
724725
data: t.String,
726+
timestamp: t.Number,
725727
},
726728
'screencastFrame': {
727729
data: t.String,

packages/playwright-core/src/server/bidi/bidiNetworkManager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ class BidiRequest {
285285
redirectedFrom._redirectedTo = this;
286286
// TODO: missing in the spec?
287287
const postDataBuffer = null;
288-
this.request = new network.Request(frame._page.browserContext, frame, null, redirectedFrom ? redirectedFrom.request : null, payload.navigation ?? undefined,
288+
this.request = new network.Request(frame._page.browserContext, frame, null, redirectedFrom ? redirectedFrom.request : null, this._id, payload.navigation ?? undefined,
289289
payload.request.url, resourceTypeFromBidi(payload.request.destination, payload.request.initiatorType, payload.initiator?.type), payload.request.method,
290290
postDataBuffer, headersOverride || fromBidiHeaders(payload.request.headers));
291291
// "raw" headers are the same as "provisional" headers in Bidi.

packages/playwright-core/src/server/chromium/crNetworkManager.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,10 @@ export class CRNetworkManager {
7676
if (this._page) {
7777
sessionInfo.eventListeners.push(...[
7878
eventsHelper.addEventListener(session, 'Network.webSocketCreated', e => this._page!.frameManager.onWebSocketCreated(e.requestId, e.url)),
79-
eventsHelper.addEventListener(session, 'Network.webSocketWillSendHandshakeRequest', e => this._page!.frameManager.onWebSocketRequest(e.requestId)),
80-
eventsHelper.addEventListener(session, 'Network.webSocketHandshakeResponseReceived', e => this._page!.frameManager.onWebSocketResponse(e.requestId, e.response.status, e.response.statusText)),
81-
eventsHelper.addEventListener(session, 'Network.webSocketFrameSent', e => e.response.payloadData && this._page!.frameManager.onWebSocketFrameSent(e.requestId, e.response.opcode, e.response.payloadData)),
82-
eventsHelper.addEventListener(session, 'Network.webSocketFrameReceived', e => e.response.payloadData && this._page!.frameManager.webSocketFrameReceived(e.requestId, e.response.opcode, e.response.payloadData)),
79+
eventsHelper.addEventListener(session, 'Network.webSocketWillSendHandshakeRequest', e => this._page!.frameManager.onWebSocketRequest(e.requestId, e.wallTime, e.timestamp, headersObjectToArray(e.request.headers, '\n'))),
80+
eventsHelper.addEventListener(session, 'Network.webSocketHandshakeResponseReceived', e => this._page!.frameManager.onWebSocketResponse(e.requestId, e.response.status, e.response.statusText, headersObjectToArray(e.response.headers, '\n'))),
81+
eventsHelper.addEventListener(session, 'Network.webSocketFrameSent', e => e.response.payloadData && this._page!.frameManager.onWebSocketFrameSent(e.requestId, e.response.opcode, e.response.payloadData, e.timestamp)),
82+
eventsHelper.addEventListener(session, 'Network.webSocketFrameReceived', e => e.response.payloadData && this._page!.frameManager.webSocketFrameReceived(e.requestId, e.response.opcode, e.response.payloadData, e.timestamp)),
8383
eventsHelper.addEventListener(session, 'Network.webSocketClosed', e => this._page!.frameManager.webSocketClosed(e.requestId)),
8484
eventsHelper.addEventListener(session, 'Network.webSocketFrameError', e => this._page!.frameManager.webSocketError(e.requestId, e.errorMessage)),
8585
]);
@@ -605,7 +605,7 @@ class InterceptableRequest {
605605
if (entries && entries.length)
606606
postDataBuffer = Buffer.concat(entries.map(entry => Buffer.from(entry.bytes!, 'base64')));
607607

608-
this.request = new network.Request(context, frame, serviceWorker, redirectedFrom?.request || null, documentId, url, toResourceType(requestWillBeSentEvent.type || 'Other'), method, postDataBuffer, headersOverride || headersObjectToArray(headers));
608+
this.request = new network.Request(context, frame, serviceWorker, redirectedFrom?.request || null, this._requestId, documentId, url, toResourceType(requestWillBeSentEvent.type || 'Other'), method, postDataBuffer, headersOverride || headersObjectToArray(headers));
609609
}
610610
}
611611

packages/playwright-core/src/server/firefox/ffNetworkManager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ class InterceptableRequest {
207207
let postDataBuffer = null;
208208
if (payload.postData)
209209
postDataBuffer = Buffer.from(payload.postData, 'base64');
210-
this.request = new network.Request(frame._page.browserContext, frame, null, redirectedFrom ? redirectedFrom.request : null, payload.navigationId,
210+
this.request = new network.Request(frame._page.browserContext, frame, null, redirectedFrom ? redirectedFrom.request : null, this._id, payload.navigationId,
211211
payload.url, internalCauseToResourceType[payload.internalCause] || causeToResourceType[payload.cause] || 'other', payload.method, postDataBuffer, payload.headers);
212212
// "raw" headers are the same as "provisional" headers in Firefox.
213213
this.request.setRawRequestHeaders(null);

packages/playwright-core/src/server/firefox/ffPage.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ export class FFPage implements PageDelegate {
9090
eventsHelper.addEventListener(this._session, 'Page.crashed', this._onCrashed.bind(this)),
9191

9292
eventsHelper.addEventListener(this._session, 'Page.webSocketCreated', this._onWebSocketCreated.bind(this)),
93+
eventsHelper.addEventListener(this._session, 'Page.webSocketOpened', this._onWebSocketOpened.bind(this)),
9394
eventsHelper.addEventListener(this._session, 'Page.webSocketClosed', this._onWebSocketClosed.bind(this)),
9495
eventsHelper.addEventListener(this._session, 'Page.webSocketFrameReceived', this._onWebSocketFrameReceived.bind(this)),
9596
eventsHelper.addEventListener(this._session, 'Page.webSocketFrameSent', this._onWebSocketFrameSent.bind(this)),
@@ -119,7 +120,10 @@ export class FFPage implements PageDelegate {
119120

120121
_onWebSocketCreated(event: Protocol.Page.webSocketCreatedPayload) {
121122
this._page.frameManager.onWebSocketCreated(webSocketId(event.frameId, event.wsid), event.requestURL);
122-
this._page.frameManager.onWebSocketRequest(webSocketId(event.frameId, event.wsid));
123+
}
124+
125+
_onWebSocketOpened(event: Protocol.Page.webSocketOpenedPayload) {
126+
this._page.frameManager.onWebSocketOpened(webSocketId(event.frameId, event.wsid), event.requestId);
123127
}
124128

125129
_onWebSocketClosed(event: Protocol.Page.webSocketClosedPayload) {
@@ -129,11 +133,11 @@ export class FFPage implements PageDelegate {
129133
}
130134

131135
_onWebSocketFrameReceived(event: Protocol.Page.webSocketFrameReceivedPayload) {
132-
this._page.frameManager.webSocketFrameReceived(webSocketId(event.frameId, event.wsid), event.opcode, event.data);
136+
this._page.frameManager.webSocketFrameReceived(webSocketId(event.frameId, event.wsid), event.opcode, event.data, event.timestamp);
133137
}
134138

135139
_onWebSocketFrameSent(event: Protocol.Page.webSocketFrameSentPayload) {
136-
this._page.frameManager.onWebSocketFrameSent(webSocketId(event.frameId, event.wsid), event.opcode, event.data);
140+
this._page.frameManager.onWebSocketFrameSent(webSocketId(event.frameId, event.wsid), event.opcode, event.data, event.timestamp);
137141
}
138142

139143
_onExecutionContextCreated(payload: Protocol.Runtime.executionContextCreatedPayload) {

packages/playwright-core/src/server/firefox/protocol.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,12 +486,14 @@ export namespace Protocol {
486486
wsid: string;
487487
opcode: number;
488488
data: string;
489+
timestamp: number;
489490
}
490491
export type webSocketFrameReceivedPayload = {
491492
frameId: string;
492493
wsid: string;
493494
opcode: number;
494495
data: string;
496+
timestamp: number;
495497
}
496498
export type screencastFramePayload = {
497499
data: string;

packages/playwright-core/src/server/frames.ts

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ export class FrameManager {
106106
private _mainFrame: Frame;
107107
readonly _consoleMessageTags = new Map<string, ConsoleTagHandler>();
108108
readonly _signalBarriers = new Set<SignalBarrier>();
109-
private _webSockets = new Map<string, network.WebSocket>();
109+
private _webSockets = new Map</* webSocketId */ string, network.WebSocket>();
110110
private _nextFrameSeq = 0;
111111

112112
constructor(page: Page) {
@@ -398,46 +398,67 @@ export class FrameManager {
398398
this._webSockets.clear();
399399
}
400400

401-
onWebSocketCreated(requestId: string, url: string) {
401+
onWebSocketCreated(webSocketId: string, url: string) {
402402
const ws = new network.WebSocket(this._page, url);
403-
this._webSockets.set(requestId, ws);
403+
this._webSockets.set(webSocketId, ws);
404404
}
405405

406-
onWebSocketRequest(requestId: string) {
407-
const ws = this._webSockets.get(requestId);
408-
if (ws && ws.markAsNotified())
406+
onWebSocketOpened(webSocketId: string, requestId: string) {
407+
const ws = this._webSockets.get(webSocketId);
408+
if (!ws)
409+
return;
410+
411+
if (ws.markAsNotified())
409412
this._page.emit(Page.Events.WebSocket, ws);
413+
414+
// In Firefox, WebSocket lifecycle events actually come through the normal Network events.
415+
ws.opened(requestId);
410416
}
411417

412-
onWebSocketResponse(requestId: string, status: number, statusText: string) {
413-
const ws = this._webSockets.get(requestId);
414-
if (status < 400)
418+
onWebSocketRequest(webSocketId: string, wallTime: number, timestamp: number, headers: types.HeadersArray) {
419+
const ws = this._webSockets.get(webSocketId);
420+
if (!ws)
415421
return;
416-
if (ws)
422+
423+
if (ws.markAsNotified())
424+
this._page.emit(Page.Events.WebSocket, ws);
425+
426+
// In Chromium and WebKit, WebSocket lifecycle events are entirely separate from normal Network events.
427+
ws.requestSent(wallTime, timestamp, headers);
428+
}
429+
430+
onWebSocketResponse(webSocketId: string, status: number, statusText: string, headers: types.HeadersArray) {
431+
const ws = this._webSockets.get(webSocketId);
432+
if (!ws)
433+
return;
434+
435+
// In Chromium and WebKit, WebSocket lifecycle events are entirely separate from normal Network events.
436+
ws.responseReceived(status, statusText, headers);
437+
if (status >= 400)
417438
ws.error(`${statusText}: ${status}`);
418439
}
419440

420-
onWebSocketFrameSent(requestId: string, opcode: number, data: string) {
421-
const ws = this._webSockets.get(requestId);
441+
onWebSocketFrameSent(webSocketId: string, opcode: number, data: string, timestamp: number) {
442+
const ws = this._webSockets.get(webSocketId);
422443
if (ws)
423-
ws.frameSent(opcode, data);
444+
ws.frameSent(opcode, data, timestamp);
424445
}
425446

426-
webSocketFrameReceived(requestId: string, opcode: number, data: string) {
427-
const ws = this._webSockets.get(requestId);
447+
webSocketFrameReceived(webSocketId: string, opcode: number, data: string, timestamp: number) {
448+
const ws = this._webSockets.get(webSocketId);
428449
if (ws)
429-
ws.frameReceived(opcode, data);
450+
ws.frameReceived(opcode, data, timestamp);
430451
}
431452

432-
webSocketClosed(requestId: string) {
433-
const ws = this._webSockets.get(requestId);
453+
webSocketClosed(webSocketId: string) {
454+
const ws = this._webSockets.get(webSocketId);
434455
if (ws)
435456
ws.closed();
436-
this._webSockets.delete(requestId);
457+
this._webSockets.delete(webSocketId);
437458
}
438459

439-
webSocketError(requestId: string, errorMessage: string): void {
440-
const ws = this._webSockets.get(requestId);
460+
webSocketError(webSocketId: string, errorMessage: string): void {
461+
const ws = this._webSockets.get(webSocketId);
441462
if (ws)
442463
ws.error(errorMessage);
443464
}

packages/playwright-core/src/server/har/harTracer.ts

Lines changed: 95 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,10 @@ import { helper } from '../helper';
3030
import * as network from '../network';
3131
import { nullProgress } from '../progress';
3232

33+
import { Page } from '../page';
34+
3335
import type { RegisteredListener } from '@utils/eventsHelper';
3436
import type { APIRequestEvent, APIRequestFinishedEvent } from '../fetch';
35-
import type { Page } from '../page';
3637
import type { Worker } from '../page';
3738
import type { HeadersArray, LifecycleEvent } from '../types';
3839
import type * as har from '@trace/har';
@@ -70,6 +71,7 @@ export class HarTracer {
7071
private _eventListeners: RegisteredListener[] = [];
7172
private _started = false;
7273
private _entrySymbol: symbol;
74+
private _webSocketEntries = new Map</* requestId */ string, har.Entry>();
7375
private _baseURL: string | undefined;
7476
private _page: Page | null;
7577

@@ -102,7 +104,10 @@ export class HarTracer {
102104
];
103105
if (this._context instanceof BrowserContext) {
104106
this._eventListeners.push(
105-
eventsHelper.addEventListener(this._context, BrowserContext.Events.Page, (page: Page) => this._createPageEntryIfNeeded(page)),
107+
eventsHelper.addEventListener(this._context, BrowserContext.Events.Page, (page: Page) => {
108+
this._addPageEventListeners(page);
109+
this._createPageEntryIfNeeded(page);
110+
}),
106111
eventsHelper.addEventListener(this._context, BrowserContext.Events.Request, (request: network.Request) => this._onRequest(request)),
107112
eventsHelper.addEventListener(this._context, BrowserContext.Events.RequestFinished, ({ request, response }) => this._onRequestFinished(request, response).catch(() => {})),
108113
eventsHelper.addEventListener(this._context, BrowserContext.Events.RequestFailed, request => this._onRequestFailed(request)),
@@ -111,11 +116,21 @@ export class HarTracer {
111116
eventsHelper.addEventListener(this._context, BrowserContext.Events.RequestFulfilled, request => this._onRequestFulfilled(request)),
112117
eventsHelper.addEventListener(this._context, BrowserContext.Events.RequestContinued, request => this._onRequestContinued(request)),
113118
);
114-
for (const page of this._context.pages())
119+
for (const page of this._context.pages()) {
120+
this._addPageEventListeners(page);
115121
this._createPageEntryIfNeeded(page);
122+
}
116123
}
117124
}
118125

126+
private _addPageEventListeners(page: Page) {
127+
if (this._page && page !== this._page)
128+
return;
129+
this._eventListeners.push(
130+
eventsHelper.addEventListener(page, Page.Events.WebSocket, (webSocket: network.WebSocket) => this._onWebSocket(page, webSocket)),
131+
);
132+
}
133+
119134
private _shouldIncludeEntryWithUrl(urlString: string) {
120135
return !this._options.urlFilter || urlMatches(this._baseURL, urlString, this._options.urlFilter);
121136
}
@@ -281,6 +296,10 @@ export class HarTracer {
281296
fromEntry.response.redirectURL = request.url();
282297
}
283298
(request as any)[this._entrySymbol] = harEntry;
299+
// In Firefox, WebSockets have additional events once opened.
300+
// In Chromium and WebKit, WebSockets have an entirely different lifecycle and won't reach this.
301+
if (request.resourceType() === 'websocket')
302+
this._webSocketEntries.set(request.requestId(), harEntry);
284303
assert(this._started);
285304
this._delegate.onEntryStarted(harEntry);
286305
}
@@ -362,6 +381,11 @@ export class HarTracer {
362381
}).catch(() => {
363382
compressionCalculationBarrier?.setDecodedBodySize(0);
364383
}).then(() => {
384+
// In Firefox, WebSockets have additional events once opened.
385+
// In Chromium and WebKit, WebSockets have an entirely different lifecycle and won't reach this.
386+
if (request.resourceType() === 'websocket')
387+
return;
388+
365389
if (this._started)
366390
this._delegate.onEntryFinished(harEntry);
367391
});
@@ -418,6 +442,74 @@ export class HarTracer {
418442
harEntry._wasContinued = true;
419443
}
420444

445+
private _onWebSocket(page: Page, webSocket: network.WebSocket) {
446+
if (!this._shouldIncludeEntryWithUrl(webSocket.url()))
447+
return;
448+
const url = network.parseURL(webSocket.url());
449+
if (!url)
450+
return;
451+
452+
let harEntry: har.Entry | undefined = undefined;
453+
let matchingRequestId: string | undefined = undefined;
454+
455+
// Only listen for the rest of the WebSocket lifecycle once it's been opened.
456+
const addRemainingListeners = () => {
457+
this._eventListeners.push(
458+
eventsHelper.addEventListener(webSocket, network.WebSocket.Events.Response, ({ status, statusText, headers }: { status: number, statusText: string, headers: HeadersArray }) => {
459+
harEntry!.response.status = status;
460+
harEntry!.response.statusText = statusText;
461+
this._recordResponseHeaders(harEntry!, headers);
462+
}),
463+
eventsHelper.addEventListener(webSocket, network.WebSocket.Events.FrameSent, ({ opcode, data, timestamp }: { opcode: number, data: string, timestamp: number }) => {
464+
harEntry!._webSocketMessages!.push({ type: 'send', time: timestamp, opcode, data });
465+
}),
466+
eventsHelper.addEventListener(webSocket, network.WebSocket.Events.FrameReceived, ({ opcode, data, timestamp }: { opcode: number, data: string, timestamp: number }) => {
467+
harEntry!._webSocketMessages!.push({ type: 'receive', time: timestamp, opcode, data });
468+
}),
469+
eventsHelper.addEventListener(webSocket, network.WebSocket.Events.SocketError, (errorMessage: string) => {
470+
harEntry!.response._failureText = errorMessage;
471+
}),
472+
eventsHelper.addEventListener(webSocket, network.WebSocket.Events.Close, () => {
473+
if (matchingRequestId !== undefined)
474+
this._webSocketEntries.delete(matchingRequestId);
475+
476+
if (this._started)
477+
this._delegate.onEntryFinished(harEntry!);
478+
}),
479+
);
480+
};
481+
482+
this._eventListeners.push(
483+
// In Firefox, WebSocket lifecycle events actually come through the normal Network events.
484+
eventsHelper.addEventListener(webSocket, network.WebSocket.Events.Open, ({ requestId }: { requestId: string }) => {
485+
matchingRequestId = requestId;
486+
487+
harEntry = this._webSocketEntries.get(matchingRequestId);
488+
if (!harEntry)
489+
return;
490+
491+
harEntry.request.url = webSocket.url();
492+
harEntry._resourceType = 'websocket';
493+
harEntry._webSocketMessages = [];
494+
495+
addRemainingListeners();
496+
}),
497+
// In Chromium and WebKit, WebSocket lifecycle events are entirely separate from normal Network events.
498+
eventsHelper.addEventListener(webSocket, network.WebSocket.Events.Request, ({ headers }: { headers: HeadersArray }) => {
499+
const pageEntry = this._createPageEntryIfNeeded(page);
500+
harEntry = createHarEntry(pageEntry?.id, 'GET', url, page.mainFrame().guid, this._options);
501+
harEntry._resourceType = 'websocket';
502+
harEntry._webSocketMessages = [];
503+
this._recordRequestHeadersAndCookies(harEntry, headers);
504+
505+
if (this._started)
506+
this._delegate.onEntryStarted(harEntry);
507+
508+
addRemainingListeners();
509+
}),
510+
);
511+
}
512+
421513
private _storeResponseContent(buffer: Buffer | undefined, content: har.Content, resourceType: string) {
422514
if (!buffer) {
423515
content.size = 0;

0 commit comments

Comments
 (0)