Adds WebSocket support to Network panel (GSoC'26) (#9968)
diff --git a/packages/devtools_app/lib/src/screens/network/network_controller.dart b/packages/devtools_app/lib/src/screens/network/network_controller.dart
index 086703f..88490a2 100644
--- a/packages/devtools_app/lib/src/screens/network/network_controller.dart
+++ b/packages/devtools_app/lib/src/screens/network/network_controller.dart
@@ -246,6 +246,7 @@
..updateOrAddAll(
requests: httpProfileData,
sockets: socketStatsData,
+ webSockets: const [],
timelineMicrosOffset: offlineData.timelineMicrosOffset ?? 0,
);
_filterAndRefreshSearchMatches();
@@ -265,12 +266,14 @@
void processNetworkTrafficHelper(
List<SocketStatistic> sockets,
List<HttpProfileRequest>? httpRequests,
+ List<WebSocketConnection> webSockets,
int timelineMicrosOffset, {
required CurrentNetworkRequests currentRequests,
}) {
currentRequests.updateOrAddAll(
requests: httpRequests!,
sockets: sockets,
+ webSockets: webSockets,
timelineMicrosOffset: timelineMicrosOffset,
);
@@ -287,11 +290,13 @@
void processNetworkTraffic({
required List<SocketStatistic> sockets,
required List<HttpProfileRequest>? httpRequests,
+ required List<WebSocketConnection> webSockets,
}) {
// Trigger refresh.
processNetworkTrafficHelper(
sockets,
httpRequests,
+ webSockets,
_timelineMicrosOffset,
currentRequests: _currentNetworkRequests,
);
@@ -339,6 +344,11 @@
await networkService.updateLastHttpDataRefreshTime(
alreadyRecordingHttp: alreadyRecordingHttp,
);
+ unawaited(
+ networkService.updateLastWebSocketDataRefreshTime(
+ alreadyRecordingWebSocket: alreadyRecordingHttp,
+ ),
+ );
final timestamp = await networkService.updateLastSocketDataRefreshTime(
alreadyRecordingSocketData: alreadyRecordingSocketData,
);
@@ -391,10 +401,11 @@
_recordingNotifier.value = state;
}
- /// Updates the last refresh time of the socket and http data refresh times.
+ /// Updates the last refresh time of the HTTP, WebSocket, and Socket data.
///
- /// This will ensure that future fetches for http and socket requests will at
- /// most fetch requests since [updateLastRefreshTime] was called.
+ /// This will ensure that future fetches for http, socket and websocket
+ /// requests will atmost fetch requests since [updateLastRefreshTime] was
+ /// called.
Future<void> updateLastRefreshTime() async {
await networkService.updateLastHttpDataRefreshTime();
await networkService.updateLastSocketDataRefreshTime();
@@ -520,17 +531,21 @@
OfflineScreenData prepareOfflineScreenData() {
final httpRequestData = <DartIOHttpRequestData>[];
final socketData = <Socket>[];
+ final webSocketData = <WebSocket>[];
for (final request in _currentNetworkRequests.value) {
if (request is DartIOHttpRequestData) {
httpRequestData.add(request);
} else if (request is Socket) {
socketData.add(request);
+ } else if (request is WebSocket) {
+ webSocketData.add(request);
}
}
final offlineData = OfflineNetworkData(
httpRequestData: httpRequestData,
socketData: socketData,
+ webSocketData: webSocketData,
selectedRequestId: selectedRequest.value?.id,
timelineMicrosOffset: _timelineMicrosOffset,
);
@@ -571,10 +586,12 @@
void updateOrAddAll({
required List<HttpProfileRequest> requests,
required List<SocketStatistic> sockets,
+ required List<WebSocketConnection> webSockets,
required int timelineMicrosOffset,
}) {
_updateOrAddRequests(requests);
_updateSocketProfiles(sockets, timelineMicrosOffset);
+ _updateWebSocketProfiles(webSockets);
notifyListeners();
}
@@ -631,6 +648,20 @@
}
}
+ void _updateWebSocketProfiles(List<WebSocketConnection> webSockets) {
+ for (final connection in webSockets) {
+ final webSocket = WebSocket(connection);
+ final existingRequest = _requestsById[webSocket.id];
+
+ if (existingRequest == null) {
+ _requestsById[webSocket.id] = webSocket;
+ value.add(webSocket);
+ } else {
+ (existingRequest as WebSocket).update(webSocket);
+ }
+ }
+ }
+
void clear({bool partial = false}) {
if (partial) {
_requestsById.keys
diff --git a/packages/devtools_app/lib/src/screens/network/network_model.dart b/packages/devtools_app/lib/src/screens/network/network_model.dart
index 3d7fa03..f101106 100644
--- a/packages/devtools_app/lib/src/screens/network/network_model.dart
+++ b/packages/devtools_app/lib/src/screens/network/network_model.dart
@@ -9,6 +9,8 @@
import '../../shared/primitives/utils.dart';
import '../../shared/ui/search.dart';
+export 'package:vm_service/vm_service.dart' show WebSocketEvent;
+
abstract class NetworkRequest
with ChangeNotifier, SearchableDataMixin, Serializable {
String get method;
@@ -239,3 +241,153 @@
return map((socket) => socket._socket).toList();
}
}
+
+class WebSocket extends NetworkRequest {
+ WebSocket(this._connection);
+
+ factory WebSocket.fromJson(Map<String, Object?> json) {
+ return WebSocket(
+ WebSocketConnection(
+ isolateId: json['isolateId'] as String,
+ id: json['connectionId'] as String,
+ uri: Uri.parse(json['uri'] as String),
+ protocol: json['protocol'] as String?,
+ state: json['state'] as String,
+ connectTimestamp: DateTime.parse(json['connectTimestamp'] as String),
+ openTimestamp: json['openTimestamp'] != null
+ ? DateTime.parse(json['openTimestamp'] as String)
+ : null,
+ closeTimestamp: json['closeTimestamp'] != null
+ ? DateTime.parse(json['closeTimestamp'] as String)
+ : null,
+ bytesSent: json['bytesSent'] as int,
+ bytesReceived: json['bytesReceived'] as int,
+ framesSent: json['framesSent'] as int,
+ framesReceived: json['framesReceived'] as int,
+ pingCount: json['pingCount'] as int,
+ pongCount: json['pongCount'] as int,
+ closeCode: json['closeCode'] as int?,
+ closeReason: json['closeReason'] as String?,
+ error: json['error'] as String?,
+ lastUpdated: DateTime.parse(json['lastUpdated'] as String),
+ events: (json['events'] as List<Object?>? ?? [])
+ .whereType<Map<String, Object?>>()
+ .map(WebSocketEvent.parse)
+ .whereType<WebSocketEvent>()
+ .toList(),
+ ),
+ );
+ }
+
+ WebSocketConnection _connection;
+ List<WebSocketEvent> get events => _connection.events;
+
+ String get connectionId => _connection.id;
+ String? get protocol => _connection.protocol;
+ String get state => _connection.state;
+
+ DateTime get lastUpdated => _connection.lastUpdated;
+
+ int get bytesSent => _connection.bytesSent;
+ int get bytesReceived => _connection.bytesReceived;
+ int get framesSent => _connection.framesSent;
+ int get framesReceived => _connection.framesReceived;
+ int get pingCount => _connection.pingCount;
+ int get pongCount => _connection.pongCount;
+ int? get closeCode => _connection.closeCode;
+ String? get closeReason => _connection.closeReason;
+ String? get error => _connection.error;
+
+ void update(WebSocket other) {
+ _connection = other._connection;
+ notifyListeners();
+ }
+
+ @override
+ String get id => 'websocket:${_connection.isolateId}:${_connection.id}';
+
+ @override
+ String get method => 'WEBSOCKET';
+
+ @override
+ String get uri => _connection.uri.toString();
+
+ @override
+ String? get contentType => 'websocket';
+
+ @override
+ String get type => 'WebSocket';
+
+ @override
+ Duration? get duration =>
+ _connection.closeTimestamp?.difference(_connection.connectTimestamp);
+
+ @override
+ DateTime get startTimestamp => _connection.connectTimestamp;
+
+ @override
+ DateTime? get endTimestamp => _connection.closeTimestamp;
+
+ @override
+ String get status => _connection.state;
+
+ @override
+ int? get port => _connection.uri.hasPort ? _connection.uri.port : null;
+
+ @override
+ int get requestBytes => _connection.bytesSent;
+
+ @override
+ int get responseBytes => _connection.bytesReceived;
+
+ @override
+ bool get didFail => _connection.state == 'error';
+
+ @override
+ bool get inProgress =>
+ _connection.state != 'closed' && _connection.state != 'error';
+
+ @override
+ bool operator ==(Object other) => other is WebSocket && id == other.id;
+
+ @override
+ int get hashCode => id.hashCode;
+
+ @override
+ Map<String, Object?> toJson() {
+ return {
+ 'isolateId': _connection.isolateId,
+ 'connectionId': connectionId,
+ 'uri': uri,
+ 'protocol': protocol,
+ 'state': state,
+ 'connectTimestamp': startTimestamp.toIso8601String(),
+ 'openTimestamp': _connection.openTimestamp?.toIso8601String(),
+ 'closeTimestamp': endTimestamp?.toIso8601String(),
+ 'bytesSent': bytesSent,
+ 'bytesReceived': bytesReceived,
+ 'framesSent': framesSent,
+ 'framesReceived': framesReceived,
+ 'pingCount': pingCount,
+ 'pongCount': pongCount,
+ 'closeCode': closeCode,
+ 'closeReason': closeReason,
+ 'error': error,
+ 'lastUpdated': lastUpdated.toIso8601String(),
+ 'events': events
+ .map(
+ (event) => {
+ 'timestamp': event.timestamp.microsecondsSinceEpoch,
+ 'event': event.event,
+ 'frameNumber': event.frameNumber,
+ 'direction': event.direction,
+ 'opcode': event.opcode,
+ 'payloadSize': event.payloadSize,
+ 'errorType': event.errorType,
+ 'errorMessage': event.errorMessage,
+ },
+ )
+ .toList(),
+ };
+ }
+}
diff --git a/packages/devtools_app/lib/src/screens/network/network_request_inspector.dart b/packages/devtools_app/lib/src/screens/network/network_request_inspector.dart
index a2f4764..5297d7b 100644
--- a/packages/devtools_app/lib/src/screens/network/network_request_inspector.dart
+++ b/packages/devtools_app/lib/src/screens/network/network_request_inspector.dart
@@ -18,6 +18,7 @@
const NetworkRequestInspector({super.key});
static const _overviewTabTitle = 'Overview';
+ static const _framesTabTitle = 'Frames';
static const _headersTabTitle = 'Headers';
static const _requestTabTitle = 'Request';
static const _responseTabTitle = 'Response';
@@ -68,6 +69,11 @@
tab: _buildTab(tabName: NetworkRequestInspector._overviewTabTitle),
tabView: NetworkRequestOverviewView(data),
),
+ if (data is WebSocket)
+ (
+ tab: _buildTab(tabName: NetworkRequestInspector._framesTabTitle),
+ tabView: WebSocketFramesView(data),
+ ),
if (data is DartIOHttpRequestData) ...[
(
tab: _buildTab(tabName: NetworkRequestInspector._headersTabTitle),
diff --git a/packages/devtools_app/lib/src/screens/network/network_request_inspector_views.dart b/packages/devtools_app/lib/src/screens/network/network_request_inspector_views.dart
index 118f76c..1567b4f 100644
--- a/packages/devtools_app/lib/src/screens/network/network_request_inspector_views.dart
+++ b/packages/devtools_app/lib/src/screens/network/network_request_inspector_views.dart
@@ -13,6 +13,8 @@
import '../../shared/http/http_request_data.dart';
import '../../shared/primitives/byte_utils.dart';
import '../../shared/primitives/utils.dart';
+import '../../shared/table/table.dart';
+import '../../shared/table/table_data.dart';
import '../../shared/ui/colors.dart';
import '../../shared/ui/common_widgets.dart';
import 'network_controller.dart';
@@ -596,6 +598,129 @@
}
}
+class _WebSocketFrameColumn extends ColumnData<WebSocketEvent> {
+ const _WebSocketFrameColumn() : super('Frame', fixedWidthPx: 60);
+
+ @override
+ int? getValue(WebSocketEvent dataObject) => dataObject.frameNumber;
+
+ @override
+ String getDisplayValue(WebSocketEvent dataObject) =>
+ dataObject.frameNumber?.toString() ?? '--';
+}
+
+class _WebSocketTimestampColumn extends ColumnData<WebSocketEvent> {
+ const _WebSocketTimestampColumn() : super('Timestamp', fixedWidthPx: 150);
+
+ @override
+ DateTime getValue(WebSocketEvent dataObject) => dataObject.timestamp;
+
+ @override
+ String getDisplayValue(WebSocketEvent dataObject) =>
+ formatDateTime(dataObject.timestamp);
+}
+
+class _WebSocketDirectionColumn extends ColumnData<WebSocketEvent> {
+ const _WebSocketDirectionColumn() : super('Direction', fixedWidthPx: 100);
+
+ @override
+ String getValue(WebSocketEvent dataObject) => dataObject.direction ?? '--';
+
+ @override
+ String getDisplayValue(WebSocketEvent dataObject) =>
+ dataObject.direction ?? '--';
+}
+
+class _WebSocketEventColumn extends ColumnData<WebSocketEvent> {
+ const _WebSocketEventColumn() : super('Event', fixedWidthPx: 120);
+
+ @override
+ String getValue(WebSocketEvent dataObject) {
+ final event = dataObject.event;
+ return event.startsWith('WebSocket.')
+ ? event.substring('WebSocket.'.length)
+ : event;
+ }
+
+ @override
+ String getDisplayValue(WebSocketEvent dataObject) => getValue(dataObject);
+}
+
+class _WebSocketOpcodeColumn extends ColumnData<WebSocketEvent> {
+ const _WebSocketOpcodeColumn() : super('Opcode', fixedWidthPx: 100);
+
+ @override
+ String getValue(WebSocketEvent dataObject) =>
+ dataObject.opcode?.toString() ?? '--';
+
+ @override
+ String getDisplayValue(WebSocketEvent dataObject) =>
+ dataObject.opcode?.toString() ?? '--';
+}
+
+class _WebSocketSizeColumn extends ColumnData<WebSocketEvent> {
+ const _WebSocketSizeColumn() : super('Size', fixedWidthPx: 100);
+
+ @override
+ int? getValue(WebSocketEvent dataObject) => dataObject.payloadSize;
+
+ @override
+ String getDisplayValue(WebSocketEvent dataObject) =>
+ dataObject.payloadSize != null
+ ? formatBytes(dataObject.payloadSize!)
+ : '--';
+}
+
+class _WebSocketErrorColumn extends ColumnData<WebSocketEvent> {
+ const _WebSocketErrorColumn() : super('Error', fixedWidthPx: 180);
+
+ @override
+ String getValue(WebSocketEvent dataObject) =>
+ dataObject.errorMessage ?? dataObject.errorType ?? '--';
+
+ @override
+ String getDisplayValue(WebSocketEvent dataObject) =>
+ dataObject.errorMessage ?? dataObject.errorType ?? '--';
+}
+
+class WebSocketFramesView extends StatelessWidget {
+ const WebSocketFramesView(this.data, {super.key});
+
+ final WebSocket data;
+
+ static const _columns = <ColumnData<WebSocketEvent>>[
+ _WebSocketFrameColumn(),
+ _WebSocketTimestampColumn(),
+ _WebSocketDirectionColumn(),
+ _WebSocketEventColumn(),
+ _WebSocketOpcodeColumn(),
+ _WebSocketSizeColumn(),
+ _WebSocketErrorColumn(),
+ ];
+
+ @override
+ Widget build(BuildContext context) {
+ final events = data.events;
+
+ if (events.isEmpty) {
+ return const Center(child: Text('No WebSocket events'));
+ }
+
+ return Padding(
+ padding: const EdgeInsets.all(defaultSpacing),
+ child: FlatTable<WebSocketEvent>(
+ keyFactory: (event) => ValueKey<WebSocketEvent>(event),
+ data: events,
+ dataKey: 'websocket-frames',
+ columns: _columns,
+ defaultSortColumn: _columns.first,
+ defaultSortDirection: SortDirection.ascending,
+ sizeColumnsToFit: false,
+ ),
+ );
+ }
+}
+
class NetworkRequestOverviewView extends StatelessWidget {
const NetworkRequestOverviewView(this.data, {super.key});
@@ -605,6 +730,8 @@
static const httpTimingGraphKey = Key('Http Timing Graph Key');
@visibleForTesting
static const socketTimingGraphKey = Key('Socket Timing Graph Key');
+ @visibleForTesting
+ static const webSocketTimingGraphKey = Key('WebSocket Timing Graph Key');
final NetworkRequest data;
@@ -615,7 +742,10 @@
padding: const EdgeInsets.all(defaultSpacing),
children: [
..._buildGeneralRows(context),
- if (data is Socket) ..._buildSocketOverviewRows(context),
+ if (data is Socket)
+ ..._buildSocketOverviewRows(context)
+ else if (data is WebSocket)
+ ..._buildWebSocketOverviewRows(context),
const PaddedDivider(
padding: EdgeInsets.only(bottom: denseRowSpacing),
),
@@ -661,14 +791,16 @@
const SizedBox(height: defaultSpacing),
],
- _buildRow(
- context: context,
- title: 'Response Size',
- child: _valueText(bytes != null ? formatBytes(bytes) : '-'),
- ),
- const SizedBox(height: defaultSpacing),
+ if (data is! WebSocket) ...[
+ _buildRow(
+ context: context,
+ title: 'Response Size',
+ child: _valueText(bytes != null ? formatBytes(bytes) : '-'),
+ ),
+ const SizedBox(height: defaultSpacing),
+ ],
- if (data.contentType != null) ...[
+ if (data.contentType != null && data is! WebSocket) ...[
_buildRow(
context: context,
title: 'Content type',
@@ -684,20 +816,28 @@
_buildRow(
context: context,
title: 'Timing',
- child: data is Socket
- ? _buildSocketTimeGraph(context)
- : _buildHttpTimeGraph(),
+ child: switch (data) {
+ Socket() => _buildSocketTimeGraph(context),
+ WebSocket() => _buildWebSocketTimeGraph(context),
+ _ => _buildHttpTimeGraph(),
+ },
),
const SizedBox(height: denseSpacing),
_buildRow(
context: context,
title: '',
- child: _valueText(data.durationDisplay),
+ child: _valueText(
+ data.duration != null
+ ? durationText(data.duration!, fractionDigits: 0)
+ : 'Pending',
+ ),
),
const SizedBox(height: defaultSpacing),
- ...data is Socket
- ? _buildSocketTimingRows(context)
- : _buildHttpTimingRows(context),
+ ...switch (data) {
+ Socket() => _buildSocketTimingRows(context),
+ WebSocket() => _buildWebSocketTimingRows(context),
+ _ => _buildHttpTimingRows(context),
+ },
const SizedBox(height: defaultSpacing),
_buildRow(
context: context,
@@ -717,7 +857,7 @@
];
}
- Duration? get _totalDuration => (data as DartIOHttpRequestData).duration;
+ Duration? get _totalDuration => data.duration;
Widget _buildTimingRow(
Color color,
@@ -839,6 +979,116 @@
];
}
+ List<Widget> _buildWebSocketOverviewRows(BuildContext context) {
+ final webSocket = data as WebSocket;
+
+ return [
+ _buildRow(
+ context: context,
+ title: 'Connection id',
+ child: _valueText(webSocket.connectionId),
+ ),
+ const SizedBox(height: defaultSpacing),
+ _buildRow(
+ context: context,
+ title: 'Protocol',
+ child: _valueText(webSocket.protocol ?? '--'),
+ ),
+ const SizedBox(height: defaultSpacing),
+ _buildRow(
+ context: context,
+ title: 'Bytes sent',
+ child: _valueText(formatBytes(webSocket.bytesSent)),
+ ),
+ const SizedBox(height: defaultSpacing),
+ _buildRow(
+ context: context,
+ title: 'Bytes received',
+ child: _valueText(formatBytes(webSocket.bytesReceived)),
+ ),
+ const SizedBox(height: defaultSpacing),
+ _buildRow(
+ context: context,
+ title: 'Frames sent',
+ child: _valueText('${webSocket.framesSent}'),
+ ),
+ const SizedBox(height: defaultSpacing),
+ _buildRow(
+ context: context,
+ title: 'Frames received',
+ child: _valueText('${webSocket.framesReceived}'),
+ ),
+ const SizedBox(height: defaultSpacing),
+ _buildRow(
+ context: context,
+ title: 'Ping count',
+ child: _valueText('${webSocket.pingCount}'),
+ ),
+ const SizedBox(height: defaultSpacing),
+ _buildRow(
+ context: context,
+ title: 'Pong count',
+ child: _valueText('${webSocket.pongCount}'),
+ ),
+ if (webSocket.closeCode case final closeCode?) ...[
+ const SizedBox(height: defaultSpacing),
+ _buildRow(
+ context: context,
+ title: 'Close code',
+ child: _valueText('$closeCode'),
+ ),
+ ],
+ if (webSocket.closeReason case final closeReason?) ...[
+ const SizedBox(height: defaultSpacing),
+ _buildRow(
+ context: context,
+ title: 'Close reason',
+ child: _valueText(closeReason),
+ ),
+ ],
+ if (webSocket.error case final error?) ...[
+ const SizedBox(height: defaultSpacing),
+ _buildRow(
+ context: context,
+ title: 'Error',
+ child: _valueText(
+ error,
+ TextStyle(color: Theme.of(context).colorScheme.error),
+ ),
+ ),
+ ],
+ ];
+ }
+
+ List<Widget> _buildWebSocketTimingRows(BuildContext context) {
+ final webSocket = data as WebSocket;
+
+ return [
+ _buildRow(
+ context: context,
+ title: 'Last updated',
+ child: _valueText(formatDateTime(webSocket.lastUpdated)),
+ ),
+ const SizedBox(height: defaultSpacing),
+ _buildRow(
+ context: context,
+ title: 'Frames',
+ child: _valueText(
+ '${webSocket.framesSent} sent, '
+ '${webSocket.framesReceived} received',
+ ),
+ ),
+ ];
+ }
+
+ Widget _buildWebSocketTimeGraph(BuildContext context) {
+ return Container(
+ key: webSocketTimingGraphKey,
+ height: _timingGraphHeight,
+ color: Theme.of(context).colorScheme.primary,
+ );
+ }
+
Widget _buildSocketTimeGraph(BuildContext context) {
return Container(
key: socketTimingGraphKey,
diff --git a/packages/devtools_app/lib/src/screens/network/network_service.dart b/packages/devtools_app/lib/src/screens/network/network_service.dart
index 59fbb64..34132bb 100644
--- a/packages/devtools_app/lib/src/screens/network/network_service.dart
+++ b/packages/devtools_app/lib/src/screens/network/network_service.dart
@@ -21,6 +21,10 @@
/// use the VM's monotonic timeline clock — not wall-clock time.
final lastHttpDataRefreshTimePerIsolate = <String, int>{};
+ /// Tracks the time (microseconds since epoch) that the WebSocket profile was
+ /// last retrieved for a given isolate ID.
+ final lastWebSocketDataRefreshTimePerIsolate = <String, int>{};
+
/// Updates the last Socket data refresh time to the current time.
///
/// If [alreadyRecordingSocketData] is true, it's unclear when the last
@@ -67,6 +71,26 @@
}
}
+ /// Updates the last WebSocket data refresh time to the current time.
+ ///
+ /// WebSocket profiling is controlled by HttpClient.enableTimelineLogging,
+ /// so this timestamp follows the HTTP timeline logging lifecycle.
+ Future<void> updateLastWebSocketDataRefreshTime({
+ bool alreadyRecordingWebSocket = false,
+ }) async {
+ if (!alreadyRecordingWebSocket) {
+ final service = serviceConnection.serviceManager.service;
+ if (service == null) return;
+
+ final timestamp = (await service.getVMTimelineMicros()).timestamp!;
+
+ for (final isolateId
+ in lastWebSocketDataRefreshTimePerIsolate.keys.toList()) {
+ lastWebSocketDataRefreshTimePerIsolate[isolateId] = timestamp;
+ }
+ }
+ }
+
/// Force refreshes the HTTP requests logged to the timeline as well as any
/// recorded Socket traffic.
///
@@ -86,6 +110,10 @@
if (cancelledCallback?.call() ?? false) return;
networkController.lastSocketDataRefreshMicros = timestamp;
+
+ final webSockets = await _refreshWebSocketProfile();
+ if (cancelledCallback?.call() ?? false) return;
+
List<HttpProfileRequest>? httpRequests;
httpRequests = await _refreshHttpProfile();
if (cancelledCallback?.call() ?? false) return;
@@ -93,6 +121,7 @@
networkController.processNetworkTraffic(
sockets: sockets,
httpRequests: httpRequests,
+ webSockets: webSockets,
);
} on RPCError catch (e) {
if (!e.isServiceDisposedError) {
@@ -145,6 +174,67 @@
});
}
+ Future<List<WebSocketConnection>> _refreshWebSocketProfile() async {
+ final service = serviceConnection.serviceManager.service;
+ if (service == null) return const [];
+
+ final connections = <WebSocketConnection>[];
+
+ await service.forEachIsolate((isolate) async {
+ final isolateId = isolate.id!;
+
+ if (!await service.isWebSocketProfilingAvailable(isolateId)) {
+ return;
+ }
+
+ final profile = await service.getWebSocketProfile(
+ isolateId,
+ updatedSince: DateTime.fromMicrosecondsSinceEpoch(
+ lastWebSocketDataRefreshTimePerIsolate.putIfAbsent(
+ isolateId,
+ // If a new isolate has spawned, request all WebSocket connections
+ // from the start of the profile.
+ () => 0,
+ ),
+ ),
+ );
+
+ final fullConnections = await Future.wait(
+ profile.connections.map(
+ (connection) =>
+ service.getWebSocketConnection(isolateId, connection.id),
+ ),
+ );
+
+ connections.addAll(fullConnections);
+
+ // Use the profile timestamp rather than DateTime.now() so that we don't
+ // miss updates between the profile snapshot and this assignment.
+ lastWebSocketDataRefreshTimePerIsolate[isolateId] =
+ profile.timestamp.microsecondsSinceEpoch;
+ });
+
+ return connections;
+ }
+
+ Future<void> _clearWebSocketProfile() async {
+ final service = serviceConnection.serviceManager.service;
+ if (service == null) return;
+
+ await service.forEachIsolate((isolate) async {
+ final isolateId = isolate.id!;
+
+ if (!await service.isWebSocketProfilingAvailable(isolateId)) {
+ return;
+ }
+
+ final future = service.clearWebSocketProfile(isolateId);
+
+ // The call may not complete immediately if the isolate is paused.
+ await timeout(future, 500);
+ });
+ }
+
Future<List<SocketStatistic>> _refreshSockets() async {
final service = serviceConnection.serviceManager.service;
if (service == null) return [];
@@ -214,6 +304,7 @@
await updateLastHttpDataRefreshTime();
await _clearSocketProfile();
await _clearHttpProfile();
+ await _clearWebSocketProfile();
} on RPCError catch (e) {
if (!e.isServiceDisposedError) {
rethrow;
diff --git a/packages/devtools_app/lib/src/screens/network/offline_network_data.dart b/packages/devtools_app/lib/src/screens/network/offline_network_data.dart
index 2a7cc91..09c12a0 100644
--- a/packages/devtools_app/lib/src/screens/network/offline_network_data.dart
+++ b/packages/devtools_app/lib/src/screens/network/offline_network_data.dart
@@ -16,6 +16,7 @@
OfflineNetworkData({
required this.httpRequestData,
required this.socketData,
+ required this.webSocketData,
this.selectedRequestId,
required this.timelineMicrosOffset,
});
@@ -62,12 +63,24 @@
selectedRequestId:
json[_OfflineDataKeys.selectedRequestId.name] as String?,
socketData: socketData,
+ webSocketData:
+ (json[_OfflineDataKeys.webSocketData.name] as List<Object?>?)
+ ?.map((e) {
+ if (e is Map<String, Object?>) {
+ return WebSocket.fromJson(e);
+ }
+ return null;
+ })
+ .whereType<WebSocket>()
+ .toList() ??
+ [],
timelineMicrosOffset: timelineMicrosOffset as int? ?? 0,
);
}
@visibleForTesting
- bool get isEmpty => httpRequestData.isEmpty && socketData.isEmpty;
+ bool get isEmpty =>
+ httpRequestData.isEmpty && socketData.isEmpty && webSocketData.isEmpty;
/// List of current [DartIOHttpRequestData] network requests.
final List<DartIOHttpRequestData> httpRequestData;
@@ -81,6 +94,9 @@
/// The list of socket statistics for the offline network data.
final List<Socket> socketData;
+ /// The list of WebSocket connections for the offline network data.
+ final List<WebSocket> webSocketData;
+
/// Converts the current offline data to a JSON format.
@override
Map<String, Object?> toJson() {
@@ -92,6 +108,9 @@
_OfflineDataKeys.socketData.name: socketData
.map((e) => e.toJson())
.toList(),
+ _OfflineDataKeys.webSocketData.name: webSocketData
+ .map((e) => e.toJson())
+ .toList(),
_OfflineDataKeys.timelineMicrosOffset.name: timelineMicrosOffset,
};
}
@@ -101,6 +120,7 @@
httpRequestData,
selectedRequestId,
socketData,
+ webSocketData,
request,
timelineMicrosOffset,
}
diff --git a/packages/devtools_app/pubspec.yaml b/packages/devtools_app/pubspec.yaml
index c7e55d7..a95b396 100644
--- a/packages/devtools_app/pubspec.yaml
+++ b/packages/devtools_app/pubspec.yaml
@@ -54,7 +54,7 @@
stack_trace: ^1.12.0
string_scanner: ^1.4.0
unified_analytics: ^7.0.0
- vm_service: ^15.0.2
+ vm_service: ^15.3.0
vm_service_protos: ^2.0.0
vm_snapshot_analysis: ^0.7.6
web: ^1.0.0
diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
index 998ed94..01ad0a7 100644
--- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
+++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
@@ -53,6 +53,9 @@
## Network profiler updates
+* Added WebSocket support to the Network profiler, including WebSocket
+ connection details, lifecycle events, frame-level inspection, and connection
+ timing information. [#9968](https://github.com/flutter/devtools/pull/9968)
* Fixed an issue where the Network tab would stop capturing HTTP requests after
a hot restart. -
[#9856](https://github.com/flutter/devtools/pull/9856)
diff --git a/packages/devtools_app/test/screens/network/network_controller_test.dart b/packages/devtools_app/test/screens/network/network_controller_test.dart
index 09553eb..2da78f0 100644
--- a/packages/devtools_app/test/screens/network/network_controller_test.dart
+++ b/packages/devtools_app/test/screens/network/network_controller_test.dart
@@ -420,6 +420,7 @@
currentNetworkRequests.updateOrAddAll(
requests: reqs,
sockets: sockets,
+ webSockets: const [],
timelineMicrosOffset: 0,
);
expect(notifyCount, 1);
@@ -439,6 +440,7 @@
currentNetworkRequests.updateOrAddAll(
requests: [request1Done],
sockets: [socketStats1Done],
+ webSockets: const [],
timelineMicrosOffset: 0,
);
expect(notifyCount, 2);
@@ -462,6 +464,7 @@
currentNetworkRequests.updateOrAddAll(
requests: [request1Done],
sockets: const [],
+ webSockets: const [],
timelineMicrosOffset: 0,
);
@@ -473,6 +476,7 @@
currentNetworkRequests.updateOrAddAll(
requests: [request1CancelledWithStatusCode],
sockets: const [],
+ webSockets: const [],
timelineMicrosOffset: 0,
);
@@ -488,6 +492,7 @@
currentNetworkRequests.updateOrAddAll(
requests: reqs,
sockets: sockets,
+ webSockets: const [],
timelineMicrosOffset: 0,
);
@@ -513,6 +518,7 @@
currentNetworkRequests.updateOrAddAll(
requests: reqs,
sockets: sockets,
+ webSockets: const [],
timelineMicrosOffset: 0,
);
@@ -539,5 +545,207 @@
);
});
});
+ group('websocket', () {
+ final connectTime = DateTime(2026, 8, 18, 10).toUtc();
+ final openTime = connectTime.add(const Duration(seconds: 1));
+ final lastUpdated = openTime.add(const Duration(seconds: 2));
+
+ final events = [
+ WebSocketEvent(event: 'WebSocket.Connect', timestamp: connectTime),
+ WebSocketEvent(
+ event: 'WebSocket.Send',
+ timestamp: openTime,
+ frameNumber: 1,
+ direction: 'out',
+ opcode: 'text',
+ payloadSize: 5,
+ ),
+ WebSocketEvent(
+ event: 'WebSocket.Receive',
+ timestamp: lastUpdated,
+ frameNumber: 2,
+ direction: 'in',
+ opcode: 'text',
+ payloadSize: 7,
+ ),
+ ];
+
+ WebSocketConnection createConnection({
+ String id = '42',
+ String state = 'open',
+ int bytesSent = 5,
+ int bytesReceived = 7,
+ int framesSent = 1,
+ int framesReceived = 1,
+ List<WebSocketEvent>? connectionEvents,
+ }) {
+ return WebSocketConnection(
+ isolateId: 'isolate-1',
+ id: id,
+ uri: Uri.parse('wss://example.com/socket'),
+ state: state,
+ protocol: 'chat',
+ connectTimestamp: connectTime,
+ openTimestamp: openTime,
+ bytesSent: bytesSent,
+ bytesReceived: bytesReceived,
+ framesSent: framesSent,
+ framesReceived: framesReceived,
+ pingCount: 2,
+ pongCount: 2,
+ lastUpdated: lastUpdated,
+ events: connectionEvents ?? events,
+ );
+ }
+
+ test('adds WebSocket connection', () {
+ final connection = createConnection();
+
+ currentNetworkRequests.updateOrAddAll(
+ requests: const [],
+ sockets: const [],
+ webSockets: [connection],
+ timelineMicrosOffset: 0,
+ );
+
+ expect(currentNetworkRequests.value, hasLength(1));
+
+ final request = currentNetworkRequests.getRequest(
+ 'websocket:isolate-1:42',
+ );
+
+ expect(request, isA<WebSocket>());
+
+ final webSocket = request! as WebSocket;
+ expect(webSocket.connectionId, '42');
+ expect(webSocket.uri, 'wss://example.com/socket');
+ expect(webSocket.protocol, 'chat');
+ expect(webSocket.state, 'open');
+ expect(webSocket.bytesSent, 5);
+ expect(webSocket.bytesReceived, 7);
+ expect(webSocket.framesSent, 1);
+ expect(webSocket.framesReceived, 1);
+ expect(webSocket.pingCount, 2);
+ expect(webSocket.pongCount, 2);
+ expect(webSocket.events, hasLength(3));
+ });
+
+ test('updates existing WebSocket instead of adding duplicate', () {
+ final initialConnection = createConnection();
+
+ currentNetworkRequests.updateOrAddAll(
+ requests: const [],
+ sockets: const [],
+ webSockets: [initialConnection],
+ timelineMicrosOffset: 0,
+ );
+
+ final updatedConnection = createConnection(
+ bytesSent: 15,
+ bytesReceived: 27,
+ framesSent: 3,
+ framesReceived: 4,
+ connectionEvents: [
+ ...events,
+ WebSocketEvent(
+ event: 'WebSocket.Send',
+ timestamp: lastUpdated,
+ frameNumber: 3,
+ direction: 'out',
+ opcode: 'binary',
+ payloadSize: 10,
+ ),
+ ],
+ );
+
+ currentNetworkRequests.updateOrAddAll(
+ requests: const [],
+ sockets: const [],
+ webSockets: [updatedConnection],
+ timelineMicrosOffset: 0,
+ );
+
+ expect(currentNetworkRequests.value, hasLength(1));
+
+ final request = currentNetworkRequests.getRequest(
+ 'websocket:isolate-1:42',
+ );
+
+ expect(request, isA<WebSocket>());
+
+ final webSocket = request! as WebSocket;
+ expect(webSocket.bytesSent, 15);
+ expect(webSocket.bytesReceived, 27);
+ expect(webSocket.framesSent, 3);
+ expect(webSocket.framesReceived, 4);
+ expect(webSocket.events, hasLength(4));
+ });
+
+ test('notifies listeners once for multiple WebSocket connections', () {
+ final connection1 = createConnection();
+
+ final connection2 = createConnection(id: '43');
+
+ currentNetworkRequests.updateOrAddAll(
+ requests: const [],
+ sockets: const [],
+ webSockets: [connection1, connection2],
+ timelineMicrosOffset: 0,
+ );
+
+ expect(currentNetworkRequests.value, hasLength(2));
+ expect(notifyCount, 1);
+ });
+
+ test('maps closed WebSocket state and duration', () {
+ final closeTime = openTime.add(const Duration(seconds: 5));
+
+ final connection = WebSocketConnection(
+ isolateId: 'isolate-1',
+ id: '44',
+ uri: Uri.parse('wss://example.com/socket'),
+ state: 'closed',
+ protocol: 'chat',
+ connectTimestamp: connectTime,
+ openTimestamp: openTime,
+ closeTimestamp: closeTime,
+ closeCode: 1000,
+ closeReason: 'Normal closure',
+ lastUpdated: closeTime,
+ events: const [],
+ );
+
+ final webSocket = WebSocket(connection);
+
+ expect(webSocket.state, 'closed');
+ expect(webSocket.inProgress, false);
+ expect(webSocket.didFail, false);
+ expect(webSocket.endTimestamp, closeTime);
+ expect(webSocket.duration, closeTime.difference(connectTime));
+ expect(webSocket.closeCode, 1000);
+ expect(webSocket.closeReason, 'Normal closure');
+ });
+
+ test('maps WebSocket error state', () {
+ final connection = WebSocketConnection(
+ isolateId: 'isolate-1',
+ id: '45',
+ uri: Uri.parse('wss://example.com/socket'),
+ state: 'error',
+ connectTimestamp: connectTime,
+ closeTimestamp: lastUpdated,
+ error: 'Connection failed',
+ lastUpdated: lastUpdated,
+ events: const [],
+ );
+
+ final webSocket = WebSocket(connection);
+
+ expect(webSocket.state, 'error');
+ expect(webSocket.didFail, true);
+ expect(webSocket.inProgress, false);
+ expect(webSocket.error, 'Connection failed');
+ });
+ });
});
}
diff --git a/packages/devtools_app/test/screens/network/network_model_test.dart b/packages/devtools_app/test/screens/network/network_model_test.dart
index 5ecdbfb..42d9396 100644
--- a/packages/devtools_app/test/screens/network/network_model_test.dart
+++ b/packages/devtools_app/test/screens/network/network_model_test.dart
@@ -68,6 +68,221 @@
});
});
+ group('WebSocketNetworkRequest', () {
+ final connectTimestamp = DateTime.utc(2026, 8, 18, 10);
+ final openTimestamp = connectTimestamp.add(const Duration(seconds: 1));
+ final lastUpdated = openTimestamp.add(const Duration(seconds: 2));
+
+ WebSocketConnection createConnection({
+ String id = '42',
+ String state = 'open',
+ int bytesSent = 100,
+ int bytesReceived = 200,
+ int framesSent = 10,
+ int framesReceived = 20,
+ int pingCount = 3,
+ int pongCount = 3,
+ DateTime? closeTimestamp,
+ int? closeCode,
+ String? closeReason,
+ String? error,
+ List<WebSocketEvent>? events,
+ }) {
+ return WebSocketConnection(
+ isolateId: 'isolate-1',
+ id: id,
+ uri: Uri.parse('wss://example.com/socket'),
+ state: state,
+ protocol: 'chat',
+ connectTimestamp: connectTimestamp,
+ openTimestamp: openTimestamp,
+ closeTimestamp: closeTimestamp,
+ bytesSent: bytesSent,
+ bytesReceived: bytesReceived,
+ framesSent: framesSent,
+ framesReceived: framesReceived,
+ pingCount: pingCount,
+ pongCount: pongCount,
+ closeCode: closeCode,
+ closeReason: closeReason,
+ error: error,
+ lastUpdated: lastUpdated,
+ events: events ?? const [],
+ );
+ }
+
+ test('maps connection properties correctly', () {
+ final connection = createConnection();
+
+ final webSocket = WebSocket(connection);
+
+ expect(webSocket.connectionId, '42');
+ expect(webSocket.uri, 'wss://example.com/socket');
+ expect(webSocket.protocol, 'chat');
+ expect(webSocket.state, 'open');
+
+ expect(webSocket.bytesSent, 100);
+ expect(webSocket.bytesReceived, 200);
+ expect(webSocket.framesSent, 10);
+ expect(webSocket.framesReceived, 20);
+ expect(webSocket.pingCount, 3);
+ expect(webSocket.pongCount, 3);
+
+ expect(webSocket.closeCode, isNull);
+ expect(webSocket.closeReason, isNull);
+ expect(webSocket.error, isNull);
+ expect(webSocket.lastUpdated, lastUpdated);
+ });
+
+ test('maps NetworkRequest properties correctly', () {
+ final connection = createConnection();
+
+ final webSocket = WebSocket(connection);
+
+ expect(webSocket.id, 'websocket:isolate-1:42');
+ expect(webSocket.method, 'WEBSOCKET');
+ expect(webSocket.uri, 'wss://example.com/socket');
+ expect(webSocket.contentType, 'websocket');
+ expect(webSocket.type, 'WebSocket');
+
+ expect(webSocket.startTimestamp, connectTimestamp);
+ expect(webSocket.endTimestamp, isNull);
+ expect(webSocket.duration, isNull);
+
+ expect(webSocket.status, 'open');
+ expect(webSocket.port, isNull);
+
+ expect(webSocket.requestBytes, 100);
+ expect(webSocket.responseBytes, 200);
+
+ expect(webSocket.didFail, false);
+ expect(webSocket.inProgress, true);
+ });
+
+ test('maps WebSocket events correctly', () {
+ final events = [
+ WebSocketEvent(event: 'WebSocket.Connect', timestamp: connectTimestamp),
+ WebSocketEvent(
+ event: 'WebSocket.Send',
+ timestamp: openTimestamp,
+ frameNumber: 1,
+ direction: 'out',
+ opcode: 'text',
+ payloadSize: 25,
+ ),
+ WebSocketEvent(
+ event: 'WebSocket.Receive',
+ timestamp: lastUpdated,
+ frameNumber: 2,
+ direction: 'in',
+ opcode: 'text',
+ payloadSize: 40,
+ ),
+ ];
+
+ final webSocket = WebSocket(createConnection(events: events));
+
+ expect(webSocket.events, hasLength(3));
+
+ expect(webSocket.events[0].event, 'WebSocket.Connect');
+
+ expect(webSocket.events[1].frameNumber, 1);
+ expect(webSocket.events[1].direction, 'out');
+ expect(webSocket.events[1].opcode, 'text');
+ expect(webSocket.events[1].payloadSize, 25);
+
+ expect(webSocket.events[2].frameNumber, 2);
+ expect(webSocket.events[2].direction, 'in');
+ expect(webSocket.events[2].opcode, 'text');
+ expect(webSocket.events[2].payloadSize, 40);
+ });
+
+ test('maps closed connection lifecycle correctly', () {
+ final closeTimestamp = openTimestamp.add(const Duration(seconds: 5));
+
+ final webSocket = WebSocket(
+ createConnection(
+ state: 'closed',
+ closeTimestamp: closeTimestamp,
+ closeCode: 1000,
+ closeReason: 'Normal closure',
+ ),
+ );
+
+ expect(webSocket.state, 'closed');
+ expect(webSocket.inProgress, false);
+ expect(webSocket.didFail, false);
+
+ expect(webSocket.endTimestamp, closeTimestamp);
+ expect(webSocket.duration, closeTimestamp.difference(connectTimestamp));
+
+ expect(webSocket.closeCode, 1000);
+ expect(webSocket.closeReason, 'Normal closure');
+ });
+
+ test('maps error connection correctly', () {
+ final webSocket = WebSocket(
+ createConnection(state: 'error', error: 'Connection failed'),
+ );
+
+ expect(webSocket.state, 'error');
+ expect(webSocket.error, 'Connection failed');
+ expect(webSocket.didFail, true);
+ expect(webSocket.inProgress, false);
+ });
+
+ test('equality and hashCode use stable connection identity', () {
+ final first = WebSocket(createConnection());
+
+ final second = WebSocket(createConnection(bytesSent: 500));
+
+ final different = WebSocket(createConnection(id: '43'));
+
+ expect(first == second, true);
+ expect(first.hashCode, second.hashCode);
+ expect(first == different, false);
+ });
+
+ test('toJson serializes connection and events', () {
+ final events = [
+ WebSocketEvent(
+ event: 'WebSocket.Send',
+ timestamp: openTimestamp,
+ frameNumber: 1,
+ direction: 'out',
+ opcode: 'text',
+ payloadSize: 25,
+ ),
+ ];
+
+ final webSocket = WebSocket(createConnection(events: events));
+
+ final json = webSocket.toJson();
+
+ expect(json['connectionId'], '42');
+ expect(json['uri'], 'wss://example.com/socket');
+ expect(json['protocol'], 'chat');
+ expect(json['state'], 'open');
+
+ expect(json['bytesSent'], 100);
+ expect(json['bytesReceived'], 200);
+ expect(json['framesSent'], 10);
+ expect(json['framesReceived'], 20);
+ expect(json['pingCount'], 3);
+ expect(json['pongCount'], 3);
+
+ expect(json['events'], hasLength(1));
+
+ final event = (json['events'] as List).first as Map<String, Object?>;
+
+ expect(event['event'], 'WebSocket.Send');
+ expect(event['frameNumber'], 1);
+ expect(event['direction'], 'out');
+ expect(event['opcode'], 'text');
+ expect(event['payloadSize'], 25);
+ });
+ });
+
group('DartIOHttpRequestData', () {
NetworkController controller;
FakeServiceConnectionManager fakeServiceConnection;
diff --git a/packages/devtools_app/test/screens/network/network_profiler_test.dart b/packages/devtools_app/test/screens/network/network_profiler_test.dart
index 053e745..d955650 100644
--- a/packages/devtools_app/test/screens/network/network_profiler_test.dart
+++ b/packages/devtools_app/test/screens/network/network_profiler_test.dart
@@ -389,6 +389,14 @@
});
group('NetworkRequestOverviewView', () {
+ setUp(() {
+ setGlobal(
+ ServiceConnectionManager,
+ FakeServiceConnectionManager(
+ service: FakeServiceManager.createFakeService(),
+ ),
+ );
+ });
Future<void> pumpView(WidgetTester tester, NetworkRequest data) async {
final widget = wrap(NetworkRequestOverviewView(data));
await tester.pumpWidget(widget);
@@ -558,7 +566,7 @@
expect(find.text('Start time: '), findsOneWidget);
expect(find.text(formatDateTime(data.startTimestamp)), findsOneWidget);
expect(find.text('End time: '), findsOneWidget);
- expect(find.text('Pending'), findsOneWidget);
+ expect(find.text('Pending'), findsNWidgets(2));
expect(
find.byKey(NetworkRequestOverviewView.socketTimingGraphKey),
findsOneWidget,
@@ -575,5 +583,176 @@
);
},
);
+ testWidgetsWithWindowSize('displays for web socket request', windowSize, (
+ tester,
+ ) async {
+ final connection = WebSocketConnection(
+ isolateId: 'isolate-1',
+ id: '42',
+ uri: Uri.parse('wss://example.com/socket'),
+ state: 'open',
+ protocol: 'chat',
+ connectTimestamp: DateTime.utc(2026, 8, 18, 10),
+ openTimestamp: DateTime.utc(2026, 8, 18, 10, 0, 1),
+ bytesSent: 100,
+ bytesReceived: 200,
+ framesSent: 10,
+ framesReceived: 20,
+ pingCount: 3,
+ pongCount: 3,
+ lastUpdated: DateTime.utc(2026, 8, 18, 10, 0, 2),
+ events: [
+ WebSocketEvent(
+ event: 'WebSocket.Send',
+ timestamp: DateTime.utc(2026, 8, 18, 10, 0, 1),
+ frameNumber: 1,
+ direction: 'out',
+ opcode: 'text',
+ payloadSize: 25,
+ ),
+ ],
+ );
+
+ final data = WebSocket(connection);
+
+ await pumpView(tester, data);
+
+ // Verify general information.
+ expect(find.text('Request uri: '), findsOneWidget);
+ expect(find.text('wss://example.com/socket'), findsOneWidget);
+ expect(find.text('Method: '), findsOneWidget);
+ expect(find.text('WEBSOCKET'), findsOneWidget);
+ expect(find.text('Status: '), findsOneWidget);
+ expect(find.text('open'), findsOneWidget);
+ expect(find.text('Port: '), findsNothing);
+ expect(find.text('Content type: '), findsNothing);
+
+ expect(find.text('Connection id: '), findsOneWidget);
+ expect(find.text('42'), findsOneWidget);
+ expect(find.text('Protocol: '), findsOneWidget);
+ expect(find.text('chat'), findsOneWidget);
+
+ expect(find.text('Bytes sent: '), findsOneWidget);
+ expect(find.text('100 B'), findsOneWidget);
+ expect(find.text('Bytes received: '), findsOneWidget);
+ expect(find.text('200 B'), findsOneWidget);
+
+ expect(find.text('Frames sent: '), findsOneWidget);
+ expect(find.text('10'), findsOneWidget);
+ expect(find.text('Frames received: '), findsOneWidget);
+ expect(find.text('20'), findsOneWidget);
+
+ expect(find.text('Ping count: '), findsOneWidget);
+ expect(find.text('3'), findsNWidgets(2));
+
+ expect(find.text('Pong count: '), findsOneWidget);
+
+ // Verify timing information.
+ expect(find.text('Timing: '), findsOneWidget);
+ expect(find.text('Start time: '), findsOneWidget);
+ expect(find.text(formatDateTime(data.startTimestamp)), findsOneWidget);
+ expect(find.text('End time: '), findsOneWidget);
+ expect(find.text('Pending'), findsNWidgets(2));
+ expect(
+ find.byKey(NetworkRequestOverviewView.webSocketTimingGraphKey),
+ findsOneWidget,
+ );
+ });
+
+ testWidgetsWithWindowSize(
+ 'displays duration for closed web socket request',
+ windowSize,
+ (tester) async {
+ final connection = WebSocketConnection(
+ isolateId: 'isolate-1',
+ id: '42',
+ uri: Uri.parse('wss://example.com/socket'),
+ state: 'closed',
+ protocol: 'chat',
+ connectTimestamp: DateTime.utc(2026, 8, 18, 10),
+ openTimestamp: DateTime.utc(2026, 8, 18, 10, 0, 1),
+ closeTimestamp: DateTime.utc(2026, 8, 18, 10, 0, 6),
+ bytesSent: 100,
+ bytesReceived: 200,
+ framesSent: 10,
+ framesReceived: 20,
+ pingCount: 3,
+ pongCount: 3,
+ lastUpdated: DateTime.utc(2026, 8, 18, 10, 0, 7),
+ events: [
+ WebSocketEvent(
+ event: 'WebSocket.Send',
+ timestamp: DateTime.utc(2026, 8, 18, 10, 0, 1),
+ frameNumber: 1,
+ direction: 'out',
+ opcode: 'text',
+ payloadSize: 25,
+ ),
+ WebSocketEvent(
+ event: 'WebSocket.Close',
+ timestamp: DateTime.utc(2026, 8, 18, 10, 0, 6),
+ frameNumber: 2,
+ direction: 'out',
+ opcode: 'close',
+ payloadSize: 0,
+ ),
+ ],
+ );
+
+ final data = WebSocket(connection);
+
+ await pumpView(tester, data);
+
+ expect(find.text('Status: '), findsOneWidget);
+ expect(find.text('closed'), findsOneWidget);
+
+ expect(find.text('Connection id: '), findsOneWidget);
+ expect(find.text('42'), findsOneWidget);
+
+ expect(find.text('Protocol: '), findsOneWidget);
+ expect(find.text('chat'), findsOneWidget);
+
+ expect(find.text('Bytes sent: '), findsOneWidget);
+ expect(find.text('100 B'), findsOneWidget);
+
+ expect(find.text('Bytes received: '), findsOneWidget);
+ expect(find.text('200 B'), findsOneWidget);
+
+ expect(find.text('Frames sent: '), findsOneWidget);
+ expect(find.text('10'), findsOneWidget);
+
+ expect(find.text('Frames received: '), findsOneWidget);
+ expect(find.text('20'), findsOneWidget);
+
+ expect(find.text('Ping count: '), findsOneWidget);
+ expect(find.text('3'), findsNWidgets(2));
+
+ expect(find.text('Pong count: '), findsOneWidget);
+
+ // The connection lasted from 10:00:00 to 10:00:06.
+ expect(data.duration, const Duration(seconds: 6));
+ expect(
+ find.text(durationText(data.duration!, fractionDigits: 0)),
+ findsOneWidget,
+ );
+
+ expect(find.text('Start time: '), findsOneWidget);
+ expect(find.text(formatDateTime(data.startTimestamp)), findsOneWidget);
+
+ expect(find.text('End time: '), findsOneWidget);
+ expect(find.text(formatDateTime(data.endTimestamp!)), findsOneWidget);
+
+ expect(find.text('Last updated: '), findsOneWidget);
+ expect(
+ find.text(formatDateTime(connection.lastUpdated)),
+ findsOneWidget,
+ );
+
+ expect(
+ find.byKey(NetworkRequestOverviewView.webSocketTimingGraphKey),
+ findsOneWidget,
+ );
+ },
+ );
});
}
diff --git a/packages/devtools_app/test/screens/network/network_request_inspector_test.dart b/packages/devtools_app/test/screens/network/network_request_inspector_test.dart
index b2dbeca..469ec8e 100644
--- a/packages/devtools_app/test/screens/network/network_request_inspector_test.dart
+++ b/packages/devtools_app/test/screens/network/network_request_inspector_test.dart
@@ -286,5 +286,74 @@
await tester.pumpAndSettle(const Duration(seconds: 1));
},
);
+ testWidgetsWithWindowSize(
+ 'WebSocket inspector shows Frames',
+ const Size(1200, 800),
+ (tester) async {
+ final connection = WebSocketConnection(
+ isolateId: 'isolate-1',
+ id: '42',
+ uri: Uri.parse('wss://example.com/socket'),
+ state: 'open',
+ protocol: 'chat',
+ connectTimestamp: DateTime.utc(2026, 8, 18, 10),
+ openTimestamp: DateTime.utc(2026, 8, 18, 10, 0, 1),
+ bytesSent: 100,
+ bytesReceived: 200,
+ framesSent: 10,
+ framesReceived: 20,
+ pingCount: 3,
+ pongCount: 3,
+ lastUpdated: DateTime.utc(2026, 8, 18, 10, 0, 2),
+ events: [
+ WebSocketEvent(
+ event: 'WebSocket.Send',
+ timestamp: DateTime.utc(2026, 8, 18, 10, 0, 1),
+ frameNumber: 1,
+ direction: 'out',
+ opcode: 'text',
+ payloadSize: 25,
+ ),
+ WebSocketEvent(
+ event: 'WebSocket.Receive',
+ timestamp: DateTime.utc(2026, 8, 18, 10, 0, 2),
+ frameNumber: 2,
+ direction: 'in',
+ opcode: 'text',
+ payloadSize: 40,
+ ),
+ ],
+ );
+
+ final webSocket = WebSocket(connection);
+
+ await tester.pumpWidget(
+ wrapWithControllers(
+ const NetworkRequestInspector(),
+ network: NetworkController(),
+ debugger: createMockDebuggerControllerWithDefaults(),
+ ),
+ );
+
+ final controller = screenControllers.lookup<NetworkController>();
+ controller.selectedRequest.value = webSocket;
+
+ await tester.pumpAndSettle();
+
+ // The Frames tab should exist for a WebSocket.
+ expect(find.text('Frames'), findsOneWidget);
+
+ // Switch to the Frames tab.
+ await tester.tap(find.text('Frames'));
+ await tester.pumpAndSettle();
+
+ // The Frames view should now be visible.
+ expect(find.byType(WebSocketFramesView), findsWidgets);
+
+ // Stop polling before disposing the test.
+ await controller.stopRecording();
+ await tester.pumpAndSettle(const Duration(seconds: 1));
+ },
+ );
});
}
diff --git a/packages/devtools_app/test/screens/network/network_table_test.dart b/packages/devtools_app/test/screens/network/network_table_test.dart
index ebc778d..8d1e51a 100644
--- a/packages/devtools_app/test/screens/network/network_table_test.dart
+++ b/packages/devtools_app/test/screens/network/network_table_test.dart
@@ -45,6 +45,7 @@
controller.processNetworkTrafficHelper(
socketProfile.sockets,
httpProfile.requests,
+ const [],
0,
currentRequests: currentRequests,
);
@@ -105,5 +106,55 @@
// timezone the test is running in (e.g. UTC vs IST).
expect(column.getDisplayValue(getRequest), contains('26.279'));
});
+ test('WebSocket columns', () {
+ final connection = WebSocketConnection(
+ isolateId: 'isolate-1',
+ id: '42',
+ uri: Uri.parse('wss://example.com/socket'),
+ state: 'open',
+ protocol: 'chat',
+ connectTimestamp: DateTime.utc(2026, 8, 18, 10),
+ openTimestamp: DateTime.utc(2026, 8, 18, 10, 0, 1),
+ bytesSent: 100,
+ bytesReceived: 200,
+ framesSent: 10,
+ framesReceived: 20,
+ pingCount: 3,
+ pongCount: 3,
+ lastUpdated: DateTime.utc(2026, 8, 18, 10, 0, 2),
+ events: const [],
+ );
+
+ final webSocket = WebSocket(connection);
+
+ expect(
+ NetworkRequestsTable.addressColumn.getDisplayValue(webSocket),
+ connection.uri.toString(),
+ );
+ expect(
+ NetworkRequestsTable.methodColumn.getDisplayValue(webSocket),
+ 'WEBSOCKET',
+ );
+ expect(
+ NetworkRequestsTable.statusColumn.getDisplayValue(webSocket),
+ 'open',
+ );
+ expect(
+ NetworkRequestsTable.typeColumn.getDisplayValue(webSocket),
+ 'WebSocket',
+ );
+ expect(
+ NetworkRequestsTable.durationColumn.getDisplayValue(webSocket),
+ 'Pending',
+ );
+ expect(
+ NetworkRequestsTable.timestampColumn.getDisplayValue(webSocket),
+ contains('00.000'),
+ );
+ expect(
+ NetworkRequestsTable.responseSizeColumn.getDisplayValue(webSocket),
+ '200 B',
+ );
+ });
});
}
diff --git a/packages/devtools_app/test/screens/network/offline_data_test.dart b/packages/devtools_app/test/screens/network/offline_data_test.dart
index 8069682..6a653d3 100644
--- a/packages/devtools_app/test/screens/network/offline_data_test.dart
+++ b/packages/devtools_app/test/screens/network/offline_data_test.dart
@@ -131,6 +131,37 @@
// Validate selectedRequestId
expect(offlineData.selectedRequestId, isNull);
+
+ // Validate webSocketData.
+ expect(offlineData.webSocketData, hasLength(1));
+
+ final webSocket = offlineData.webSocketData.single;
+
+ expect(webSocket.connectionId, '42');
+ expect(webSocket.uri.toString(), 'wss://example.com/socket');
+ expect(webSocket.state, 'open');
+ expect(webSocket.protocol, 'chat');
+
+ expect(webSocket.bytesSent, 100);
+ expect(webSocket.bytesReceived, 200);
+ expect(webSocket.framesSent, 10);
+ expect(webSocket.framesReceived, 20);
+ expect(webSocket.pingCount, 3);
+ expect(webSocket.pongCount, 3);
+
+ expect(webSocket.events, hasLength(2));
+
+ expect(webSocket.events[0].frameNumber, 1);
+ expect(webSocket.events[0].event, 'WebSocket.Send');
+ expect(webSocket.events[0].direction, 'out');
+ expect(webSocket.events[0].opcode, 'text');
+ expect(webSocket.events[0].payloadSize, 25);
+
+ expect(webSocket.events[1].frameNumber, 2);
+ expect(webSocket.events[1].event, 'WebSocket.Receive');
+ expect(webSocket.events[1].direction, 'in');
+ expect(webSocket.events[1].opcode, 'text');
+ expect(webSocket.events[1].payloadSize, 40);
});
test('OfflineNetworkData should serialize correctly', () {
@@ -150,6 +181,7 @@
final emptyOfflineData = OfflineNetworkData(
httpRequestData: [],
socketData: [],
+ webSocketData: [],
timelineMicrosOffset: 1731482170837171,
);
@@ -161,6 +193,7 @@
final populatedHttpData = OfflineNetworkData(
httpRequestData: offlineData.httpRequestData,
socketData: [],
+ webSocketData: [],
timelineMicrosOffset: 1731482170837171,
);
@@ -177,6 +210,42 @@
);
expect(restoredData.socketData.length, offlineData.socketData.length);
expect(restoredData.selectedRequestId, offlineData.selectedRequestId);
+
+ expect(
+ restoredData.webSocketData.length,
+ offlineData.webSocketData.length,
+ );
+
+ final restoredWebSocket = restoredData.webSocketData.single;
+ final originalWebSocket = offlineData.webSocketData.single;
+
+ expect(restoredWebSocket.connectionId, originalWebSocket.connectionId);
+ expect(restoredWebSocket.uri, originalWebSocket.uri);
+ expect(restoredWebSocket.state, originalWebSocket.state);
+ expect(restoredWebSocket.protocol, originalWebSocket.protocol);
+
+ expect(restoredWebSocket.bytesSent, originalWebSocket.bytesSent);
+ expect(restoredWebSocket.bytesReceived, originalWebSocket.bytesReceived);
+ expect(restoredWebSocket.framesSent, originalWebSocket.framesSent);
+ expect(
+ restoredWebSocket.framesReceived,
+ originalWebSocket.framesReceived,
+ );
+ expect(restoredWebSocket.pingCount, originalWebSocket.pingCount);
+ expect(restoredWebSocket.pongCount, originalWebSocket.pongCount);
+
+ expect(restoredWebSocket.events, hasLength(2));
+
+ for (var i = 0; i < originalWebSocket.events.length; i++) {
+ final originalEvent = originalWebSocket.events[i];
+ final restoredEvent = restoredWebSocket.events[i];
+
+ expect(restoredEvent.frameNumber, originalEvent.frameNumber);
+ expect(restoredEvent.event, originalEvent.event);
+ expect(restoredEvent.direction, originalEvent.direction);
+ expect(restoredEvent.opcode, originalEvent.opcode);
+ expect(restoredEvent.payloadSize, originalEvent.payloadSize);
+ }
});
test('Handles serialization errors gracefully', () {
diff --git a/packages/devtools_app/test/screens/network/sample_network_offline_data.json b/packages/devtools_app/test/screens/network/sample_network_offline_data.json
index 4345951..204ef22 100644
--- a/packages/devtools_app/test/screens/network/sample_network_offline_data.json
+++ b/packages/devtools_app/test/screens/network/sample_network_offline_data.json
@@ -297,5 +297,45 @@
"writeBytes": 18247
}
}
+ ],
+ "webSocketData": [
+ {
+ "isolateId": "isolate-1",
+ "connectionId": "42",
+ "uri": "wss://example.com/socket",
+ "state": "open",
+ "protocol": "chat",
+ "connectTimestamp": "2026-08-18T10:00:00.000Z",
+ "openTimestamp": "2026-08-18T10:00:01.000Z",
+ "closeTimestamp": null,
+ "bytesSent": 100,
+ "bytesReceived": 200,
+ "framesSent": 10,
+ "framesReceived": 20,
+ "pingCount": 3,
+ "pongCount": 3,
+ "closeCode": null,
+ "closeReason": null,
+ "error": null,
+ "lastUpdated": "2026-08-18T10:00:02.000Z",
+ "events": [
+ {
+ "event": "WebSocket.Send",
+ "timestamp": 1787047201000000,
+ "frameNumber": 1,
+ "direction": "out",
+ "opcode": "text",
+ "payloadSize": 25
+ },
+ {
+ "event": "WebSocket.Receive",
+ "timestamp": 1787047202000000,
+ "frameNumber": 2,
+ "direction": "in",
+ "opcode": "text",
+ "payloadSize": 40
+ }
+ ]
+ }
]
}
\ No newline at end of file