From 0f73f7c451994bcddcd03c92994972a6651d1201 Mon Sep 17 00:00:00 2001 From: "microsoft-playwright-automation[bot]" <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 08:52:52 -0700 Subject: [PATCH 1/4] feat(webkit): roll to r2310 (#41226) --- packages/playwright-core/browsers.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 1e5a956654504..000e53ee5446d 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -45,7 +45,7 @@ }, { "name": "webkit", - "revision": "2309", + "revision": "2310", "installByDefault": true, "revisionOverrides": { "mac14": "2251", From 2453651168c82e616ebf2876c6a56eb656bd3eba Mon Sep 17 00:00:00 2001 From: Aleksei Torsukov <143401525+atrskv@users.noreply.github.com> Date: Wed, 10 Jun 2026 19:22:48 +0300 Subject: [PATCH 2/4] docs: add uv and Poetry installation instructions (#41231) fixes --- docs/src/intro-python.md | 60 ++++++++++++++++++++++++++++++++++++++ docs/src/library-python.md | 36 +++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/docs/src/intro-python.md b/docs/src/intro-python.md index dc4c7b9885b60..190c4517d01d2 100644 --- a/docs/src/intro-python.md +++ b/docs/src/intro-python.md @@ -23,10 +23,42 @@ Get started by installing Playwright and running the example test to see it in a Install the [Pytest plugin](https://pypi.org/project/pytest-playwright/): + + + + ```bash pip install pytest-playwright ``` + + + + +```bash +poetry add pytest-playwright +``` + + + + + +```bash +uv add pytest-playwright +``` + + + + + Install the required browsers: ```bash @@ -69,10 +101,38 @@ pytest To update Playwright to the latest version run the following command: + + + ```bash pip install pytest-playwright playwright -U ``` + + + +```bash +poetry update pytest-playwright playwright +``` + + + + +```bash +uv add --upgrade pytest-playwright playwright +``` + + + + ## System requirements - Python 3.8 or higher. diff --git a/docs/src/library-python.md b/docs/src/library-python.md index bd78b01576f39..9dcf0cba99e10 100644 --- a/docs/src/library-python.md +++ b/docs/src/library-python.md @@ -7,12 +7,48 @@ title: "Getting started - Library" [PyPI version](https://pypi.python.org/pypi/playwright/) + + + + ```bash pip install --upgrade pip pip install playwright playwright install ``` + + + + +```bash +poetry self update +poetry add playwright +playwright install +``` + + + + + +```bash +uv self update +uv add playwright +playwright install +``` + + + + + These commands download the Playwright package and install browser binaries for Chromium, Firefox and WebKit. To modify this behavior see [installation parameters](./browsers.md#install-browsers). ## Usage From 313599a87c9ff5c541b729d3969aa31c8a0ac4c1 Mon Sep 17 00:00:00 2001 From: Devin Rousso Date: Wed, 10 Jun 2026 11:05:43 -0600 Subject: [PATCH 3/4] feat(trace-viewer): show `WebSocket` messages (#41200) switch to using a `.jsonl` file for `WebSocket` messages so that new messages can immediately be saved (without having to (re)write the entire file) and the trace viewer can more easily pick up changes fixes --- .../src/server/har/harRecorder.ts | 7 + .../src/server/har/harTracer.ts | 60 ++--- .../src/server/trace/recorder/tracing.ts | 6 + .../trace-viewer/src/ui/networkFilters.tsx | 2 +- .../src/ui/networkResourceDetails.css | 71 ++++++ .../src/ui/networkResourceDetails.tsx | 233 +++++++++++++++++- packages/trace-viewer/src/ui/networkTab.tsx | 36 +-- tests/library/har-websocket.spec.ts | 8 +- tests/library/trace-viewer.spec.ts | 68 +++++ 9 files changed, 430 insertions(+), 61 deletions(-) diff --git a/packages/playwright-core/src/server/har/harRecorder.ts b/packages/playwright-core/src/server/har/harRecorder.ts index b81a287a1e49d..dff29ebdbb6e1 100644 --- a/packages/playwright-core/src/server/har/harRecorder.ts +++ b/packages/playwright-core/src/server/har/harRecorder.ts @@ -77,6 +77,13 @@ export class HarRecorder implements HarTracerDelegate { this._fs.writeFile(path.join(this._resourcesDir, sha1), buffer, true /* skipIfExists */); } + onContentBlobAppend(sha1: string, text: string) { + if (!this._writtenContentEntries.size) + this._fs.mkdir(this._resourcesDir); + this._writtenContentEntries.add(sha1); + this._fs.appendFile(path.join(this._resourcesDir, sha1), text); + } + private async _flush() { if (this._isFlushed) return; diff --git a/packages/playwright-core/src/server/har/harTracer.ts b/packages/playwright-core/src/server/har/harTracer.ts index 384b1a53e72b0..f0fa5697225a3 100644 --- a/packages/playwright-core/src/server/har/harTracer.ts +++ b/packages/playwright-core/src/server/har/harTracer.ts @@ -19,7 +19,7 @@ import { base64ByteLength } from '@isomorphic/base64'; import { ManualPromise } from '@isomorphic/manualPromise'; import { eventsHelper } from '@utils/eventsHelper'; import { assert } from '@isomorphic/assert'; -import { calculateSha1 } from '@utils/crypto'; +import { calculateSha1, createGuid } from '@utils/crypto'; import { monotonicTime } from '@isomorphic/time'; import { isTextualMimeType } from '@isomorphic/mimeType'; import { urlMatches } from '@isomorphic/urlMatch'; @@ -45,6 +45,7 @@ export interface HarTracerDelegate { onEntryStarted(entry: har.Entry): void; onEntryFinished(entry: har.Entry): void; onContentBlob(sha1: string, buffer: Buffer): void; + onContentBlobAppend(sha1: string, text: string): void; } type HarTracerOptions = { @@ -75,7 +76,6 @@ 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; @@ -440,27 +440,25 @@ export class HarTracer { const harEntry = createHarEntry(pageEntry?.id, method, url, page.mainFrame().guid, this._options, webSocket.wallTimeMs()); harEntry._resourceType = 'websocket'; - 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; + let sha1: string | undefined = undefined; + const recordMessage = (type: 'send' | 'receive', opcode: number, data: string, wallTimeMs: number) => { + const message = { type, time: this._options.omitTiming ? -1 : wallTimeMs, opcode, data }; + if (this._options.content === 'embed') { + harEntry._webSocketMessages ??= []; + harEntry._webSocketMessages.push(message); + } else if (this._options.content === 'attach') { + if (!sha1) { + sha1 = createGuid() + '.jsonl'; + if (this._options.includeTraceInfo) + harEntry.response.content._sha1 = sha1; + else + harEntry.response.content._file = sha1; + } - 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); - } + this._delegate.onContentBlobAppend(sha1, JSON.stringify(message) + '\n'); + } + }; let oldestWallTimeMs = Infinity; let newestWallTimeMs = -Infinity; @@ -497,15 +495,11 @@ export class HarTracer { } }), eventsHelper.addEventListener(webSocket, network.WebSocket.Events.FrameSent, ({ opcode, data, wallTimeMs }: { opcode: number, data: string, wallTimeMs: number }) => { - if (this._options.content !== 'omit') - messages.push({ type: 'send', time: this._options.omitTiming ? -1 : wallTimeMs, opcode, data }); - + recordMessage('send', opcode, data, wallTimeMs); updateTime(wallTimeMs); }), eventsHelper.addEventListener(webSocket, network.WebSocket.Events.FrameReceived, ({ opcode, data, wallTimeMs }: { opcode: number, data: string, wallTimeMs: number }) => { - if (this._options.content !== 'omit') - messages.push({ type: 'receive', time: this._options.omitTiming ? -1 : wallTimeMs, opcode, data }); - + recordMessage('receive', opcode, data, wallTimeMs); updateTime(wallTimeMs); if (!this._options.omitSizes) { @@ -530,11 +524,6 @@ 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); }), @@ -665,12 +654,6 @@ 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(); @@ -703,7 +686,6 @@ export class HarTracer { } } this._pageEntries = []; - this._saveOpenWebSocketMessagesFunctions.clear(); return log; } diff --git a/packages/playwright-core/src/server/trace/recorder/tracing.ts b/packages/playwright-core/src/server/trace/recorder/tracing.ts index 5351b2c2b25e5..2edfb3dcbe17c 100644 --- a/packages/playwright-core/src/server/trace/recorder/tracing.ts +++ b/packages/playwright-core/src/server/trace/recorder/tracing.ts @@ -560,6 +560,12 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps this._appendResource(sha1, buffer); } + onContentBlobAppend(sha1: string, text: string) { + if (!this._allResources.has(sha1)) + this._allResources.add(sha1); + this._fs.appendFile(path.join(this._state!.resourcesDir, sha1), text, this._state!.options.live /* flush */); + } + onSnapshotterBlob(blob: SnapshotterBlob): void { this._appendResource(blob.sha1, blob.buffer); } diff --git a/packages/trace-viewer/src/ui/networkFilters.tsx b/packages/trace-viewer/src/ui/networkFilters.tsx index 7d38e83195f36..df0f0b9e7307a 100644 --- a/packages/trace-viewer/src/ui/networkFilters.tsx +++ b/packages/trace-viewer/src/ui/networkFilters.tsx @@ -16,7 +16,7 @@ import './networkFilters.css'; -const resourceTypes = ['Fetch', 'HTML', 'JS', 'CSS', 'Font', 'Image'] as const; +const resourceTypes = ['Fetch', 'HTML', 'JS', 'CSS', 'Font', 'Image', 'WS'] as const; export type ResourceType = typeof resourceTypes[number]; export type FilterState = { diff --git a/packages/trace-viewer/src/ui/networkResourceDetails.css b/packages/trace-viewer/src/ui/networkResourceDetails.css index 15e32c370166c..c9fc25289431e 100644 --- a/packages/trace-viewer/src/ui/networkResourceDetails.css +++ b/packages/trace-viewer/src/ui/networkResourceDetails.css @@ -103,6 +103,77 @@ text-align: center; } +.network-websocket-message-row { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + width: 100%; + padding: 2px 8px; + white-space: nowrap; + overflow: hidden; +} + +.network-websocket-message-row .codicon { + flex: none; +} + +.network-websocket-message-direction-send { + color: var(--vscode-charts-green); +} + +.network-websocket-message-direction-receive { + color: var(--vscode-charts-red); +} + +.network-websocket-message-preview { + flex: auto; + font-family: var(--vscode-editor-font-family); + text-overflow: ellipsis; + overflow: hidden; +} + +.network-websocket-message-opcode, +.network-websocket-message-length, +.network-websocket-message-time { + flex: none; + color: var(--vscode-descriptionForeground); + font-variant-numeric: tabular-nums; +} + +.network-websocket-message-opcode { + min-width: 80px; + text-align: left; +} + +.network-websocket-message-length, +.network-websocket-message-time { + min-width: 64px; + text-align: right; +} + +.network-websocket-message-detail { + flex: auto; + overflow: hidden; +} + +.network-websocket-message-detail-toolbar { + padding: 0 8px; + border-bottom: 1px solid var(--vscode-panel-border); +} + +.network-websocket-message-detail-summary { + color: var(--vscode-descriptionForeground); + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} + +.network-websocket-message-detail-body { + flex: auto; + overflow: hidden; +} + .tab-network .toolbar { min-height: 30px !important; background-color: initial !important; diff --git a/packages/trace-viewer/src/ui/networkResourceDetails.tsx b/packages/trace-viewer/src/ui/networkResourceDetails.tsx index b8863ab050835..dc1dadf90c497 100644 --- a/packages/trace-viewer/src/ui/networkResourceDetails.tsx +++ b/packages/trace-viewer/src/ui/networkResourceDetails.tsx @@ -26,14 +26,19 @@ import { getAPIRequestCodeGen } from './codegen'; import type { Language } from '@isomorphic/locatorGenerators'; import { isJsonMimeType, isXmlMimeType } from '@isomorphic/mimeType'; import { useAsyncMemo, useSetting } from '@web/uiUtils'; -import { msToString } from '@isomorphic/formatUtils'; -import type { Entry } from '@trace/har'; +import { bytesToString, msToString } from '@isomorphic/formatUtils'; +import type { Entry, WebSocketMessage } from '@trace/har'; import { useTraceModel } from './traceModelContext'; import { Expandable } from '@web/components/expandable'; +import { ListView } from '@web/components/listView'; +import { SplitView } from '@web/components/splitView'; import { Toolbar } from '@web/components/toolbar'; +import { PlaceholderPanel } from './placeholderPanel'; type RequestBody = { text: string, mimeType?: string } | null; type ResponseBody = { dataUrl?: string, text?: string, mimeType?: string, font?: BufferSource } | null; +type FormattableBody = { text?: string, mimeType?: string } | null; +type IndexedWebSocketMessage = WebSocketMessage & { index: number, byteLength: number }; export const NetworkResourceDetails: React.FunctionComponent<{ @@ -288,6 +293,188 @@ const FontPreview: React.FunctionComponent<{ ; }; +export const WebSocketResourceDetails: React.FunctionComponent<{ + resource: ResourceSnapshot; + startTimeOffset: number; + onClose: () => void; +}> = ({ resource, startTimeOffset, onClose }) => { + const [selectedTab, setSelectedTab] = React.useState('messages'); + + return ]} + tabs={[ + { + id: 'headers', + title: 'Headers', + render: () => , + }, + { + id: 'messages', + title: 'Messages', + render: () => , + }, + ]} + selectedTab={selectedTab} + setSelectedTab={setSelectedTab} />; +}; + +const WebSocketMessagesTab: React.FunctionComponent<{ + resource: ResourceSnapshot; +}> = ({ resource }) => { + const model = useTraceModel(); + + const indexedMessages = useAsyncMemo(async () => { + if (resource._webSocketMessages) + return resource._webSocketMessages.map((m, index) => ({ ...m, index, byteLength: messageByteLength(m) })); + if (model && resource.response.content._sha1) { + try { + const response = await fetch(model.createRelativeUrl(`sha1/${resource.response.content._sha1}`)); + if (!response.ok) + return []; + const text = await response.text(); + const messages = text.split('\n').filter(Boolean).map(line => JSON.parse(line) as WebSocketMessage); + return messages.map((m, index) => ({ ...m, index, byteLength: messageByteLength(m) })); + } catch { + return []; + } + } + return []; + }, [resource, model], undefined); + + const [selectedIndex, setSelectedIndex] = React.useState(undefined); + const selectedMessage = selectedIndex !== undefined ? indexedMessages?.[selectedIndex] : undefined; + const baseTimeMs = React.useMemo(() => { + if (!indexedMessages) + return undefined; + for (const f of indexedMessages) { + if (f.time > 0) + return f.time; + } + return undefined; + }, [indexedMessages]); + + if (indexedMessages === undefined) + return ; + + if (indexedMessages.length === 0) + return ; + + const list = String(indexedMessage.index)} + selectedItem={selectedMessage} + onSelected={indexedMessage => setSelectedIndex(indexedMessage.index)} + render={indexedMessage => renderMessageRow(indexedMessage, baseTimeMs)} + />; + + if (!selectedMessage) + return list; + + return } + />; +}; + +const WebSocketMessagesListView = ListView; + +const WebSocketMessageDetail: React.FunctionComponent<{ + message: WebSocketMessage, +}> = ({ message }) => { + const [showFormattedMessage, setShowFormattedMessage] = useSetting('trace-viewer-network-details-show-formatted-message', true); + const messageBody = React.useMemo(() => { + if (message.opcode === 1) { + const text = message.data; + let mimeType: string | undefined; + try { + JSON.parse(text); + mimeType = 'application/json'; + } catch { + mimeType = 'text/plain'; + } + return { text, mimeType }; + } + if (message.opcode === 8 || message.opcode === 9 || message.opcode === 10) { + if (!message.data) + return { text: '' }; + // Control messages may carry a small payload as base64. + try { + const text = decodeBase64ToText(message.data); + return { text, mimeType: 'text/plain' }; + } catch { + return { text: dumpHex(message.data) }; + } + } + return { text: dumpHex(message.data) }; + }, [message]); + const formatResult = useFormattedBody(messageBody, showFormattedMessage); + + return
+ + + {message.type === 'send' ? 'Sent' : 'Received'} · {opcodeName(message.opcode)} · {bytesToString(messageByteLength(message))} + +
+ setShowFormattedMessage(!showFormattedMessage)} /> +
+
+ +
+
; +}; + +function renderMessageRow(message: IndexedWebSocketMessage, baseTimeMs: number | undefined): React.ReactNode { + const directionIcon = message.type === 'send' ? 'codicon-arrow-up' : 'codicon-arrow-down'; + const directionLabel = message.type === 'send' ? 'Sent' : 'Received'; + const opcodeLabel = opcodeName(message.opcode); + const relativeTime = (message.time > 0 && baseTimeMs !== undefined) ? msToString(message.time - baseTimeMs) : '-'; + const preview = messagePreview(message); + + return
+ + {preview} + {opcodeLabel} + {bytesToString(message.byteLength)} + {relativeTime} +
; +} + +function opcodeName(opcode: number): string { + switch (opcode) { + case 0: return 'Continuation'; + case 1: return 'Text'; + case 2: return 'Binary'; + case 8: return 'Close'; + case 9: return 'Ping'; + case 10: return 'Pong'; + default: return `Opcode ${opcode}`; + } +} + +function messageByteLength(message: WebSocketMessage): number { + return (message.opcode === 1) ? (new TextEncoder()).encode(message.data).length : base64ByteLength(message.data); +} + +function messagePreview(message: WebSocketMessage): string { + if (message.opcode === 1) { + const trimmed = message.data.replace(/\s+/g, ' '); + return trimmed.length > 200 ? trimmed.substring(0, 200) + '…' : trimmed; + } + if (message.opcode === 8) + return '(close)'; + if (message.opcode === 9) + return '(ping)'; + if (message.opcode === 10) + return '(pong)'; + return `(binary, ${bytesToString(base64ByteLength(message.data))})`; +} + function statusClass(statusCode: number): string { if (statusCode < 300 || statusCode === 304) return 'green-circle'; @@ -340,7 +527,7 @@ function formatBody(body: string, contentType?: string): string { return body; } -const useFormattedBody = (body: RequestBody | ResponseBody, showFormatted: boolean) => { +const useFormattedBody = (body: FormattableBody, showFormatted: boolean) => { return React.useMemo(() => { if (body?.text === undefined) return { text: '' }; @@ -355,3 +542,43 @@ const useFormattedBody = (body: RequestBody | ResponseBody, showFormatted: boole } }, [body, showFormatted]); }; + +function base64ByteLength(data: string): number { + if (!data) + return 0; + const padding = (data[data.length - 2] === '=') ? 2 : ((data[data.length - 1] === '=') ? 1 : 0); + return Math.max(0, Math.floor(data.length * 3 / 4) - padding); +} + +function decodeBase64ToText(base64: string): string { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; ++i) + bytes[i] = binary.charCodeAt(i); + return (new TextDecoder('utf-8', { fatal: false })).decode(bytes); +} + +function dumpHex(base64: string): string { + if (!base64) + return ''; + const binary = atob(base64); + const lines: string[] = []; + for (let offset = 0; offset < binary.length; offset += 16) { + const chunkLength = Math.min(16, binary.length - offset); + const hex: string[] = []; + const ascii: string[] = []; + for (let i = 0; i < 16; ++i) { + if (i < chunkLength) { + const code = binary.charCodeAt(offset + i); + hex.push(code.toString(16).padStart(2, '0')); + ascii.push((code >= 0x20 && code < 0x7f) ? binary[offset + i] : '.'); + } else { + hex.push(' '); + } + if (i === 7) + hex.push(''); + } + lines.push(`${offset.toString(16).padStart(8, '0')} ${hex.join(' ')} ${ascii.join('')}`); + } + return lines.join('\n'); +} diff --git a/packages/trace-viewer/src/ui/networkTab.tsx b/packages/trace-viewer/src/ui/networkTab.tsx index 75872ebccf708..411b67fceeb93 100644 --- a/packages/trace-viewer/src/ui/networkTab.tsx +++ b/packages/trace-viewer/src/ui/networkTab.tsx @@ -17,7 +17,7 @@ import * as React from 'react'; import type { Boundaries } from './geometry'; import './networkTab.css'; -import { NetworkResourceDetails } from './networkResourceDetails'; +import { NetworkResourceDetails, WebSocketResourceDetails } from './networkResourceDetails'; import { bytesToString, msToString } from '@isomorphic/formatUtils'; import { PlaceholderPanel } from './placeholderPanel'; import { context, type ResourceEntry } from '@isomorphic/trace/traceModel'; @@ -122,7 +122,9 @@ export const NetworkTab: React.FunctionComponent<{ sidebarIsFirst={true} orientation='horizontal' settingName='networkResourceDetails' - main={ setSelectedResourceKey(undefined)} />} + main={visibleSelectedEntry.resource._resourceType === 'websocket' + ? setSelectedResourceKey(undefined)} /> + : setSelectedResourceKey(undefined)} />} sidebar={grid} />} ; @@ -278,10 +280,15 @@ const renderEntry = (resource: ResourceEntry, boundaries: Boundaries, contextIdG } catch { resourceName = resource.request.url; } - let contentType = resource.response.content.mimeType; - const charset = contentType.match(/^(.*);\s*charset=.*$/); - if (charset) - contentType = charset[1]; + let contentType: string; + if (resource._resourceType === 'websocket') { + contentType = 'websocket'; + } else { + contentType = resource.response.content.mimeType; + const charset = contentType.match(/^(.*);\s*charset=.*$/); + if (charset) + contentType = charset[1]; + } return { name: { name: resourceName, url: resource.request.url }, @@ -369,18 +376,19 @@ function comparator(sortBy: ColumnName) { return (a: RenderedEntry, b: RenderedEntry) => a.contextId.localeCompare(b.contextId); } -const resourceTypePredicates: Record boolean> = { - 'Fetch': contentType => contentType === 'application/json', - 'HTML': contentType => contentType === 'text/html', - 'CSS': contentType => contentType === 'text/css', - 'JS': contentType => contentType.includes('javascript'), - 'Font': contentType => contentType.includes('font'), - 'Image': contentType => contentType.includes('image'), +const resourceTypePredicates: Record boolean> = { + 'Fetch': entry => entry.contentType === 'application/json', + 'HTML': entry => entry.contentType === 'text/html', + 'CSS': entry => entry.contentType === 'text/css', + 'JS': entry => entry.contentType.includes('javascript'), + 'Font': entry => entry.contentType.includes('font'), + 'Image': entry => entry.contentType.includes('image'), + 'WS': entry => entry.resource._resourceType === 'websocket', }; function filterEntry({ searchValue, resourceTypes }: FilterState) { return (entry: RenderedEntry) => { - const isRightType = resourceTypes.size === 0 || Array.from(resourceTypes).some(type => resourceTypePredicates[type](entry.contentType)); + const isRightType = resourceTypes.size === 0 || Array.from(resourceTypes).some(type => resourceTypePredicates[type](entry)); return isRightType && entry.name.url.toLowerCase().includes(searchValue.toLowerCase()); }; } diff --git a/tests/library/har-websocket.spec.ts b/tests/library/har-websocket.spec.ts index 795996e9f836e..f9c90544eb4d1 100644 --- a/tests/library/har-websocket.spec.ts +++ b/tests/library/har-websocket.spec.ts @@ -187,8 +187,8 @@ async function testWebSocketMessages(contextFactory, server, testInfo, content) if (content === 'attach') { expect(wsEntry._webSocketMessages).toBeUndefined(); const file = wsEntry.response.content._file!; - expect(file).toMatch(/^[0-9a-f]{40}\.json$/); - messages = JSON.parse(zip.get(file)!.toString()) as WebSocketMessage[]; + expect(file).toMatch(/^[0-9a-f]+\.jsonl$/); + messages = zip.get(file)!.toString().split('\n').filter(Boolean).map(line => JSON.parse(line)) as WebSocketMessage[]; } else { messages = wsEntry._webSocketMessages; } @@ -262,9 +262,9 @@ it('should attach websocket messages for a still open websocket after stopping', expect(wsEntry._webSocketMessages).toBeUndefined(); const file = wsEntry.response.content._file!; - expect(file).toMatch(/^[0-9a-f]{40}\.json$/); + expect(file).toMatch(/^[0-9a-f]+\.jsonl$/); - const messages = JSON.parse(zip.get(file)!.toString()) as Array<{ type: string, time: number, opcode: number, data: string }>; + const messages = zip.get(file)!.toString().split('\n').filter(Boolean).map(line => JSON.parse(line)) 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 }, diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts index 9e69039b7a695..2597046e9fdfb 100644 --- a/tests/library/trace-viewer.spec.ts +++ b/tests/library/trace-viewer.spec.ts @@ -652,6 +652,74 @@ test('should have network request overrides 2', async ({ page, server, runAndTra await expect.soft(traceViewer.networkRequests).toContainText([/script.jsGET200application\/javascript.*continued/]); }); +test('should filter network requests by websocket type', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/10996' } +}, async ({ page, server, runAndTrace }) => { + server.onceWebSocketConnection(ws => { + ws.on('message', () => ws.close()); + }); + + const traceViewer = await runAndTrace(async () => { + await page.goto(server.EMPTY_PAGE); + await page.evaluate(async url => { + const ws = new WebSocket(url); + await new Promise(resolve => ws.addEventListener('open', resolve)); + ws.send('done'); + await new Promise(resolve => ws.addEventListener('close', resolve, { once: true })); + }, `ws://${server.HOST}/ws`); + }); + + await traceViewer.showNetworkTab(); + await traceViewer.page.getByText('WS', { exact: true }).click(); + await expect(traceViewer.networkRequests).toHaveCount(1); + await expect(traceViewer.networkRequests).toContainText('websocket'); +}); + +test('should show websocket messages', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/10996' } +}, async ({ page, server, runAndTrace }) => { + server.onceWebSocketConnection(ws => { + ws.on('message', message => { + if (message.toString() === 'ping') + ws.send('pong'); + else if (message.toString() === 'binary') + ws.send(Buffer.from([0x01, 0x02, 0x03, 0x04])); + }); + }); + + const traceViewer = await runAndTrace(async () => { + await page.goto(server.EMPTY_PAGE); + await page.evaluate(async url => { + const ws = new WebSocket(url); + await new Promise(resolve => ws.addEventListener('open', resolve)); + ws.send('ping'); + await new Promise(resolve => ws.addEventListener('message', resolve, { once: true })); + ws.send('binary'); + await new Promise(resolve => ws.addEventListener('message', resolve, { once: true })); + ws.close(); + await new Promise(resolve => ws.addEventListener('close', resolve, { once: true })); + }, `ws://${server.HOST}/ws`); + }); + + await traceViewer.showNetworkTab(); + const wsRequest = traceViewer.networkRequests.filter({ hasText: 'ws' }).filter({ hasText: 'websocket' }); + await expect(wsRequest).toBeVisible(); + await wsRequest.click(); + + const messagesTab = traceViewer.networkTab.getByRole('tabpanel', { name: 'Messages' }); + await traceViewer.networkTab.getByRole('tab', { name: 'Messages' }).click(); + await expect(messagesTab).toBeVisible(); + + const wsList = messagesTab.getByRole('listbox', { name: 'WebSocket messages' }); + await expect(wsList.getByRole('option')).toHaveCount(4); + await expect(wsList.getByRole('option').nth(0)).toContainText('ping'); + await expect(wsList.getByRole('option').nth(0)).toContainText('Text'); + await expect(wsList.getByRole('option').nth(1)).toContainText('pong'); + await expect(wsList.getByRole('option').nth(1)).toContainText('Text'); + await expect(wsList.getByRole('option').nth(2)).toContainText('binary'); + await expect(wsList.getByRole('option').nth(3)).toContainText('Binary'); +}); + test('should show snapshot URL and copy button', async ({ page, runAndTrace, server }) => { const traceViewer = await runAndTrace(async () => { await page.goto(server.EMPTY_PAGE); From 08788271fd74ac3c079d860c5ecb7e7ed21d8259 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 10 Jun 2026 10:09:31 -0700 Subject: [PATCH 4/4] fix(tracing): add missing error handlers to createWriteStream pipes (#41227) Add `.on('error', reject)` to two `createWriteStream` pipe chains that only had `'close'` handlers. Without error handlers, write failures (disk full, permissions) cause the promise to hang and the unhandled error event to crash the process. Matches the existing pattern in the same codebase (`testTracing.ts:396`, `blob.ts:88`). --- packages/playwright/src/reporters/html.ts | 4 ++-- packages/playwright/src/worker/testTracing.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/playwright/src/reporters/html.ts b/packages/playwright/src/reporters/html.ts index 6f9e05d844b54..8bc5cdc8586df 100644 --- a/packages/playwright/src/reporters/html.ts +++ b/packages/playwright/src/reporters/html.ts @@ -464,11 +464,11 @@ class HtmlBuilder { private async _writeReportData(filePath: string) { fs.appendFileSync(filePath, ''); diff --git a/packages/playwright/src/worker/testTracing.ts b/packages/playwright/src/worker/testTracing.ts index 27132bc7fda4d..70b6a5c28e738 100644 --- a/packages/playwright/src/worker/testTracing.ts +++ b/packages/playwright/src/worker/testTracing.ts @@ -238,9 +238,9 @@ export class TestTracing { const traceContent = Buffer.from(this._traceEvents.map(e => JSON.stringify(e)).join('\n')); zipFile.addBuffer(traceContent, testTraceEntryName); - await new Promise(f => { + await new Promise((resolve, reject) => { zipFile.end(undefined, () => { - zipFile.outputStream.pipe(fs.createWriteStream(this._generateNextTraceRecordingPath())).on('close', f); + zipFile.outputStream.pipe(fs.createWriteStream(this._generateNextTraceRecordingPath())).on('close', resolve).on('error', reject); }); });