diff --git a/packages/playwright-core/src/server/har/harTracer.ts b/packages/playwright-core/src/server/har/harTracer.ts index 9490437fdaa82..b1492362f2f74 100644 --- a/packages/playwright-core/src/server/har/harTracer.ts +++ b/packages/playwright-core/src/server/har/harTracer.ts @@ -75,6 +75,7 @@ export class HarTracer { private _pageEntrySymbol: symbol; private _baseURL: string | undefined; private _page: Page | null; + private _saveOpenWebSocketMessagesFunctions = new Set<() => void>(); constructor(context: BrowserContext | APIRequestContext, page: Page | null, delegate: HarTracerDelegate, options: HarTracerOptions) { this._context = context; @@ -438,7 +439,28 @@ export class HarTracer { const pageEntry = this._createPageEntryIfNeeded(page); const harEntry = createHarEntry(pageEntry?.id, method, url, page.mainFrame().guid, this._options, webSocket.wallTimeMs()); harEntry._resourceType = 'websocket'; - harEntry._webSocketMessages = []; + + const messages: har.WebSocketMessage[] = []; + if (this._options.content === 'embed') + harEntry._webSocketMessages = messages; + + let saveMessages: (() => void) | undefined; + if (this._options.content === 'attach') { + saveMessages = () => { + if (!messages.length) + return; + + const buffer = Buffer.from(JSON.stringify(messages)); + const sha1 = calculateSha1(buffer) + '.json'; + if (this._options.includeTraceInfo) + harEntry.response.content._sha1 = sha1; + else + harEntry.response.content._file = sha1; + if (this._started) + this._delegate.onContentBlob(sha1, buffer); + }; + this._saveOpenWebSocketMessagesFunctions.add(saveMessages); + } let oldestWallTimeMs = Infinity; let newestWallTimeMs = -Infinity; @@ -475,12 +497,17 @@ export class HarTracer { } }), eventsHelper.addEventListener(webSocket, network.WebSocket.Events.FrameSent, ({ opcode, data, wallTimeMs }: { opcode: number, data: string, wallTimeMs: number }) => { - harEntry._webSocketMessages!.push({ type: 'send', time: this._options.omitTiming ? -1 : wallTimeMs, opcode, data }); + if (this._options.content !== 'omit') + messages.push({ type: 'send', time: this._options.omitTiming ? -1 : wallTimeMs, opcode, data }); + updateTime(wallTimeMs); }), eventsHelper.addEventListener(webSocket, network.WebSocket.Events.FrameReceived, ({ opcode, data, wallTimeMs }: { opcode: number, data: string, wallTimeMs: number }) => { - harEntry._webSocketMessages!.push({ type: 'receive', time: this._options.omitTiming ? -1 : wallTimeMs, opcode, data }); + if (this._options.content !== 'omit') + messages.push({ type: 'receive', time: this._options.omitTiming ? -1 : wallTimeMs, opcode, data }); + updateTime(wallTimeMs); + if (!this._options.omitSizes) { const length = (opcode === 1) ? Buffer.byteLength(data, 'utf8') : base64ByteLength(data); @@ -503,6 +530,11 @@ export class HarTracer { eventsHelper.addEventListener(webSocket, network.WebSocket.Events.Close, () => { eventsHelper.removeEventListeners(eventListeners); + if (saveMessages) { + this._saveOpenWebSocketMessagesFunctions.delete(saveMessages); + saveMessages(); + } + if (this._started) this._delegate.onEntryFinished(harEntry); }), @@ -633,6 +665,12 @@ export class HarTracer { } stop() { + // Unlike other requests that have a single response, a WebSocket can receive multiple frames. + // As such, we don't finish the entry until the WebSocket is closed, which delays when the captured frames are saved. + // Make sure to save at least what has been captured so far. + for (const saveOpenWebSocketMessages of this._saveOpenWebSocketMessagesFunctions) + saveOpenWebSocketMessages(); + this._started = false; eventsHelper.removeEventListeners(this._eventListeners); this._barrierPromises.clear(); @@ -665,6 +703,7 @@ export class HarTracer { } } this._pageEntries = []; + this._saveOpenWebSocketMessagesFunctions.clear(); return log; } diff --git a/tests/library/har-websocket.spec.ts b/tests/library/har-websocket.spec.ts index c8f1b0305367a..a6c92bf4d455e 100644 --- a/tests/library/har-websocket.spec.ts +++ b/tests/library/har-websocket.spec.ts @@ -16,6 +16,7 @@ */ import { browserTest as it, expect } from '../config/browserTest'; +import { parseHar } from '../config/utils'; import fs from 'fs'; import net from 'net'; import type { BrowserContext, BrowserContextOptions } from 'playwright-core'; @@ -33,6 +34,10 @@ async function pageWithHar(contextFactory: (options?: BrowserContextOptions) => await context.close(); return JSON.parse(fs.readFileSync(harPath).toString())['log'] as Log; }, + getZip: async () => { + await context.close(); + return parseHar(harPath); + }, }; } @@ -56,7 +61,17 @@ function responseHeadersSize(headers: { name: string, value: string }[]): number return result; } -it('should only have one websocket entry', async ({ contextFactory, server, browserName }, testInfo) => { +function messageSize(message: string | number[]): number { + // The payload is short enough that they only need the minimum frame header size. + if (message.length <= 125) + return 6 + message.length; + // The payload is large enough that additional bytes are needed to represent the payload length. + if (message.length <= 2 ** 16) + return 6 + 2 + message.length; + return 6 + 8 + message.length; +} + +it('should only have one websocket entry', async ({ contextFactory, server }, testInfo) => { server.onceWebSocketConnection(ws => { ws.on('message', () => ws.close()); }); @@ -77,7 +92,7 @@ it('should only have one websocket entry', async ({ contextFactory, server, brow expect(wsEntry._resourceType).toBe('websocket'); }); -it('should include websocket handshake headers and status', async ({ contextFactory, server, browserName }, testInfo) => { +it('should include websocket handshake headers and status', async ({ contextFactory, server }, testInfo) => { server.onceWebSocketConnection(ws => { ws.on('message', () => ws.close()); }); @@ -116,11 +131,14 @@ it('should include websocket handshake headers and status', async ({ contextFact }); it('should include websocket messages', async ({ contextFactory, server }, testInfo) => { - const incoming = 'x'.repeat(125); - const outgoing = 'outgoing'; + const incomingText = 'x'.repeat(125); + const incomingBinary = (new Array(125)).fill(0x01); + const outgoingText = 'outgoing'; + const outgoingBinary = [0x02, 0x03, 0x04, 0x05]; server.onceWebSocketConnection(ws => { - ws.on('message', () => ws.send(incoming)); + let count = 0; + ws.on('message', () => ws.send((++count < 2) ? incomingText : Buffer.from(incomingBinary))); }); const { page, getLog } = await pageWithHar(contextFactory, testInfo); @@ -128,24 +146,31 @@ it('should include websocket messages', async ({ contextFactory, server }, testI const beforeMs = Date.now(); const wsUrl = `ws://${server.HOST}/ws`; - const closed = page.evaluate(({ url, outgoing }) => new Promise(resolve => { + const closed = page.evaluate(({ url, outgoingText, outgoingBinary }) => new Promise(resolve => { + let count = 0; const ws = new WebSocket(url); - ws.addEventListener('open', () => ws.send(outgoing)); - ws.addEventListener('message', () => ws.close()); + ws.addEventListener('open', () => ws.send(outgoingText)); + ws.addEventListener('message', () => { + if (++count < 2) + ws.send(new Uint8Array(outgoingBinary)); + else + ws.close(); + }); ws.addEventListener('close', () => resolve()); - }), { url: wsUrl, outgoing }); + }), { url: wsUrl, outgoingText, outgoingBinary }); await closed; const afterMs = Date.now(); const log = await getLog(); const wsEntry = log.entries.find(e => e.request.url === wsUrl)! as Entry; - // The payload is short enough that they only need the minimum frame header size. - expect(wsEntry.response._transferSize).toBe(responseHeadersSize(wsEntry.response.headers) + 6 + incoming.length); + expect(wsEntry.response._transferSize).toBe(responseHeadersSize(wsEntry.response.headers) + messageSize(incomingText) + messageSize(incomingBinary)); 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 }, + expect(messages.map(m => ({ type: m.type, opcode: m.opcode, data: m.opcode === 1 ? m.data : [...Buffer.from(m.data, 'base64')] }))).toEqual([ + { type: 'send', opcode: 1, data: outgoingText }, + { type: 'receive', opcode: 1, data: incomingText }, + { type: 'send', opcode: 2, data: outgoingBinary }, + { type: 'receive', opcode: 2, data: incomingBinary }, ]); for (const m of messages) { expect(m.time).toBeGreaterThanOrEqual(beforeMs - 1); @@ -155,11 +180,14 @@ it('should include websocket messages', async ({ contextFactory, server }, testI }); it('should include larger websocket messages', async ({ contextFactory, server }, testInfo) => { - const incoming = 'x'.repeat(126); - const outgoing = 'outgoing'; + const incomingText = 'x'.repeat(126); + const incomingBinary = (new Array(126)).fill(0x01); + const outgoingText = 'outgoing'; + const outgoingBinary = [0x02, 0x03, 0x04, 0x05]; server.onceWebSocketConnection(ws => { - ws.on('message', () => ws.send(incoming)); + let count = 0; + ws.on('message', () => ws.send((++count < 2) ? incomingText : Buffer.from(incomingBinary))); }); const { page, getLog } = await pageWithHar(contextFactory, testInfo); @@ -167,24 +195,31 @@ it('should include larger websocket messages', async ({ contextFactory, server } const beforeMs = Date.now(); const wsUrl = `ws://${server.HOST}/ws`; - const closed = page.evaluate(({ url, outgoing }) => new Promise(resolve => { + const closed = page.evaluate(({ url, outgoingText, outgoingBinary }) => new Promise(resolve => { + let count = 0; const ws = new WebSocket(url); - ws.addEventListener('open', () => ws.send(outgoing)); - ws.addEventListener('message', () => ws.close()); + ws.addEventListener('open', () => ws.send(outgoingText)); + ws.addEventListener('message', () => { + if (++count < 2) + ws.send(new Uint8Array(outgoingBinary)); + else + ws.close(); + }); ws.addEventListener('close', () => resolve()); - }), { url: wsUrl, outgoing }); + }), { url: wsUrl, outgoingText, outgoingBinary }); await closed; const afterMs = Date.now(); const log = await getLog(); const wsEntry = log.entries.find(e => e.request.url === wsUrl)! as Entry; - // The payload is large enough that additional bytes are needed to represent the payload length. - expect(wsEntry.response._transferSize).toBe(responseHeadersSize(wsEntry.response.headers) + 6 + 2 + incoming.length); + expect(wsEntry.response._transferSize).toBe(responseHeadersSize(wsEntry.response.headers) + messageSize(incomingText) + messageSize(incomingBinary)); 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 }, + expect(messages.map(m => ({ type: m.type, opcode: m.opcode, data: m.opcode === 1 ? m.data : [...Buffer.from(m.data, 'base64')] }))).toEqual([ + { type: 'send', opcode: 1, data: outgoingText }, + { type: 'receive', opcode: 1, data: incomingText }, + { type: 'send', opcode: 2, data: outgoingBinary }, + { type: 'receive', opcode: 2, data: incomingBinary }, ]); for (const m of messages) { expect(m.time).toBeGreaterThanOrEqual(beforeMs - 1); @@ -194,50 +229,14 @@ it('should include larger websocket messages', async ({ contextFactory, server } }); it('should include gigantic websocket messages', async ({ contextFactory, server }, testInfo) => { - const incoming = 'x'.repeat(2 ** 16 + 1); - const outgoing = 'outgoing'; - - 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, outgoing }) => new Promise(resolve => { - const ws = new WebSocket(url); - ws.addEventListener('open', () => ws.send(outgoing)); - ws.addEventListener('message', () => ws.close()); - ws.addEventListener('close', () => resolve()); - }), { url: wsUrl, outgoing }); - await closed; - const afterMs = Date.now(); - const log = await getLog(); - - const wsEntry = log.entries.find(e => e.request.url === wsUrl)! as Entry; - // The payload is large enough that additional bytes are needed to represent the payload length. - expect(wsEntry.response._transferSize).toBe(responseHeadersSize(wsEntry.response.headers) + 6 + 8 + incoming.length); - - 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 - 1); - expect(m.time).toBeLessThanOrEqual(afterMs + 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]; + const incomingText = 'x'.repeat(2 ** 16 + 1); + const incomingBinary = (new Array(2 ** 16 + 1)).fill(0x01); + const outgoingText = 'outgoing'; + const outgoingBinary = [0x02, 0x03, 0x04, 0x05]; server.onceWebSocketConnection(ws => { - ws.on('message', () => ws.send(Buffer.from(incoming))); + let count = 0; + ws.on('message', () => ws.send((++count < 2) ? incomingText : Buffer.from(incomingBinary))); }); const { page, getLog } = await pageWithHar(contextFactory, testInfo); @@ -245,25 +244,31 @@ it('should include binary websocket messages', async ({ contextFactory, server } const beforeMs = Date.now(); const wsUrl = `ws://${server.HOST}/ws`; - const closed = page.evaluate(({ url, outgoing }) => new Promise(resolve => { + const closed = page.evaluate(({ url, outgoingText, outgoingBinary }) => new Promise(resolve => { + let count = 0; const ws = new WebSocket(url); - ws.binaryType = 'arraybuffer'; - ws.addEventListener('open', () => ws.send(new Uint8Array(outgoing))); - ws.addEventListener('message', () => ws.close()); + ws.addEventListener('open', () => ws.send(outgoingText)); + ws.addEventListener('message', () => { + if (++count < 2) + ws.send(new Uint8Array(outgoingBinary)); + else + ws.close(); + }); ws.addEventListener('close', () => resolve()); - }), { url: wsUrl, outgoing }); + }), { url: wsUrl, outgoingText, outgoingBinary }); await closed; const afterMs = Date.now(); const log = await getLog(); const wsEntry = log.entries.find(e => e.request.url === wsUrl)! as Entry; - // The payload is short enough that they only need the minimum frame header size. - expect(wsEntry.response._transferSize).toBe(responseHeadersSize(wsEntry.response.headers) + 6 + incoming.length); + expect(wsEntry.response._transferSize).toBe(responseHeadersSize(wsEntry.response.headers) + messageSize(incomingText) + messageSize(incomingBinary)); const messages = wsEntry._webSocketMessages; - expect(messages.map(m => ({ type: m.type, opcode: m.opcode, data: [...Buffer.from(m.data, 'base64')] }))).toEqual([ - { type: 'send', opcode: 2, data: outgoing }, - { type: 'receive', opcode: 2, data: incoming }, + expect(messages.map(m => ({ type: m.type, opcode: m.opcode, data: m.opcode === 1 ? m.data : [...Buffer.from(m.data, 'base64')] }))).toEqual([ + { type: 'send', opcode: 1, data: outgoingText }, + { type: 'receive', opcode: 1, data: incomingText }, + { type: 'send', opcode: 2, data: outgoingBinary }, + { type: 'receive', opcode: 2, data: incomingBinary }, ]); for (const m of messages) { expect(m.time).toBeGreaterThanOrEqual(beforeMs - 1); @@ -310,6 +315,160 @@ it('should include websocket entry time across multiple messages', async ({ cont expect(wsEntry.time).toBeLessThanOrEqual(afterMs - beforeMs); }); +it('should attach websocket messages', async ({ contextFactory, server }, testInfo) => { + const incomingText = 'incoming'; + const incomingBinary = [0x01, 0x02, 0x03, 0x04]; + const outgoingText = 'outgoing'; + const outgoingBinary = [0x05, 0x06, 0x07, 0x08]; + + server.onceWebSocketConnection(ws => { + let count = 0; + ws.on('message', () => ws.send((++count < 2) ? incomingText : Buffer.from(incomingBinary))); + }); + + const { page, getZip } = await pageWithHar(contextFactory, testInfo, { content: 'attach', outputPath: 'test.har.zip' }); + await page.goto(server.EMPTY_PAGE); + + const beforeMs = Date.now(); + const wsUrl = `ws://${server.HOST}/ws`; + const closed = page.evaluate(({ url, outgoingText, outgoingBinary }) => new Promise(resolve => { + let count = 0; + const ws = new WebSocket(url); + ws.addEventListener('open', () => ws.send(outgoingText)); + ws.addEventListener('message', () => { + if (++count < 2) + ws.send(new Uint8Array(outgoingBinary)); + else + ws.close(); + }); + ws.addEventListener('close', () => resolve()); + }), { url: wsUrl, outgoingText, outgoingBinary }); + await closed; + const afterMs = Date.now(); + const zip = await getZip(); + const log = JSON.parse(zip.get('har.har')!.toString())['log'] as Log; + + const wsEntry = log.entries.find(e => e.request.url === wsUrl)! as Entry; + expect(wsEntry.response._transferSize).toBe(responseHeadersSize(wsEntry.response.headers) + messageSize(incomingText) + messageSize(incomingBinary)); + expect(wsEntry._webSocketMessages).toBeUndefined(); + + const file = wsEntry.response.content._file!; + expect(file).toMatch(/^[0-9a-f]{40}\.json$/); + + const messages = JSON.parse(zip.get(file)!.toString()) as Array<{ type: string, time: number, opcode: number, data: string }>; + expect(messages.map(m => ({ type: m.type, opcode: m.opcode, data: m.opcode === 1 ? m.data : [...Buffer.from(m.data, 'base64')] }))).toEqual([ + { type: 'send', opcode: 1, data: outgoingText }, + { type: 'receive', opcode: 1, data: incomingText }, + { type: 'send', opcode: 2, data: outgoingBinary }, + { type: 'receive', opcode: 2, data: incomingBinary }, + ]); + for (const m of messages) { + expect(m.time).toBeGreaterThanOrEqual(beforeMs - 1); + expect(m.time).toBeLessThanOrEqual(afterMs + 1); + } + expect(messages[0].time).toBeLessThanOrEqual(messages[1].time); +}); + +it('should attach websocket messages for a still open websocket after stopping', async ({ contextFactory, server }, testInfo) => { + const incomingText = 'incoming'; + const incomingBinary = [0x01, 0x02, 0x03, 0x04]; + const outgoingText = 'outgoing'; + const outgoingBinary = [0x05, 0x06, 0x07, 0x08]; + + server.onceWebSocketConnection(ws => { + let count = 0; + ws.on('message', () => ws.send((++count < 2) ? incomingText : Buffer.from(incomingBinary))); + }); + + const { page, getZip } = await pageWithHar(contextFactory, testInfo, { content: 'attach', outputPath: 'test.har.zip' }); + await page.goto(server.EMPTY_PAGE); + + const beforeMs = Date.now(); + const wsUrl = `ws://${server.HOST}/ws`; + const [ws] = await Promise.all([ + page.waitForEvent('websocket'), + page.evaluate(({ url, outgoingText, outgoingBinary }) => { + const ws = new WebSocket(url); + (window as any).ws = ws; + let count = 0; + ws.addEventListener('open', () => ws.send(outgoingText)); + ws.addEventListener('message', () => { + if (++count < 2) + ws.send(new Uint8Array(outgoingBinary)); + }); + }, { url: wsUrl, outgoingText, outgoingBinary }), + ]); + // Wait for all frames so the HAR tracer has observed them before the context is closed. + await ws.waitForEvent('framesent'); + await ws.waitForEvent('framereceived'); + await ws.waitForEvent('framesent'); + await ws.waitForEvent('framereceived'); + const afterMs = Date.now(); + + // Do not close the WebSocket on the page side. Closing the context should still flush messages. + expect(await page.evaluate(() => (window as any).ws.readyState)).toBe(1 /* OPEN */); + + const zip = await getZip(); + const log = JSON.parse(zip.get('har.har')!.toString())['log'] as Log; + + const wsEntry = log.entries.find(e => e.request.url === wsUrl)! as Entry; + expect(wsEntry.response._transferSize).toBe(responseHeadersSize(wsEntry.response.headers) + messageSize(incomingText) + messageSize(incomingBinary)); + expect(wsEntry._webSocketMessages).toBeUndefined(); + + const file = wsEntry.response.content._file!; + expect(file).toMatch(/^[0-9a-f]{40}\.json$/); + + const messages = JSON.parse(zip.get(file)!.toString()) as Array<{ type: string, time: number, opcode: number, data: string }>; + expect(messages.map(m => ({ type: m.type, opcode: m.opcode, data: m.opcode === 1 ? m.data : [...Buffer.from(m.data, 'base64')] }))).toEqual([ + { type: 'send', opcode: 1, data: outgoingText }, + { type: 'receive', opcode: 1, data: incomingText }, + { type: 'send', opcode: 2, data: outgoingBinary }, + { type: 'receive', opcode: 2, data: incomingBinary }, + ]); + for (const m of messages) { + expect(m.time).toBeGreaterThanOrEqual(beforeMs - 1); + expect(m.time).toBeLessThanOrEqual(afterMs + 1); + } + expect(messages[0].time).toBeLessThanOrEqual(messages[1].time); +}); + +it('should omit websocket messages', async ({ contextFactory, server }, testInfo) => { + const incomingText = 'incoming'; + const incomingBinary = [0x01, 0x02, 0x03, 0x04]; + const outgoingText = 'outgoing'; + const outgoingBinary = [0x05, 0x06, 0x07, 0x08]; + + server.onceWebSocketConnection(ws => { + let count = 0; + ws.on('message', () => ws.send((++count < 2) ? incomingText : Buffer.from(incomingBinary))); + }); + + const { page, getZip } = await pageWithHar(contextFactory, testInfo, { content: 'omit', outputPath: 'test.har.zip' }); + await page.goto(server.EMPTY_PAGE); + + const wsUrl = `ws://${server.HOST}/ws`; + const closed = page.evaluate(({ url, outgoingText, outgoingBinary }) => new Promise(resolve => { + let count = 0; + const ws = new WebSocket(url); + ws.addEventListener('open', () => ws.send(outgoingText)); + ws.addEventListener('message', () => { + if (++count < 2) + ws.send(new Uint8Array(outgoingBinary)); + else + ws.close(); + }); + ws.addEventListener('close', () => resolve()); + }), { url: wsUrl, outgoingText, outgoingBinary }); + await closed; + const zip = await getZip(); + const log = JSON.parse(zip.get('har.har')!.toString())['log'] as Log; + + const wsEntry = log.entries.find(e => e.request.url === wsUrl)! as Entry; + expect(wsEntry._webSocketMessages).toBeUndefined(); + expect(wsEntry.response._transferSize).toBe(responseHeadersSize(wsEntry.response.headers) + messageSize(incomingText) + messageSize(incomingBinary)); + expect(wsEntry.response.content._file).toBeUndefined(); +}); + 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();