Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/src/trace-viewer.md
Original file line number Diff line number Diff line change
Expand Up @@ -765,6 +765,20 @@ Double click on an action from your test in the actions sidebar. This will filte

Use the timeline to filter actions, by clicking a start point and dragging to an ending point. The network tab will also be filtered to only show the network requests that were made during the actions selected.

#### WebSocket connections

The Network tab also displays WebSocket connections made during your test. WebSocket entries are shown with a "WS" method indicator and can be filtered using the type filter. Click on a WebSocket connection to see detailed information:

- **Info tab**: Shows the WebSocket URL, connection status, timing information, and frame statistics (total frames, sent/received count, text/binary breakdown).
- **Frames tab**: Displays all WebSocket frames exchanged during the connection. Each frame shows:
- Direction (sent ↑ or received ↓)
- Frame type (text or binary)
- Timestamp
- Data size
- Content preview

Click on any frame in the list to view its full content. This is particularly useful for debugging real-time communication in applications using WebSocket-based protocols like Socket.IO or custom WebSocket implementations.

### Metadata

Next to the Actions tab you will find the Metadata tab which will show you more information on your test such as the Browser, viewport size, test duration and more.
Expand Down
70 changes: 70 additions & 0 deletions packages/playwright-core/src/server/trace/recorder/tracing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { SerializedFS, removeFolders } from '../../utils/fileUtils';
import { HarTracer } from '../../har/harTracer';
import { SdkObject } from '../../instrumentation';
import { Page } from '../../page';
import { WebSocket } from '../../network';
import { isAbortError } from '../../progress';

import type { SnapshotterBlob, SnapshotterDelegate } from './snapshotter';
Expand Down Expand Up @@ -216,6 +217,8 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
this._startScreencast();
if (this._state.options.snapshots)
await this._snapshotter?.start();
// Start WebSocket tracing for all existing and new pages
this._startWebSocketTracing();
return { traceName: this._state.traceName };
}

Expand Down Expand Up @@ -284,6 +287,73 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
page.setScreencastOptions(null);
}

private _startWebSocketTracing() {
if (!(this._context instanceof BrowserContext))
return;
// Track WebSockets on existing pages
for (const page of this._context.pages())
this._startWebSocketTracingInPage(page);
// Track WebSockets on new pages
this._eventListeners.push(
eventsHelper.addEventListener(this._context, BrowserContext.Events.Page, this._startWebSocketTracingInPage.bind(this)),
);
}

private _startWebSocketTracingInPage(page: Page) {
this._eventListeners.push(
eventsHelper.addEventListener(page, Page.Events.WebSocket, (ws: WebSocket) => {
this._onWebSocketCreated(ws, page);
}),
);
}

private _onWebSocketCreated(ws: WebSocket, page: Page) {
const wsGuid = ws.guid;
const event: trace.WebSocketCreatedTraceEvent = {
type: 'websocket-created',
wsGuid,
timestamp: monotonicTime(),
url: ws.url(),
pageId: page.guid,
};
this._appendTraceEvent(event);

// Listen to WebSocket events
const frameListener = (frameEvent: { opcode: number, data: string }, direction: 'sent' | 'received') => {
const frameTraceEvent: trace.WebSocketFrameTraceEvent = {
type: 'websocket-frame',
wsGuid,
timestamp: monotonicTime(),
opcode: frameEvent.opcode,
data: frameEvent.data,
direction,
};
this._appendTraceEvent(frameTraceEvent);
};

this._eventListeners.push(
eventsHelper.addEventListener(ws, WebSocket.Events.FrameSent, (e: { opcode: number, data: string }) => frameListener(e, 'sent')),
eventsHelper.addEventListener(ws, WebSocket.Events.FrameReceived, (e: { opcode: number, data: string }) => frameListener(e, 'received')),
eventsHelper.addEventListener(ws, WebSocket.Events.SocketError, (error: string) => {
const errorEvent: trace.WebSocketErrorTraceEvent = {
type: 'websocket-error',
wsGuid,
timestamp: monotonicTime(),
error,
};
this._appendTraceEvent(errorEvent);
}),
eventsHelper.addEventListener(ws, WebSocket.Events.Close, () => {
const closeEvent: trace.WebSocketClosedTraceEvent = {
type: 'websocket-closed',
wsGuid,
timestamp: monotonicTime(),
};
this._appendTraceEvent(closeEvent);
}),
);
}

