diff --git a/packages/devtools_app/lib/src/screens/network/har_data_entry.dart b/packages/devtools_app/lib/src/screens/network/har_data_entry.dart index 4be56ecfb19..0318c7f7783 100644 --- a/packages/devtools_app/lib/src/screens/network/har_data_entry.dart +++ b/packages/devtools_app/lib/src/screens/network/har_data_entry.dart @@ -4,6 +4,7 @@ import 'dart:convert'; +import '../../screens/network/utils/http_utils.dart'; import '../../shared/http/http_request_data.dart'; import '../../shared/primitives/utils.dart'; import 'constants.dart'; @@ -137,7 +138,7 @@ class HarDataEntry { NetworkEventKeys.mimeType.name: e.contentType, NetworkEventKeys.text.name: e.requestBody, }, - NetworkEventKeys.headersSize.name: _calculateHeadersSize( + NetworkEventKeys.headersSize.name: calculateHeadersSize( e.requestHeaders, ), NetworkEventKeys.bodySize.name: _calculateBodySize(e.requestBody), @@ -157,7 +158,7 @@ class HarDataEntry { NetworkEventKeys.text.name: e.responseBody, }, NetworkEventKeys.redirectURL.name: '', - NetworkEventKeys.headersSize.name: _calculateHeadersSize( + NetworkEventKeys.headersSize.name: calculateHeadersSize( e.responseHeaders, ), NetworkEventKeys.bodySize.name: _calculateBodySize(e.responseBody), @@ -272,28 +273,6 @@ class HarDataEntry { } } -int _calculateHeadersSize(Map? headers) { - if (headers == null) return -1; - - // Combine headers into a single string with CRLF endings - String headersString = - headers.entries.map((entry) { - final key = entry.key; - var value = entry.value; - // If the value is a List, join it with a comma - if (value is List) { - value = value.join(', '); - } - return '$key: $value\r\n'; - }).join(); - - // Add final CRLF to indicate end of headers - headersString += '\r\n'; - - // Calculate the byte length of the headers string - return utf8.encode(headersString).length; -} - int _calculateBodySize(String? requestBody) { if (requestBody.isNullOrEmpty) { return 0; 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 bc61b25214a..ab65b87521b 100644 --- a/packages/devtools_app/lib/src/screens/network/network_controller.dart +++ b/packages/devtools_app/lib/src/screens/network/network_controller.dart @@ -14,6 +14,7 @@ import '../../shared/config_specific/logger/allowed_error.dart'; import '../../shared/globals.dart'; import '../../shared/http/http_request_data.dart'; import '../../shared/http/http_service.dart' as http_service; +import '../../shared/offline/offline_data.dart'; import '../../shared/primitives/utils.dart'; import '../../shared/ui/filter.dart'; import '../../shared/ui/search.dart'; @@ -22,6 +23,7 @@ import 'har_network_data.dart'; import 'network_model.dart'; import 'network_screen.dart'; import 'network_service.dart'; +import 'offline_network_data.dart'; /// Different types of Network Response which can be used to visualise response /// on Response tab @@ -46,10 +48,12 @@ class NetworkController extends DisposableController with SearchControllerMixin, FilterControllerMixin, + OfflineScreenControllerMixin, AutoDisposeControllerMixin { NetworkController() { _networkService = NetworkService(this); _currentNetworkRequests = CurrentNetworkRequests(); + _initHelper(); addAutoDisposeListener( _currentNetworkRequests, _filterAndRefreshSearchMatches, @@ -166,6 +170,48 @@ class NetworkController extends DisposableController @visibleForTesting bool get isPolling => _pollingTimer != null; + void _initHelper() async { + if (offlineDataController.showingOfflineData.value) { + await maybeLoadOfflineData( + NetworkScreen.id, + createData: (json) => OfflineNetworkData.fromJson(json), + // This ignore is used because the 'data' parameter can have a dynamic type, + // which cannot be explicitly typed here due to its dependency on JSON parsing. + // ignore: avoid_dynamic_calls + shouldLoad: (data) => !data.isEmpty, + loadData: (data) => loadOfflineData(data), + ); + } else { + await startRecording(); + } + } + + void loadOfflineData(OfflineNetworkData offlineData) { + final httpProfileData = + offlineData.httpRequestData.mapToHttpProfileRequests; + final socketStatsData = offlineData.socketData.mapToSocketStatistics; + + _currentNetworkRequests + ..clear() + ..updateOrAddAll( + requests: httpProfileData, + sockets: socketStatsData, + timelineMicrosOffset: offlineData.timelineMicrosOffset ?? 0, + ); + _filterAndRefreshSearchMatches(); + + // If a selectedRequestId is available, select it in offline mode. + if (offlineData.selectedRequestId != null) { + final selected = _currentNetworkRequests.getRequest( + offlineData.selectedRequestId ?? '', + ); + if (selected != null) { + selectedRequest.value = selected; + resetDropDown(); + } + } + } + @visibleForTesting void processNetworkTrafficHelper( List sockets, @@ -400,6 +446,31 @@ class NetworkController extends DisposableController } } + @override + OfflineScreenData prepareOfflineScreenData() { + final httpRequestData = []; + final socketData = []; + for (final request in _currentNetworkRequests.value) { + if (request is DartIOHttpRequestData) { + httpRequestData.add(request); + } else if (request is Socket) { + socketData.add(request); + } + } + + final offlineData = OfflineNetworkData( + httpRequestData: httpRequestData, + socketData: socketData, + selectedRequestId: selectedRequest.value?.id, + timelineMicrosOffset: _timelineMicrosOffset, + ); + + return OfflineScreenData( + screenId: NetworkScreen.id, + data: offlineData.toJson(), + ); + } + Future _fetchFullDataBeforeExport() => Future.wait( filteredData.value.whereType().map( (item) => item.getFullRequestData(), 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 44a12b1d538..303719b761e 100644 --- a/packages/devtools_app/lib/src/screens/network/network_model.dart +++ b/packages/devtools_app/lib/src/screens/network/network_model.dart @@ -2,13 +2,15 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:devtools_shared/devtools_shared.dart'; import 'package:flutter/material.dart'; import 'package:vm_service/vm_service.dart'; import '../../shared/primitives/utils.dart'; import '../../shared/ui/search.dart'; -abstract class NetworkRequest with ChangeNotifier, SearchableDataMixin { +abstract class NetworkRequest + with ChangeNotifier, SearchableDataMixin, Serializable { String get method; String get uri; @@ -77,6 +79,15 @@ abstract class NetworkRequest with ChangeNotifier, SearchableDataMixin { class Socket extends NetworkRequest { Socket(this._socket, this._timelineMicrosBase); + factory Socket.fromJson(Map json) { + return Socket( + SocketStatistic.parse( + json[SocketJsonKey.socket.name] as Map, + )!, + json[SocketJsonKey.timelineMicrosBase.name] as int, + ); + } + int _timelineMicrosBase; SocketStatistic _socket; @@ -172,4 +183,53 @@ class Socket extends NetworkRequest { @override int get hashCode => id.hashCode; + + SocketStatistic get socketData => _socket; + + @override + Map toJson() { + return { + SocketJsonKey.timelineMicrosBase.name: _timelineMicrosBase, + SocketJsonKey.socket.name: _socket.toJson(), + }; + } +} + +extension on SocketStatistic { + Map toJson() { + return { + SocketJsonKey.id.name: id, + SocketJsonKey.startTime.name: startTime, + SocketJsonKey.endTime.name: endTime, + //TODO verify if these timings are in correct format + SocketJsonKey.lastReadTime.name: lastReadTime, + SocketJsonKey.lastWriteTime.name: lastWriteTime, + SocketJsonKey.socketType.name: socketType, + SocketJsonKey.address.name: address, + SocketJsonKey.port.name: port, + SocketJsonKey.readBytes.name: readBytes, + SocketJsonKey.writeBytes.name: writeBytes, + }; + } +} + +enum SocketJsonKey { + id, + startTime, + endTime, + lastReadTime, + lastWriteTime, + socketType, + address, + port, + readBytes, + writeBytes, + timelineMicrosBase, + socket, +} + +extension SocketExtension on List { + List get mapToSocketStatistics { + return map((socket) => socket._socket).toList(); + } } diff --git a/packages/devtools_app/lib/src/screens/network/network_screen.dart b/packages/devtools_app/lib/src/screens/network/network_screen.dart index ce7e247af21..2387e8dd854 100644 --- a/packages/devtools_app/lib/src/screens/network/network_screen.dart +++ b/packages/devtools_app/lib/src/screens/network/network_screen.dart @@ -14,7 +14,6 @@ import '../../shared/analytics/analytics.dart' as ga; import '../../shared/analytics/constants.dart' as gac; import '../../shared/config_specific/copy_to_clipboard/copy_to_clipboard.dart'; import '../../shared/framework/screen.dart'; -import '../../shared/globals.dart'; import '../../shared/http/curl_command.dart'; import '../../shared/http/http_request_data.dart'; import '../../shared/primitives/utils.dart'; @@ -104,19 +103,6 @@ class _NetworkScreenBodyState extends State void didChangeDependencies() { super.didChangeDependencies(); if (!initController()) return; - unawaited(controller.startRecording()); - - cancelListeners(); - - addAutoDisposeListener( - serviceConnection.serviceManager.isolateManager.mainIsolate, - () { - if (serviceConnection.serviceManager.isolateManager.mainIsolate.value != - null) { - unawaited(controller.startRecording()); - } - }, - ); } @override @@ -131,7 +117,14 @@ class _NetworkScreenBodyState extends State Widget build(BuildContext context) { return Column( children: [ - _NetworkProfilerControls(controller: controller), + OfflineAwareControls( + controlsBuilder: + (offline) => _NetworkProfilerControls( + controller: controller, + offline: offline, + ), + gaScreen: gac.network, + ), const SizedBox(height: intermediateSpacing), Expanded(child: _NetworkProfilerBody(controller: controller)), ], @@ -142,12 +135,17 @@ class _NetworkScreenBodyState extends State /// The row of controls that control the Network profiler (e.g., record, pause, /// clear, search, filter, etc.). class _NetworkProfilerControls extends StatefulWidget { - const _NetworkProfilerControls({required this.controller}); + const _NetworkProfilerControls({ + required this.controller, + required this.offline, + }); static const _includeTextWidth = 810.0; final NetworkController controller; + final bool offline; + @override State<_NetworkProfilerControls> createState() => _NetworkProfilerControlsState(); @@ -160,7 +158,6 @@ class _NetworkProfilerControlsState extends State<_NetworkProfilerControls> @override void initState() { super.initState(); - _recording = widget.controller.recordingNotifier.value; addAutoDisposeListener(widget.controller.recordingNotifier, () { setState(() { @@ -177,54 +174,56 @@ class _NetworkProfilerControlsState extends State<_NetworkProfilerControls> final hasRequests = widget.controller.filteredData.value.isNotEmpty; return Row( children: [ - StartStopRecordingButton( - recording: _recording, - onPressed: - () async => await widget.controller.togglePolling(!_recording), - tooltipOverride: - _recording - ? 'Stop recording network traffic' - : 'Resume recording network traffic', - minScreenWidthForTextBeforeScaling: double.infinity, - gaScreen: gac.network, - gaSelection: _recording ? gac.pause : gac.resume, - ), - const SizedBox(width: denseSpacing), - ClearButton( - minScreenWidthForTextBeforeScaling: - _NetworkProfilerControls._includeTextWidth, - gaScreen: gac.network, - gaSelection: gac.clear, - onPressed: widget.controller.clear, - ), - const SizedBox(width: defaultSpacing), - DownloadButton( - tooltip: 'Download as .har file', - minScreenWidthForTextBeforeScaling: - _NetworkProfilerControls._includeTextWidth, - onPressed: widget.controller.exportAsHarFile, - gaScreen: gac.network, - gaSelection: gac.NetworkEvent.downloadAsHar.name, - ), - const SizedBox(width: defaultSpacing), - // TODO(kenz): fix focus issue when state is refreshed - Expanded( - child: SearchField( - searchController: widget.controller, - searchFieldEnabled: hasRequests, - searchFieldWidth: - screenWidth <= MediaSize.xs - ? defaultSearchFieldWidth - : wideSearchFieldWidth, + if (!widget.offline) ...[ + StartStopRecordingButton( + recording: _recording, + onPressed: + () async => await widget.controller.togglePolling(!_recording), + tooltipOverride: + _recording + ? 'Stop recording network traffic' + : 'Resume recording network traffic', + minScreenWidthForTextBeforeScaling: double.infinity, + gaScreen: gac.network, + gaSelection: _recording ? gac.pause : gac.resume, ), - ), - const SizedBox(width: denseSpacing), - Expanded( - child: StandaloneFilterField( - controller: widget.controller, - filteredItem: 'request', + const SizedBox(width: denseSpacing), + ClearButton( + minScreenWidthForTextBeforeScaling: + _NetworkProfilerControls._includeTextWidth, + gaScreen: gac.network, + gaSelection: gac.clear, + onPressed: widget.controller.clear, ), - ), + const SizedBox(width: defaultSpacing), + DownloadButton( + tooltip: 'Download as .har file', + minScreenWidthForTextBeforeScaling: + _NetworkProfilerControls._includeTextWidth, + onPressed: widget.controller.exportAsHarFile, + gaScreen: gac.network, + gaSelection: gac.NetworkEvent.downloadAsHar.name, + ), + const Spacer(), + // TODO(kenz): fix focus issue when state is refreshed + Expanded( + child: SearchField( + searchController: widget.controller, + searchFieldEnabled: hasRequests, + searchFieldWidth: + screenWidth <= MediaSize.xs + ? defaultSearchFieldWidth + : wideSearchFieldWidth, + ), + ), + const SizedBox(width: denseSpacing), + Expanded( + child: StandaloneFilterField( + controller: widget.controller, + filteredItem: 'request', + ), + ), + ], ], ); } 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 new file mode 100644 index 00000000000..bde196cb1f0 --- /dev/null +++ b/packages/devtools_app/lib/src/screens/network/offline_network_data.dart @@ -0,0 +1,102 @@ +// Copyright 2024 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:devtools_shared/devtools_shared.dart'; + +import '../../shared/http/http_request_data.dart'; +import '../network/network_controller.dart'; +import 'network_model.dart'; + +/// Class to encapsulate offline data for the [NetworkController]. +/// +/// It is responsible for serializing and deserializing offline network data. +class OfflineNetworkData with Serializable { + OfflineNetworkData({ + required this.httpRequestData, + required this.socketData, + this.selectedRequestId, + required this.timelineMicrosOffset, + }); + + /// Creates an instance of [OfflineNetworkData] from a JSON map. + factory OfflineNetworkData.fromJson(Map json) { + final httpRequestJsonList = + json[_OfflineDataKeys.httpRequestData.name] as List?; + // Deserialize httpRequestData + final httpRequestData = + httpRequestJsonList + ?.map((e) { + if (e is Map) { + final requestData = + e[_OfflineDataKeys.request.name] as Map?; + return requestData != null + ? DartIOHttpRequestData.fromJson(requestData, null, null) + : null; + } + return null; + }) + .whereType() + .toList() ?? + []; + + // Deserialize socketData + final socketJsonList = + json[_OfflineDataKeys.socketData.name] as List?; + final socketData = + socketJsonList + ?.map((e) { + if (e is Map) { + return Socket.fromJson(e); + } + return null; + }) + .whereType() + .toList() ?? + []; + final timelineMicrosOffset = json['timelineMicrosOffset']; + + return OfflineNetworkData( + httpRequestData: httpRequestData, + selectedRequestId: + json[_OfflineDataKeys.selectedRequestId.name] as String?, + socketData: socketData, + timelineMicrosOffset: timelineMicrosOffset as int? ?? 0, + ); + } + + bool get isEmpty => httpRequestData.isEmpty && socketData.isEmpty; + + /// List of current [DartIOHttpRequestData] network requests. + final List httpRequestData; + + /// The ID of the currently selected request, if any. + final String? selectedRequestId; + + /// used to calculate the correct wall-time for timeline events. + final int? timelineMicrosOffset; + + /// The list of socket statistics for the offline network data. + final List socketData; + + /// Converts the current offline data to a JSON format. + @override + Map toJson() { + return { + _OfflineDataKeys.httpRequestData.name: + httpRequestData.map((e) => e.toJson()).toList(), + _OfflineDataKeys.selectedRequestId.name: selectedRequestId, + _OfflineDataKeys.socketData.name: + socketData.map((e) => e.toJson()).toList(), + _OfflineDataKeys.timelineMicrosOffset.name: timelineMicrosOffset, + }; + } +} + +enum _OfflineDataKeys { + httpRequestData, + selectedRequestId, + socketData, + request, + timelineMicrosOffset, +} diff --git a/packages/devtools_app/lib/src/screens/network/utils/http_utils.dart b/packages/devtools_app/lib/src/screens/network/utils/http_utils.dart new file mode 100644 index 00000000000..e6e564941d2 --- /dev/null +++ b/packages/devtools_app/lib/src/screens/network/utils/http_utils.dart @@ -0,0 +1,32 @@ +// Copyright 2024 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; + +/// Calculates the size of the headers in bytes. +/// +/// Takes a map of headers [headers], where keys are header names and values +/// can be strings or lists of strings. Returns the size of the headers +/// in bytes or -1 if [headers] is null. +int calculateHeadersSize(Map? headers) { + if (headers == null) return -1; + + // Combine headers into a single string with CRLF endings + String headersString = + headers.entries.map((entry) { + final key = entry.key; + var value = entry.value; + // If the value is a List, join it with a comma + if (value is List) { + value = value.join(', '); + } + return '$key: $value\r\n'; + }).join(); + + // Add final CRLF to indicate end of headers + headersString += '\r\n'; + + // Calculate the byte length of the headers string + return utf8.encode(headersString).length; +} diff --git a/packages/devtools_app/lib/src/shared/feature_flags.dart b/packages/devtools_app/lib/src/shared/feature_flags.dart index 14151c99181..83ba0929cd9 100644 --- a/packages/devtools_app/lib/src/shared/feature_flags.dart +++ b/packages/devtools_app/lib/src/shared/feature_flags.dart @@ -41,6 +41,11 @@ const _kMemoryDisconnectExperience = bool.fromEnvironment( defaultValue: true, ); +const _kNetworkOfflineExperiment = bool.fromEnvironment( + 'network_disconnect_experience', + defaultValue: true, +); + // It is ok to have enum-like static only classes. // ignore: avoid_classes_with_only_static_members /// Flags to hide features under construction. @@ -65,6 +70,11 @@ abstract class FeatureFlags { /// https://github.com/flutter/devtools/issues/5606 static const memoryDisconnectExperience = _kMemoryDisconnectExperience; + /// Flag to enable offline data on network screen. + /// + /// https://github.com/flutter/devtools/issues/3806 + static const networkOffline = _kNetworkOfflineExperiment; + /// Flag to enable save/load for the Memory screen. /// /// https://github.com/flutter/devtools/issues/8019 diff --git a/packages/devtools_app/lib/src/shared/framework/screen.dart b/packages/devtools_app/lib/src/shared/framework/screen.dart index 8d5c93ec6fb..9ade450ff70 100644 --- a/packages/devtools_app/lib/src/shared/framework/screen.dart +++ b/packages/devtools_app/lib/src/shared/framework/screen.dart @@ -76,6 +76,10 @@ enum ScreenMetaData { iconAsset: 'icons/app_bar/network.png', requiresDartVm: true, tutorialVideoTimestamp: '?t=547', + // ignore: avoid_redundant_argument_values, false positive + requiresConnection: FeatureFlags.networkOffline, + // ignore: avoid_redundant_argument_values, false positive + worksWithOfflineData: FeatureFlags.networkOffline, ), logging( 'logging', diff --git a/packages/devtools_app/lib/src/shared/http/constants.dart b/packages/devtools_app/lib/src/shared/http/constants.dart new file mode 100644 index 00000000000..6f5852b46ac --- /dev/null +++ b/packages/devtools_app/lib/src/shared/http/constants.dart @@ -0,0 +1,79 @@ +// Copyright 2024 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +enum HttpRequestDataKeys { + connectionInfo, + remoteAddress, + localPort, + contentLength, + startedDateTime, + time, + request, + method, + url, + httpVersion, + cookies, + headers, + queryString, + postData, + mimeType, + text, + headersSize, + bodySize, + followRedirects, + maxRedirects, + persistentConnection, + proxyDetails, + proxy, + type, + error, + response, + status, + statusCode, + statusText, + redirects, + redirectURL, + cache, + timings, + blocked, + dns, + connect, + send, + wait, + receive, + ssl, + connection, + comment, + isolateId, + uri, + id, + startTime, + events, + timestamp, + event, + compressionState, + isRedirect, + reasonPhrase, + queryParameters, + content, + size, + connectionId, + requestBody, + responseBody, + endTime, + arguments, + host, + username, + isDirect, +} + +enum HttpRequestDataValues { json } + +class HttpRequestDataDefaults { + static const none = 'None'; + static const error = 'Error'; + static const httpVersion = 'HTTP/2.0'; + static const json = 'json'; + static const httpProfileRequest = '@HttpProfileRequest'; +} diff --git a/packages/devtools_app/lib/src/shared/http/http_request_data.dart b/packages/devtools_app/lib/src/shared/http/http_request_data.dart index 18bb4386313..dd02e3f702a 100644 --- a/packages/devtools_app/lib/src/shared/http/http_request_data.dart +++ b/packages/devtools_app/lib/src/shared/http/http_request_data.dart @@ -13,6 +13,7 @@ import 'package:vm_service/vm_service.dart'; import '../../screens/network/network_model.dart'; import '../globals.dart'; import '../primitives/utils.dart'; +import 'constants.dart'; import 'http.dart'; final _log = Logger('http_request_data'); @@ -51,12 +52,34 @@ class DartIOHttpRequestData extends NetworkRequest { Map? requestPostData, Map? responseContent, ) { + final isFullRequest = + modifiedRequestData.containsKey(HttpRequestDataKeys.requestBody.name) && + modifiedRequestData.containsKey(HttpRequestDataKeys.responseBody.name); + + final parsedRequest = + isFullRequest + ? HttpProfileRequest.parse(modifiedRequestData) + : HttpProfileRequestRef.parse(modifiedRequestData); + + final responseBody = + responseContent?[HttpRequestDataKeys.text.name]?.toString(); + final requestBody = + requestPostData?[HttpRequestDataKeys.text.name]?.toString(); + return DartIOHttpRequestData( - HttpProfileRequestRef.parse(modifiedRequestData)!, - requestFullDataFromVmService: false, + parsedRequest!, + requestFullDataFromVmService: parsedRequest is! HttpProfileRequest, ) - .._responseBody = responseContent?['text'].toString() - .._requestBody = requestPostData?['text'].toString(); + .._responseBody = responseBody + .._requestBody = requestBody; + } + + @override + Map toJson() { + return { + HttpRequestDataKeys.request.name: + (_request as HttpProfileRequest).toJson(), + }; } static const _connectionInfoKey = 'connectionInfo'; @@ -331,3 +354,85 @@ class DartIOHttpRequestData extends NetworkRequest { int get hashCode => Object.hash(id, method, uri, contentType, type, port, startTimestamp); } + +extension HttpRequestExtension on List { + List get mapToHttpProfileRequests { + return map( + (httpRequestData) => httpRequestData._request as HttpProfileRequest, + ).toList(); + } +} + +extension HttpProfileRequestExtension on HttpProfileRequest { + Map toJson() { + return { + HttpRequestDataKeys.id.name: id, + HttpRequestDataKeys.method.name: method, + HttpRequestDataKeys.uri.name: uri.toString(), + HttpRequestDataKeys.startTime.name: startTime.microsecondsSinceEpoch, + HttpRequestDataKeys.endTime.name: endTime?.microsecondsSinceEpoch, + HttpRequestDataKeys.response.name: response?.toJson(), + HttpRequestDataKeys.request.name: request?.toJson(), + HttpRequestDataKeys.isolateId.name: isolateId, + HttpRequestDataKeys.events.name: events.map((e) => e.toJson()).toList(), + HttpRequestDataKeys.requestBody.name: requestBody?.toList(), + HttpRequestDataKeys.responseBody.name: responseBody?.toList(), + }; + } +} + +extension HttpProfileRequestDataExtension on HttpProfileRequestData { + Map toJson() { + return { + HttpRequestDataKeys.headers.name: headers, + HttpRequestDataKeys.followRedirects.name: followRedirects, + HttpRequestDataKeys.maxRedirects.name: maxRedirects, + HttpRequestDataKeys.connectionInfo.name: connectionInfo, + HttpRequestDataKeys.contentLength.name: contentLength, + HttpRequestDataKeys.cookies.name: cookies, + HttpRequestDataKeys.persistentConnection.name: persistentConnection, + HttpRequestDataKeys.proxyDetails.name: proxyDetails, + }; + } +} + +extension HttpProfileResponseDataExtension on HttpProfileResponseData { + Map toJson() { + return { + HttpRequestDataKeys.startTime.name: startTime?.microsecondsSinceEpoch, + HttpRequestDataKeys.endTime.name: endTime?.microsecondsSinceEpoch, + HttpRequestDataKeys.headers.name: headers, + HttpRequestDataKeys.compressionState.name: compressionState, + HttpRequestDataKeys.connectionInfo.name: connectionInfo, + HttpRequestDataKeys.contentLength.name: contentLength, + HttpRequestDataKeys.cookies.name: cookies, + HttpRequestDataKeys.isRedirect.name: isRedirect, + HttpRequestDataKeys.persistentConnection.name: persistentConnection, + HttpRequestDataKeys.reasonPhrase.name: reasonPhrase, + HttpRequestDataKeys.redirects.name: redirects, + HttpRequestDataKeys.statusCode.name: statusCode, + HttpRequestDataKeys.error.name: error, + }; + } +} + +extension HttpProfileRequestEventExtension on HttpProfileRequestEvent { + Map toJson() { + return { + HttpRequestDataKeys.event.name: event, + HttpRequestDataKeys.timestamp.name: timestamp.microsecondsSinceEpoch, + HttpRequestDataKeys.arguments.name: arguments, + }; + } +} + +extension HttpProfileProxyDataExtension on HttpProfileProxyData { + Map toJson() { + return { + HttpRequestDataKeys.host.name: host, + HttpRequestDataKeys.username.name: username, + HttpRequestDataKeys.isDirect.name: isDirect, + HttpRequestDataKeys.host.name: port, + }; + } +} diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md index efcb692a04b..67211acdcbe 100644 --- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md +++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md @@ -36,8 +36,8 @@ TODO: Remove this section if there are not any general updates. ## Network profiler updates -TODO: Remove this section if there are not any general updates. - +* Offline support added for the network screen. - [#8332](https://github.com/flutter/devtools/pull/8332) + ## Logging updates TODO: Remove this section if there are not any general updates. 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 519b2472e6d..25d0a682c54 100644 --- a/packages/devtools_app/test/screens/network/network_controller_test.dart +++ b/packages/devtools_app/test/screens/network/network_controller_test.dart @@ -22,6 +22,7 @@ void main() { late HttpProfile httpProfile; setUp(() { + setGlobal(OfflineDataController, OfflineDataController()); socketProfile = loadSocketProfile(); httpProfile = loadHttpProfile(); fakeServiceConnection = FakeServiceConnectionManager( 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 d7a0d8aed8b..b552b5bf3d6 100644 --- a/packages/devtools_app/test/screens/network/network_model_test.dart +++ b/packages/devtools_app/test/screens/network/network_model_test.dart @@ -86,6 +86,7 @@ void main() { ); setGlobal(ServiceConnectionManager, fakeServiceConnection); setGlobal(PreferencesController, PreferencesController()); + setGlobal(OfflineDataController, OfflineDataController()); controller = NetworkController(); await controller.startRecording(); }); 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 7ef99153e1c..b31d24fabc8 100644 --- a/packages/devtools_app/test/screens/network/network_profiler_test.dart +++ b/packages/devtools_app/test/screens/network/network_profiler_test.dart @@ -50,6 +50,7 @@ void main() { const windowSize = Size(1599.0, 1000.0); setUpAll(() { + setGlobal(OfflineDataController, OfflineDataController()); socketProfile = loadSocketProfile(); httpProfile = loadHttpProfile(); setGlobal(IdeTheme, IdeTheme()); 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 ebd50aa6d56..70045e4e9c4 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 @@ -43,6 +43,7 @@ void main() { ); setGlobal(ServiceConnectionManager, fakeServiceConnection); setGlobal(NotificationService, NotificationService()); + setGlobal(OfflineDataController, OfflineDataController()); controller = NetworkController(); setupClipboardCopyListener( clipboardContentsCallback: (contents) { diff --git a/packages/devtools_app/test/screens/network/network_screen_test.dart b/packages/devtools_app/test/screens/network/network_screen_test.dart index 3b77263f891..65de45e9f78 100644 --- a/packages/devtools_app/test/screens/network/network_screen_test.dart +++ b/packages/devtools_app/test/screens/network/network_screen_test.dart @@ -17,6 +17,7 @@ void main() { group('NetworkScreen', () { setUp(() { + setGlobal(OfflineDataController, OfflineDataController()); fakeServiceConnection = FakeServiceConnectionManager(); when( fakeServiceConnection.serviceManager.connectedApp!.isDartWebAppNow, 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 d12d1b611f9..f473b9abf1c 100644 --- a/packages/devtools_app/test/screens/network/network_table_test.dart +++ b/packages/devtools_app/test/screens/network/network_table_test.dart @@ -26,6 +26,7 @@ void main() { late List requests; setUpAll(() { + setGlobal(OfflineDataController, OfflineDataController()); httpProfile = loadHttpProfile(); socketProfile = loadSocketProfile(); fakeServiceConnection = FakeServiceConnectionManager( diff --git a/packages/devtools_app/test/screens/network/offline_data_test.dart b/packages/devtools_app/test/screens/network/offline_data_test.dart new file mode 100644 index 00000000000..26ebef59f42 --- /dev/null +++ b/packages/devtools_app/test/screens/network/offline_data_test.dart @@ -0,0 +1,178 @@ +// Copyright 2024 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; +import 'dart:io'; + +import 'package:devtools_app/src/screens/network/network_model.dart'; +import 'package:devtools_app/src/screens/network/offline_network_data.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late Map jsonData; + late OfflineNetworkData offlineData; + late Socket firstSocket; + late Socket secondSocket; + + setUpAll(() { + final file = File('test/screens/network/sample_network_offline_data.json'); + final fileContent = file.readAsStringSync(); + jsonData = jsonDecode(fileContent) as Map; + + // Create OfflineNetworkData + offlineData = OfflineNetworkData.fromJson(jsonData); + + // Extracting sockets for reuse in tests + firstSocket = offlineData.socketData.first; + secondSocket = offlineData.socketData.last; + }); + + group('Socket Tests', () { + test('Socket should deserialize from JSON correctly', () { + // Validate first socket + expect(firstSocket.id, '105553123901536'); + expect(firstSocket.socketType, 'tcp'); + expect(firstSocket.port, 443); + expect(firstSocket.readBytes, 4367); + expect(firstSocket.writeBytes, 18237); + + // Validate timestamps + const timelineMicrosBase = 1731482170837171; + expect( + firstSocket.startTimestamp, + DateTime.fromMicrosecondsSinceEpoch(timelineMicrosBase + 171830570040), + ); + expect( + firstSocket.endTimestamp, + DateTime.fromMicrosecondsSinceEpoch(timelineMicrosBase + 171830929647), + ); + expect( + firstSocket.lastReadTimestamp, + DateTime.fromMicrosecondsSinceEpoch(timelineMicrosBase + 171830928421), + ); + expect( + firstSocket.lastWriteTimestamp, + DateTime.fromMicrosecondsSinceEpoch(timelineMicrosBase + 171830669180), + ); + }); + + test('Socket should serialize to JSON correctly', () { + final serializedJson = firstSocket.toJson(); + + // Validate serialized JSON + expect(serializedJson['timelineMicrosBase'], 1731482170837171); + expect((serializedJson['socket'] as Map)['id'], '105553123901536'); + expect((serializedJson['socket'] as Map)['startTime'], 171830570040); + expect((serializedJson['socket'] as Map)['endTime'], 171830929647); + expect((serializedJson['socket'] as Map)['readBytes'], 4367); + expect((serializedJson['socket'] as Map)['writeBytes'], 18237); + }); + + test('Socket duration should be calculated correctly', () { + final expectedDuration = Duration( + microseconds: + firstSocket.endTimestamp!.microsecondsSinceEpoch - + firstSocket.startTimestamp.microsecondsSinceEpoch, + ); + + expect(firstSocket.duration, expectedDuration); + }); + + test( + 'Socket status should indicate "Open" or "Closed" based on endTime', + () { + expect( + firstSocket.status, + 'Closed', + ); // The provided socket has an endTime + + // Modify socket to simulate "Open" status + final openSocketJson = { + ...firstSocket.toJson(), + 'socket': { + ...(firstSocket.toJson()['socket'] as Map), + 'endTime': null, + }, + }; + final openSocket = Socket.fromJson(openSocketJson); + + expect(openSocket.status, 'Open'); // No endTime indicates "Open" + }, + ); + + test('Socket equality and hash code should work correctly', () { + expect(firstSocket == secondSocket, isFalse); + expect(firstSocket.hashCode != secondSocket.hashCode, isTrue); + + final duplicateSocket = Socket.fromJson(firstSocket.toJson()); + expect(firstSocket, equals(duplicateSocket)); + expect(firstSocket.hashCode, equals(duplicateSocket.hashCode)); + }); + }); + + group('OfflineNetworkData Tests', () { + test('OfflineNetworkData should deserialize correctly', () { + // Validate httpRequestData + expect(offlineData.httpRequestData.length, 2); + expect(offlineData.httpRequestData.first.id, '975585676925010898'); + expect(offlineData.httpRequestData.first.method, 'GET'); + expect( + offlineData.httpRequestData.first.uri, + 'https://jsonplaceholder.typicode.com/albums/1', + ); + + // Validate socketData + expect(offlineData.socketData.length, 2); + + // Validate selectedRequestId + expect(offlineData.selectedRequestId, isNull); + }); + + test('OfflineNetworkData should serialize correctly', () { + final serializedJson = offlineData.toJson(); + + // Validate serialized JSON + final httpRequestData = serializedJson['httpRequestData'] as List; + final firstRequest = httpRequestData.first as Map; + final requestDetails = firstRequest['request'] as Map; + + expect(requestDetails['id'], '975585676925010898'); + }); + + test( + 'isEmpty should return true when both httpRequestData and socketData are empty', + () { + final emptyOfflineData = OfflineNetworkData( + httpRequestData: [], + socketData: [], + timelineMicrosOffset: 1731482170837171, + ); + + expect(emptyOfflineData.isEmpty, isTrue); + }, + ); + + test('isEmpty should return false when httpRequestData is populated', () { + final populatedHttpData = OfflineNetworkData( + httpRequestData: offlineData.httpRequestData, + socketData: [], + timelineMicrosOffset: 1731482170837171, + ); + + expect(populatedHttpData.isEmpty, isFalse); + }); + + test('toJson and fromJson should preserve data integrity', () { + final serializedJson = offlineData.toJson(); + final restoredData = OfflineNetworkData.fromJson(serializedJson); + + expect( + restoredData.httpRequestData.length, + offlineData.httpRequestData.length, + ); + expect(restoredData.socketData.length, offlineData.socketData.length); + expect(restoredData.selectedRequestId, offlineData.selectedRequestId); + }); + }); +} 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 new file mode 100644 index 00000000000..4345951db15 --- /dev/null +++ b/packages/devtools_app/test/screens/network/sample_network_offline_data.json @@ -0,0 +1,301 @@ +{ + "httpRequestData": [ + { + "request": { + "id": "975585676925010898", + "method": "GET", + "uri": "https://jsonplaceholder.typicode.com/albums/1", + "startTime": 1731654001072706, + "endTime": 1731654001547377, + "response": { + "startTime": 1731654001589754, + "endTime": 1731654001591776, + "headers": { + "x-ratelimit-reset": [ + "1730729357" + ], + "x-ratelimit-limit": [ + "1000" + ], + "date": [ + "Fri, 15 Nov 2024 07:00:01 GMT" + ], + "transfer-encoding": [ + "chunked" + ], + "vary": [ + "Origin, Accept-Encoding" + ], + "content-encoding": [ + "gzip" + ], + "x-ratelimit-remaining": [ + "999" + ], + "pragma": [ + "no-cache" + ], + "server": [ + "cloudflare" + ], + "reporting-endpoints": [ + "heroku-nel=https://nel.heroku.com/reports?ts=1730729316&sid=e11707d5-02a7-43ef-b45e-2cf4d2036f7d&s=486jdn49YhgGKLCA8ntm7fq%2BpZFtSZ0mXlRJhpb9Drs%3D" + ], + "cf-ray": [ + "8e2d5ca65edc3b19-BOM" + ], + "etag": [ + "W/\"40-74G1+b66MteeTYAz6G+NybtDGFA\"" + ], + "connection": [ + "keep-alive" + ], + "cache-control": [ + "max-age=43200" + ], + "age": [ + "428" + ], + "server-timing": [ + "cfL4;desc=\"?proto=TCP&rtt=1937&sent=6&recv=7&lost=0&retrans=0&sent_bytes=3149&recv_bytes=601&delivery_rate=2286315&cwnd=252&unsent_bytes=0&cid=b3587511212928f2&ts=96&x=0\"" + ], + "report-to": [ + "{\"group\":\"heroku-nel\",\"max_age\":3600,\"endpoints\":[{\"url\":\"https://nel.heroku.com/reports?ts=1730729316&sid=e11707d5-02a7-43ef-b45e-2cf4d2036f7d&s=486jdn49YhgGKLCA8ntm7fq%2BpZFtSZ0mXlRJhpb9Drs%3D\"}]}" + ], + "cf-cache-status": [ + "HIT" + ], + "content-type": [ + "application/json; charset=utf-8" + ], + "access-control-allow-credentials": [ + "true" + ], + "x-powered-by": [ + "Express" + ], + "alt-svc": [ + "h3=\":443\"; ma=86400" + ], + "nel": [ + "{\"report_to\":\"heroku-nel\",\"max_age\":3600,\"success_fraction\":0.005,\"failure_fraction\":0.05,\"response_headers\":[\"Via\"]}" + ], + "via": [ + "1.1 vegur" + ], + "x-content-type-options": [ + "nosniff" + ], + "expires": [ + "-1" + ] + }, + "compressionState": "HttpClientResponseCompressionState.decompressed", + "connectionInfo": { + "localPort": 62852, + "remoteAddress": "172.67.167.151", + "remotePort": 443 + }, + "contentLength": -1, + "cookies": [], + "isRedirect": false, + "persistentConnection": true, + "reasonPhrase": "OK", + "redirects": [], + "statusCode": 200, + "error": null + }, + "request": { + "headers": { + "user-agent": [ + "Dart/3.6 (dart:io)" + ], + "accept-encoding": [ + "gzip" + ], + "content-length": [ + "0" + ], + "host": [ + "jsonplaceholder.typicode.com" + ] + }, + "followRedirects": true, + "maxRedirects": 5, + "connectionInfo": { + "localPort": 62852, + "remoteAddress": "172.67.167.151", + "remotePort": 443 + }, + "contentLength": 0, + "cookies": [], + "persistentConnection": true, + "proxyDetails": null + }, + "isolateId": "isolates/6270534775640395", + "events": [ + { + "event": "Connection established", + "timestamp": 1731654001547154, + "arguments": null + }, + { + "event": "Request sent", + "timestamp": 1731654001547179, + "arguments": null + }, + { + "event": "Waiting (TTFB)", + "timestamp": 1731654001589068, + "arguments": null + }, + { + "event": "Content Download", + "timestamp": 1731654001591839, + "arguments": null + } + ], + "requestBody": [], + "responseBody": [] + } + }, + { + "request": { + "id": "975585676925010899", + "method": "PUT", + "uri": "https://fake-store-api.mock.beeceptor.com", + "startTime": 1731654001073364, + "endTime": 1731654001502615, + "response": { + "startTime": 1731654001763913, + "endTime": 1731654001764397, + "headers": { + "content-type": [ + "text/plain" + ], + "alt-svc": [ + "h3=\":443\"; ma=2592000" + ], + "date": [ + "Fri, 15 Nov 2024 07:00:01 GMT" + ], + "access-control-allow-origin": [ + "*" + ], + "vary": [ + "Accept-Encoding" + ], + "content-length": [ + "125" + ] + }, + "compressionState": "HttpClientResponseCompressionState.notCompressed", + "connectionInfo": { + "localPort": 62851, + "remoteAddress": "159.89.140.122", + "remotePort": 443 + }, + "contentLength": 125, + "cookies": [], + "isRedirect": false, + "persistentConnection": true, + "reasonPhrase": "OK", + "redirects": [], + "statusCode": 200, + "error": null + }, + "request": { + "headers": { + "user-agent": [ + "Dart/3.6 (dart:io)" + ], + "accept-encoding": [ + "gzip" + ], + "user_id": [ + "1" + ], + "content-length": [ + "0" + ], + "items": [ + "[{\"product_id\":1,\"quantity\":2},{\"product_id\":3,\"quantity\":1}]" + ], + "host": [ + "fake-store-api.mock.beeceptor.com" + ] + }, + "followRedirects": true, + "maxRedirects": 5, + "connectionInfo": { + "localPort": 62851, + "remoteAddress": "159.89.140.122", + "remotePort": 443 + }, + "contentLength": 0, + "cookies": [], + "persistentConnection": true, + "proxyDetails": null + }, + "isolateId": "isolates/6270534775640395", + "events": [ + { + "event": "Connection established", + "timestamp": 1731654001501363, + "arguments": null + }, + { + "event": "Request sent", + "timestamp": 1731654001501436, + "arguments": null + }, + { + "event": "Waiting (TTFB)", + "timestamp": 1731654001763857, + "arguments": null + }, + { + "event": "Content Download", + "timestamp": 1731654001764409, + "arguments": null + } + ], + "requestBody": [], + "responseBody": [] + } + } + ], + "selectedRequestId": null, + "socketData": [ + { + "timelineMicrosBase": 1731482170837171, + "socket": { + "id": "105553123901536", + "startTime": 171830570040, + "endTime": 171830929647, + "lastReadTime": 171830928421, + "lastWriteTime": 171830669180, + "socketType": "tcp", + "address": "159.89.140.122", + "port": 443, + "readBytes": 4367, + "writeBytes": 18237 + } + }, + { + "timelineMicrosBase": 1731482170837171, + "socket": { + "id": "105553123902256", + "startTime": 171830571806, + "endTime": 171830757188, + "lastReadTime": 171830753067, + "lastWriteTime": 171830712602, + "socketType": "tcp", + "address": "172.67.167.151", + "port": 443, + "readBytes": 5447, + "writeBytes": 18247 + } + } + ] +} \ No newline at end of file diff --git a/packages/devtools_app/test/shared/framework/visible_screens_test.dart b/packages/devtools_app/test/shared/framework/visible_screens_test.dart index 686d32b5d39..ad169354f65 100644 --- a/packages/devtools_app/test/shared/framework/visible_screens_test.dart +++ b/packages/devtools_app/test/shared/framework/visible_screens_test.dart @@ -233,7 +233,7 @@ void main() { ProfilerScreen, // Works offline, so appears regardless of web flag MemoryScreen, // Works offline, so appears regardless of web flag // DebuggerScreen, - // NetworkScreen, + NetworkScreen, // LoggingScreen, // AppSizeScreen, // DeepLinksScreen,