;
+
+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);