private _allocateNewTraceFile(state: RecordingState) {
const suffix = state.chunkOrdinal ? `-chunk${state.chunkOrdinal}` : ``;
state.chunkOrdinal++;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
*/

import type { Language } from '../locatorGenerators';
import type { ResourceSnapshot } from '@trace/snapshot';
import type { ResourceSnapshot, WebSocketSnapshot } from '@trace/snapshot';
import type * as trace from '@trace/trace';

// *Entry structures are used to pass the trace between the sw and the page.
Expand All @@ -35,6 +35,7 @@ export type ContextEntry = {
options: trace.BrowserContextEventOptions;
pages: PageEntry[];
resources: ResourceSnapshot[];
websockets: WebSocketSnapshot[];
actions: ActionEntry[];
events: (trace.EventTraceEvent | trace.ConsoleMessageTraceEvent)[];
stdio: trace.StdioTraceEvent[];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ function createEmptyContext(): ContextEntry {
},
pages: [],
resources: [],
websockets: [],
actions: [],
events: [],
errors: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import { getActionGroup } from '@isomorphic/protocolFormatter';

import type { Language } from '@isomorphic/locatorGenerators';
import type { ResourceSnapshot } from '@trace/snapshot';
import type { ResourceSnapshot, WebSocketSnapshot } from '@trace/snapshot';
import type * as trace from '@trace/trace';
import type { ActionTraceEvent } from '@trace/trace';
import type { ActionEntry, ContextEntry, PageEntry } from '@isomorphic/trace/entries';
Expand Down Expand Up @@ -85,6 +85,7 @@ export class TraceModel {
readonly testIdAttributeName: string | undefined;
readonly sources: Map<string, SourceModel>;
resources: ResourceSnapshot[];
websockets: WebSocketSnapshot[];
readonly actionCounters: Map<string, number>;
readonly traceUrl: string;

Expand Down Expand Up @@ -114,11 +115,13 @@ export class TraceModel {
this.hasSource = contexts.some(c => c.hasSource);
this.hasStepData = contexts.some(context => context.origin === 'testRunner');
this.resources = [...contexts.map(c => c.resources)].flat();
this.websockets = [...contexts.map(c => c.websockets)].flat();
this.attachments = this.actions.flatMap(action => action.attachments?.map(attachment => ({ ...attachment, callId: action.callId, traceUrl })) ?? []);
this.visibleAttachments = this.attachments.filter(attachment => !attachment.name.startsWith('_'));

this.events.sort((a1, a2) => a1.time - a2.time);
this.resources.sort((a1, a2) => a1._monotonicTime! - a2._monotonicTime!);
this.websockets.sort((a1, a2) => a1.createdTimestamp - a2.createdTimestamp);
this.errorDescriptors = this.hasStepData ? this._errorDescriptorsFromTestRunner() : this._errorDescriptorsFromActions();
this.sources = collectSources(this.actions, this.errorDescriptors);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type * as traceV7 from './versions/traceV7';
import type * as traceV8 from './versions/traceV8';
import type { ActionEntry, ContextEntry, PageEntry } from './entries';
import type { SnapshotStorage } from './snapshotStorage';
import type { WebSocketSnapshot } from '@trace/snapshot';

export class TraceVersionError extends Error {
constructor(message: string) {
Expand All @@ -43,6 +44,7 @@ export class TraceModernizer {
private _pageEntries = new Map<string, PageEntry>();
private _jsHandles = new Map<string, { preview: string }>();
private _consoleObjects = new Map<string, { type: string, text: string, location: { url: string, lineNumber: number, columnNumber: number }, args?: { preview: string, value: string }[] }>();
private _websockets = new Map<string, WebSocketSnapshot>();

constructor(contextEntry: ContextEntry, snapshotStorage: SnapshotStorage) {
this._contextEntry = contextEntry;
Expand Down Expand Up @@ -168,6 +170,42 @@ export class TraceModernizer {
case 'frame-snapshot':
this._snapshotStorage.addFrameSnapshot(this._contextEntry.contextId, event.snapshot, this._pageEntry(event.snapshot.pageId).screencastFrames);
break;
case 'websocket-created': {
const ws: WebSocketSnapshot = {
wsGuid: event.wsGuid,
url: event.url,
pageId: event.pageId,
createdTimestamp: event.timestamp,
frames: [],
};
this._websockets.set(event.wsGuid, ws);
contextEntry.websockets.push(ws);
break;
}
case 'websocket-frame': {
const ws = this._websockets.get(event.wsGuid);
if (ws) {
ws.frames.push({
opcode: event.opcode,
data: event.data,
timestamp: event.timestamp,
direction: event.direction,
});
}
break;
}
case 'websocket-closed': {
const ws = this._websockets.get(event.wsGuid);
if (ws)
ws.closedTimestamp = event.timestamp;
break;
}
case 'websocket-error': {
const ws = this._websockets.get(event.wsGuid);
if (ws)
ws.error = event.error;
break;
}
}
// Make sure there is a page entry for each page, even without screencast frames,
// to show in the metadata view.
Expand Down
2 changes: 1 addition & 1 deletion packages/trace-viewer/src/ui/networkFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
86 changes: 86 additions & 0 deletions packages/trace-viewer/src/ui/networkResourceDetails.css
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,89 @@
.yellow-circle::before {
background-color: var(--vscode-charts-yellow);
}

/* WebSocket Frames Styles */
.websocket-frames-tab {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}

.websocket-frames-list {
flex: 1 1 40%;
min-height: 100px;
overflow: auto;
border-bottom: 1px solid var(--vscode-panel-border);
}

.websocket-frames-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}

.websocket-frames-table th {
background-color: var(--vscode-sideBar-background);
padding: 6px 8px;
text-align: left;
font-weight: 600;
position: sticky;
top: 0;
border-bottom: 1px solid var(--vscode-panel-border);
}

.websocket-frame-row {
cursor: pointer;
}

.websocket-frame-row:hover {
background-color: var(--vscode-list-hoverBackground);
}

.websocket-frame-row.selected {
background-color: var(--vscode-list-activeSelectionBackground);
color: var(--vscode-list-activeSelectionForeground);
}

.websocket-frame-row td {
padding: 4px 8px;
border-bottom: 1px solid var(--vscode-panel-border);
}

.websocket-frame-direction {
display: flex;
align-items: center;
gap: 4px;
}

.websocket-frame-row.sent .websocket-frame-direction .codicon {
color: var(--vscode-charts-green);
}

.websocket-frame-row.received .websocket-frame-direction .codicon {
color: var(--vscode-charts-blue);
}

.websocket-frame-preview {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--vscode-editor-font-family);
font-size: 11px;
color: var(--vscode-descriptionForeground);
}

.websocket-frame-detail {
flex: 1 1 300px;
min-height: 200px;
max-height: 50%;
overflow: auto;
border-top: 1px solid var(--vscode-panel-border);
}

.network-request-error {
color: var(--vscode-charts-red);
padding: 8px;
}
Loading