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 086703fe12c..981a735b595 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 @@ class NetworkController extends DevToolsScreenController ..updateOrAddAll( requests: httpProfileData, sockets: socketStatsData, + webSockets: const [], timelineMicrosOffset: offlineData.timelineMicrosOffset ?? 0, ); _filterAndRefreshSearchMatches(); @@ -265,12 +266,14 @@ class NetworkController extends DevToolsScreenController void processNetworkTrafficHelper( List sockets, List? httpRequests, + List webSockets, int timelineMicrosOffset, { required CurrentNetworkRequests currentRequests, }) { currentRequests.updateOrAddAll( requests: httpRequests!, sockets: sockets, + webSockets: webSockets, timelineMicrosOffset: timelineMicrosOffset, ); @@ -287,11 +290,13 @@ class NetworkController extends DevToolsScreenController void processNetworkTraffic({ required List sockets, required List? httpRequests, + required List webSockets, }) { // Trigger refresh. processNetworkTrafficHelper( sockets, httpRequests, + webSockets, _timelineMicrosOffset, currentRequests: _currentNetworkRequests, ); @@ -339,6 +344,9 @@ class NetworkController extends DevToolsScreenController await networkService.updateLastHttpDataRefreshTime( alreadyRecordingHttp: alreadyRecordingHttp, ); + networkService.updateLastWebSocketDataRefreshTime( + alreadyRecordingWebSocket: alreadyRecordingHttp, + ); final timestamp = await networkService.updateLastSocketDataRefreshTime( alreadyRecordingSocketData: alreadyRecordingSocketData, ); @@ -391,10 +399,11 @@ class NetworkController extends DevToolsScreenController _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 updateLastRefreshTime() async { await networkService.updateLastHttpDataRefreshTime(); await networkService.updateLastSocketDataRefreshTime(); @@ -571,10 +580,12 @@ class CurrentNetworkRequests extends ValueNotifier> { void updateOrAddAll({ required List requests, required List sockets, + required List webSockets, required int timelineMicrosOffset, }) { _updateOrAddRequests(requests); _updateSocketProfiles(sockets, timelineMicrosOffset); + _updateWebSocketProfiles(webSockets); notifyListeners(); } @@ -631,6 +642,20 @@ class CurrentNetworkRequests extends ValueNotifier> { } } + void _updateWebSocketProfiles(List 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 3d7fa035607..b735b76de5c 100644 --- a/packages/devtools_app/lib/src/screens/network/network_model.dart +++ b/packages/devtools_app/lib/src/screens/network/network_model.dart @@ -239,3 +239,119 @@ extension SocketExtension on List { return map((socket) => socket._socket).toList(); } } + +class WebSocket extends NetworkRequest { + WebSocket(this._connection); + + WebSocketConnection _connection; + WebSocketConnection get connection => _connection; + List 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 toJson() { + return { + '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.toIso8601String(), + '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 a2f476487a8..5297d7b275e 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 @@ class NetworkRequestInspector extends StatelessWidget { 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 @@ class NetworkRequestInspector extends StatelessWidget { 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 118f76c29fd..c2cf07f8fc6 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 @@ -596,6 +596,67 @@ class HttpRequestCookiesView extends StatelessWidget { } } +class WebSocketFramesView extends StatelessWidget { + const WebSocketFramesView(this.data, {super.key}); + + final WebSocket data; + + @override + Widget build(BuildContext context) { + final events = data.events; + + if (events.isEmpty) { + return const Center(child: Text('No WebSocket events')); + } + + return ListView( + padding: const EdgeInsets.all(defaultSpacing), + children: [ + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: DataTable( + columns: const [ + DataColumn(label: Text('Frame')), + DataColumn(label: Text('Timestamp')), + DataColumn(label: Text('Direction')), + DataColumn(label: Text('Event')), + DataColumn(label: Text('Opcode')), + DataColumn(label: Text('Size')), + DataColumn(label: Text('Error')), + ], + rows: [ + for (final event in events) + DataRow( + cells: [ + DataCell(Text(event.frameNumber?.toString() ?? '--')), + DataCell(Text(formatDateTime(event.timestamp))), + DataCell(Text(event.direction ?? '--')), + DataCell( + // WebSocket timeline events are prefixed with `WebSocket.`. + // Strip the prefix for display in the Event column. + Text(event.event.substring('WebSocket.'.length)), + ), + DataCell(Text(event.opcode?.toString() ?? '--')), + DataCell( + Text( + event.payloadSize != null + ? formatBytes(event.payloadSize!) + : '--', + ), + ), + DataCell( + Text(event.errorMessage ?? event.errorType ?? '--'), + ), + ], + ), + ], + ), + ), + ], + ); + } +} + class NetworkRequestOverviewView extends StatelessWidget { const NetworkRequestOverviewView(this.data, {super.key}); @@ -605,6 +666,8 @@ class NetworkRequestOverviewView extends StatelessWidget { 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 +678,10 @@ class NetworkRequestOverviewView extends StatelessWidget { 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 +727,16 @@ class NetworkRequestOverviewView extends StatelessWidget { 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 +752,28 @@ class NetworkRequestOverviewView extends StatelessWidget { _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 +793,7 @@ class NetworkRequestOverviewView extends StatelessWidget { ]; } - Duration? get _totalDuration => (data as DartIOHttpRequestData).duration; + Duration? get _totalDuration => data.duration; Widget _buildTimingRow( Color color, @@ -839,6 +915,116 @@ class NetworkRequestOverviewView extends StatelessWidget { ]; } + List _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 _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 59fbb646c6f..2c7ae09cce7 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 @@ class NetworkService { /// use the VM's monotonic timeline clock — not wall-clock time. final lastHttpDataRefreshTimePerIsolate = {}; + /// Tracks the time (microseconds since epoch) that the WebSocket profile was + /// last retrieved for a given isolate ID. + final lastWebSocketDataRefreshTimePerIsolate = {}; + /// Updates the last Socket data refresh time to the current time. /// /// If [alreadyRecordingSocketData] is true, it's unclear when the last @@ -67,6 +71,22 @@ class NetworkService { } } + /// 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. + void updateLastWebSocketDataRefreshTime({ + bool alreadyRecordingWebSocket = false, + }) { + if (!alreadyRecordingWebSocket) { + final now = DateTime.now().microsecondsSinceEpoch; + for (final isolateId + in lastWebSocketDataRefreshTimePerIsolate.keys.toList()) { + lastWebSocketDataRefreshTimePerIsolate[isolateId] = now; + } + } + } + /// Force refreshes the HTTP requests logged to the timeline as well as any /// recorded Socket traffic. /// @@ -86,6 +106,10 @@ class NetworkService { if (cancelledCallback?.call() ?? false) return; networkController.lastSocketDataRefreshMicros = timestamp; + + final webSockets = await _refreshWebSocketProfile(); + if (cancelledCallback?.call() ?? false) return; + List? httpRequests; httpRequests = await _refreshHttpProfile(); if (cancelledCallback?.call() ?? false) return; @@ -93,6 +117,7 @@ class NetworkService { networkController.processNetworkTraffic( sockets: sockets, httpRequests: httpRequests, + webSockets: webSockets, ); } on RPCError catch (e) { if (!e.isServiceDisposedError) { @@ -145,6 +170,67 @@ class NetworkService { }); } + Future> _refreshWebSocketProfile() async { + final service = serviceConnection.serviceManager.service; + if (service == null) return const []; + + final connections = []; + + 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 _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> _refreshSockets() async { final service = serviceConnection.serviceManager.service; if (service == null) return []; @@ -214,6 +300,7 @@ class NetworkService { await updateLastHttpDataRefreshTime(); await _clearSocketProfile(); await _clearHttpProfile(); + await _clearWebSocketProfile(); } on RPCError catch (e) { if (!e.isServiceDisposedError) { rethrow; diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md index 998ed947ab1..f202cc9ed83 100644 --- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md +++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md @@ -53,13 +53,21 @@ TODO: Remove this section if there are not any updates. ## Network profiler updates +## 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 exported response status in HAR files so that they parse as integers + instead of strings. [#9900](https://github.com/flutter/devtools/pull/9900) * Fixed an issue where the Network tab would stop capturing HTTP requests after a hot restart. - [#9856](https://github.com/flutter/devtools/pull/9856) * Fixed an issue where the Network tab would stop capturing new HTTP requests after pressing Clear while recording. - [#9856](https://github.com/flutter/devtools/pull/9856) - + ## Logging updates * Correct time units and cumulative nature of GC events. 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 09553ebbb30..2da78f04eaa 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 @@ void main() { currentNetworkRequests.updateOrAddAll( requests: reqs, sockets: sockets, + webSockets: const [], timelineMicrosOffset: 0, ); expect(notifyCount, 1); @@ -439,6 +440,7 @@ void main() { currentNetworkRequests.updateOrAddAll( requests: [request1Done], sockets: [socketStats1Done], + webSockets: const [], timelineMicrosOffset: 0, ); expect(notifyCount, 2); @@ -462,6 +464,7 @@ void main() { currentNetworkRequests.updateOrAddAll( requests: [request1Done], sockets: const [], + webSockets: const [], timelineMicrosOffset: 0, ); @@ -473,6 +476,7 @@ void main() { currentNetworkRequests.updateOrAddAll( requests: [request1CancelledWithStatusCode], sockets: const [], + webSockets: const [], timelineMicrosOffset: 0, ); @@ -488,6 +492,7 @@ void main() { currentNetworkRequests.updateOrAddAll( requests: reqs, sockets: sockets, + webSockets: const [], timelineMicrosOffset: 0, ); @@ -513,6 +518,7 @@ void main() { currentNetworkRequests.updateOrAddAll( requests: reqs, sockets: sockets, + webSockets: const [], timelineMicrosOffset: 0, ); @@ -539,5 +545,207 @@ void main() { ); }); }); + 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? 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()); + + 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()); + + 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 5ecdbfb6503..42d9396821a 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 @@ void main() { }); }); + 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? 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; + + 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 053e745aae5..d9556509895 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 @@ void main() { }); group('NetworkRequestOverviewView', () { + setUp(() { + setGlobal( + ServiceConnectionManager, + FakeServiceConnectionManager( + service: FakeServiceManager.createFakeService(), + ), + ); + }); Future pumpView(WidgetTester tester, NetworkRequest data) async { final widget = wrap(NetworkRequestOverviewView(data)); await tester.pumpWidget(widget); @@ -558,7 +566,7 @@ void main() { 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 @@ void main() { ); }, ); + 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 b2dbeca9165..726833b2019 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,94 @@ void main() { await tester.pumpAndSettle(const Duration(seconds: 1)); }, ); + testWidgets('WebSocket inspector shows Frames', (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(); + 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), findsOneWidget); + + // Verify the table headers. + expect(find.text('Frame'), findsOneWidget); + expect(find.text('Timestamp'), findsOneWidget); + expect(find.text('Direction'), findsOneWidget); + expect(find.text('Event'), findsOneWidget); + expect(find.text('Opcode'), findsOneWidget); + expect(find.text('Size'), findsOneWidget); + expect(find.text('Error'), findsOneWidget); + + // Verify the first frame. + expect(find.text('1'), findsOneWidget); + expect(find.text('out'), findsOneWidget); + expect(find.text('Send'), findsOneWidget); + expect(find.text('25 B'), findsOneWidget); + + // Verify the second frame. + expect(find.text('2'), findsOneWidget); + expect(find.text('in'), findsOneWidget); + expect(find.text('Receive'), findsOneWidget); + expect(find.text('40 B'), findsOneWidget); + + // Both frames use the text opcode. + expect(find.text('text'), findsNWidgets(2)); + + // 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 ebc778db896..8d1e51aa53d 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 @@ void main() { controller.processNetworkTrafficHelper( socketProfile.sockets, httpProfile.requests, + const [], 0, currentRequests: currentRequests, ); @@ -105,5 +106,55 @@ void main() { // 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', + ); + }); }); }