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..74272b80b78 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'; @@ -28,18 +29,16 @@ class HarDataEntry { final modifiedRequestData = _remapCustomFieldKeys(json); // Retrieving url, method from requestData - final requestData = - modifiedRequestData[NetworkEventKeys.request.name] - as Map; + final requestData = modifiedRequestData[NetworkEventKeys.request.name] + as Map; modifiedRequestData[NetworkEventKeys.uri.name] = requestData[NetworkEventKeys.url.name]; modifiedRequestData[NetworkEventKeys.method.name] = requestData[NetworkEventKeys.method.name]; // Adding missing keys which are mandatory for parsing - final responseData = - modifiedRequestData[NetworkEventKeys.response.name] - as Map; + final responseData = modifiedRequestData[NetworkEventKeys.response.name] + as Map; responseData[NetworkEventKeys.redirects.name] = >[]; final requestPostData = responseData[NetworkEventKeys.postData.name]; final responseContent = responseData[NetworkEventKeys.content.name]; @@ -61,65 +60,60 @@ class HarDataEntry { /// serialization. static Map toJson(DartIOHttpRequestData e) { // Implement the logic to convert DartIOHttpRequestData to HAR entry format - final requestCookies = - e.requestCookies.map((cookie) { - return { - NetworkEventKeys.name.name: cookie.name, - NetworkEventKeys.value.name: cookie.value, - NetworkEventKeys.path.name: cookie.path, - NetworkEventKeys.domain.name: cookie.domain, - NetworkEventKeys.expires.name: - cookie.expires?.toUtc().toIso8601String(), - NetworkEventKeys.httpOnly.name: cookie.httpOnly, - NetworkEventKeys.secure.name: cookie.secure, - }; - }).toList(); - - final requestHeaders = - e.requestHeaders?.entries.map((header) { - var value = header.value; - if (value is List) { - value = value.first; - } - return { - NetworkEventKeys.name.name: header.key, - NetworkEventKeys.value.name: value, - }; - }).toList(); - - final queryString = - Uri.parse(e.uri).queryParameters.entries.map((param) { - return { - NetworkEventKeys.name.name: param.key, - NetworkEventKeys.value.name: param.value, - }; - }).toList(); - - final responseCookies = - e.responseCookies.map((cookie) { - return { - NetworkEventKeys.name.name: cookie.name, - NetworkEventKeys.value.name: cookie.value, - NetworkEventKeys.path.name: cookie.path, - NetworkEventKeys.domain.name: cookie.domain, - NetworkEventKeys.expires.name: - cookie.expires?.toUtc().toIso8601String(), - NetworkEventKeys.httpOnly.name: cookie.httpOnly, - NetworkEventKeys.secure.name: cookie.secure, - }; - }).toList(); - - final responseHeaders = - e.responseHeaders?.entries.map((header) { - var value = header.value; - if (value is List) { - value = value.first; - } - return { - NetworkEventKeys.name.name: header.key, - NetworkEventKeys.value.name: value, - }; - }).toList(); + final requestCookies = e.requestCookies.map((cookie) { + return { + NetworkEventKeys.name.name: cookie.name, + NetworkEventKeys.value.name: cookie.value, + NetworkEventKeys.path.name: cookie.path, + NetworkEventKeys.domain.name: cookie.domain, + NetworkEventKeys.expires.name: + cookie.expires?.toUtc().toIso8601String(), + NetworkEventKeys.httpOnly.name: cookie.httpOnly, + NetworkEventKeys.secure.name: cookie.secure, + }; + }).toList(); + + final requestHeaders = e.requestHeaders?.entries.map((header) { + var value = header.value; + if (value is List) { + value = value.first; + } + return { + NetworkEventKeys.name.name: header.key, + NetworkEventKeys.value.name: value, + }; + }).toList(); + + final queryString = Uri.parse(e.uri).queryParameters.entries.map((param) { + return { + NetworkEventKeys.name.name: param.key, + NetworkEventKeys.value.name: param.value, + }; + }).toList(); + + final responseCookies = e.responseCookies.map((cookie) { + return { + NetworkEventKeys.name.name: cookie.name, + NetworkEventKeys.value.name: cookie.value, + NetworkEventKeys.path.name: cookie.path, + NetworkEventKeys.domain.name: cookie.domain, + NetworkEventKeys.expires.name: + cookie.expires?.toUtc().toIso8601String(), + NetworkEventKeys.httpOnly.name: cookie.httpOnly, + NetworkEventKeys.secure.name: cookie.secure, + }; + }).toList(); + + final responseHeaders = e.responseHeaders?.entries.map((header) { + var value = header.value; + if (value is List) { + value = value.first; + } + return { + NetworkEventKeys.name.name: header.key, + NetworkEventKeys.value.name: value, + }; + }).toList(); return { NetworkEventKeys.startedDateTime.name: @@ -137,9 +131,8 @@ class HarDataEntry { NetworkEventKeys.mimeType.name: e.contentType, NetworkEventKeys.text.name: e.requestBody, }, - NetworkEventKeys.headersSize.name: _calculateHeadersSize( - e.requestHeaders, - ), + NetworkEventKeys.headersSize.name: + calculateHeadersSize(e.requestHeaders), NetworkEventKeys.bodySize.name: _calculateBodySize(e.requestBody), }, // Response @@ -157,9 +150,8 @@ class HarDataEntry { NetworkEventKeys.text.name: e.responseBody, }, NetworkEventKeys.redirectURL.name: '', - NetworkEventKeys.headersSize.name: _calculateHeadersSize( - e.responseHeaders, - ), + NetworkEventKeys.headersSize.name: + calculateHeadersSize(e.responseHeaders), NetworkEventKeys.bodySize.name: _calculateBodySize(e.responseBody), }, // Cache @@ -272,28 +264,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 2d555968d36..ac8e4b00f91 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_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,46 @@ 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), + // ignore: avoid_dynamic_calls + shouldLoad: (data) => !data.isEmpty, + loadData: (data) => loadOfflineData(data), + ); + } else { + await startRecording(); + } + } + + Future loadOfflineData(OfflineNetworkData offlineData) async { + final httpProfileData = + offlineData.httpRequestData.mapToHttpProfileRequests; + final socketStatsData = offlineData.socketData.mapToSocketStatistics; + + _currentNetworkRequests + ..clear() + ..updateOrAddAll( + requests: httpProfileData, + sockets: socketStatsData, + timelineMicrosOffset: offlineData.timelineMicrosOffset, + ); + _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 +444,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 ec92270cdc0..2abd767b9ef 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/common_widgets.dart'; import '../../shared/config_specific/copy_to_clipboard/copy_to_clipboard.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..3550770df5d --- /dev/null +++ b/packages/devtools_app/lib/src/screens/network/utils/http_utils.dart @@ -0,0 +1,31 @@ +// 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 360145f5c25..e87b0de7419 100644 --- a/packages/devtools_app/lib/src/shared/feature_flags.dart +++ b/packages/devtools_app/lib/src/shared/feature_flags.dart @@ -41,6 +41,9 @@ 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 +68,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/http/constants.dart b/packages/devtools_app/lib/src/shared/http/constants.dart new file mode 100644 index 00000000000..3ed1190b561 --- /dev/null +++ b/packages/devtools_app/lib/src/shared/http/constants.dart @@ -0,0 +1,81 @@ +// 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..e49b33e1e53 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,33 @@ 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, - ) - .._responseBody = responseContent?['text'].toString() - .._requestBody = requestPostData?['text'].toString(); + parsedRequest!, + requestFullDataFromVmService: parsedRequest is! HttpProfileRequest, + ) + .._responseBody = responseBody + .._requestBody = requestBody; + } + + @override + Map toJson() { + return { + HttpRequestDataKeys.request.name: + (_request as HttpProfileRequest).toJson(), + }; } static const _connectionInfoKey = 'connectionInfo'; @@ -73,9 +95,9 @@ class DartIOHttpRequestData extends NetworkRequest { isFetchingFullData = true; final updated = await serviceConnection.serviceManager.service! .getHttpProfileRequestWrapper( - _request.isolateId, - _request.id.toString(), - ); + _request.isolateId, + _request.id.toString(), + ); _request = updated; final fullRequest = _request as HttpProfileRequest; _responseBody = utf8.decode(fullRequest.responseBody!); @@ -211,10 +233,9 @@ class DartIOHttpRequestData extends NetworkRequest { requestCookies.isNotEmpty || responseCookies.isNotEmpty; /// A list of all cookies contained within the request headers. - List get requestCookies => - _hasError - ? [] - : DartIOHttpRequestData._parseCookies(_request.request?.cookies); + List get requestCookies => _hasError + ? [] + : DartIOHttpRequestData._parseCookies(_request.request?.cookies); /// A list of all cookies contained within the response headers. List get responseCookies => @@ -314,10 +335,9 @@ class DartIOHttpRequestData extends NetworkRequest { DateTime lastTime = _request.startTime; for (final instant in instantEvents) { final instantTime = instant.timestamp; - instant._timeRange = - TimeRange() - ..start = Duration(microseconds: lastTime.microsecondsSinceEpoch) - ..end = Duration(microseconds: instantTime.microsecondsSinceEpoch); + instant._timeRange = TimeRange() + ..start = Duration(microseconds: lastTime.microsecondsSinceEpoch) + ..end = Duration(microseconds: instantTime.microsecondsSinceEpoch); lastTime = instantTime; } } @@ -328,6 +348,95 @@ class DartIOHttpRequestData extends NetworkRequest { } @override - int get hashCode => - Object.hash(id, method, uri, contentType, type, port, startTimestamp); + 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/lib/src/shared/screen.dart b/packages/devtools_app/lib/src/shared/screen.dart index c9857feeaf5..7e46df42008 100644 --- a/packages/devtools_app/lib/src/shared/screen.dart +++ b/packages/devtools_app/lib/src/shared/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/test/network/offline_data_test.dart b/packages/devtools_app/test/network/offline_data_test.dart new file mode 100644 index 00000000000..33f195c7048 --- /dev/null +++ b/packages/devtools_app/test/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/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/network/sample_network_offline_data.json b/packages/devtools_app/test/network/sample_network_offline_data.json new file mode 100644 index 00000000000..4345951db15 --- /dev/null +++ b/packages/devtools_app/test/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