implemented har feature (#7970)
diff --git a/packages/devtools_app/lib/src/screens/network/constants.dart b/packages/devtools_app/lib/src/screens/network/constants.dart new file mode 100644 index 0000000..b6e016e --- /dev/null +++ b/packages/devtools_app/lib/src/screens/network/constants.dart
@@ -0,0 +1,88 @@ +// 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 NetworkEventKeys { + log, + version, + creator, + name, + pages, + startedDateTime, + id, + title, + pageTimings, + onContentLoad, + onLoad, + entries, + pageref, + time, + request, + method, + url, + httpVersion, + cookies, + headers, + queryString, + postData, + mimeType, + text, + headersSize, + bodySize, + response, + status, + statusText, + content, + size, + redirectURL, + cache, + timings, + blocked, + dns, + connect, + send, + wait, + receive, + ssl, + serverIPAddress, + connection, + comment, + value, + uri, + redirects, + path, + domain, + expires, + httpOnly, + secure, + reasonPhrase, +} + +class NetworkEventDefaults { + static const logVersion = '1.2'; + static const creatorName = 'devtools'; + static const onContentLoad = -1; + static const onLoad = -1; + static const httpVersion = 'HTTP/1.1'; + static const responseHttpVersion = 'http/2.0'; + static const blocked = -1; + static const dns = -1; + static const connect = -1; + static const send = 1; + static const receive = 1; + static const ssl = -1; +} + +class NetworkEventCustomFieldKeys { + static const isolateId = '_isolateId'; + static const id = '_id'; + static const startTime = '_startTime'; + static const events = '_events'; +} + +enum NetworkEventCustomFieldRemappedKeys { + isolateId, + id, + startTime, + events, +}
diff --git a/packages/devtools_app/lib/src/screens/network/har_builder.dart b/packages/devtools_app/lib/src/screens/network/har_builder.dart new file mode 100644 index 0000000..6e37ca9 --- /dev/null +++ b/packages/devtools_app/lib/src/screens/network/har_builder.dart
@@ -0,0 +1,42 @@ +// 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 '../../shared/http/http_request_data.dart'; +import '../../shared/utils.dart'; +import 'constants.dart'; +import 'har_data_entry.dart'; + +/// Builds a HAR (HTTP Archive) object from a list of HTTP requests. +/// +/// The HAR format is a JSON-based format used for logging a web browser's +/// interaction with a site. It is useful for performance analysis and +/// debugging. This function constructs the HAR object based on the 1.2 +/// specification. +/// +/// For more details on the HAR format, see the [HAR 1.2 Specification](https://github.com/ahmadnassri/har-spec/blob/master/versions/1.2.md). +/// +/// Parameters: +/// - [httpRequests]: A list of [DartIOHttpRequestData] data. +/// +/// Returns: +/// - A Map representing the HAR object. +Map<String, Object?> buildHar(List<DartIOHttpRequestData> httpRequests) { + // Build the creator + final creator = <String, Object?>{ + NetworkEventKeys.name.name: NetworkEventDefaults.creatorName, + NetworkEventKeys.version.name: devToolsVersion, + }; + + // Build the entries + final entries = httpRequests.map((e) => HarDataEntry.toJson(e)).toList(); + + // Assemble the final HAR object + return <String, Object?>{ + NetworkEventKeys.log.name: <String, Object?>{ + NetworkEventKeys.version.name: NetworkEventDefaults.logVersion, + NetworkEventKeys.creator.name: creator, + NetworkEventKeys.entries.name: entries, + }, + }; +}
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 new file mode 100644 index 0000000..be71e04 --- /dev/null +++ b/packages/devtools_app/lib/src/screens/network/har_data_entry.dart
@@ -0,0 +1,292 @@ +// 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 '../../shared/http/http_request_data.dart'; +import '../../shared/primitives/utils.dart'; +import 'constants.dart'; + +/// A class representing a single entry in the HTTP Archive (HAR) format. +/// +/// This class is used to parse and handle data from HAR entries, converting +/// them into [DartIOHttpRequestData] objects which represent HTTP request data. +/// +/// This class also provides functionality to convert its instances back to +/// JSON format, maintaining the HAR entry structure. +class HarDataEntry { + HarDataEntry(this.request); + + /// Creates an instance of [HarDataEntry] from a JSON object. + /// + /// This factory constructor expects the [json] parameter to be a Map + /// representing a single HAR entry. + factory HarDataEntry.fromJson(Map<String, Object?> json) { + _convertHeaders(json); + + final modifiedRequestData = _remapCustomFieldKeys(json); + + // Retrieving url, method from requestData + final requestData = modifiedRequestData[NetworkEventKeys.request.name] + as Map<String, Object?>; + 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<String, Object?>; + responseData[NetworkEventKeys.redirects.name] = <Map<String, Object?>>[]; + final requestPostData = responseData[NetworkEventKeys.postData.name]; + final responseContent = responseData[NetworkEventKeys.content.name]; + + return HarDataEntry( + DartIOHttpRequestData.fromJson( + modifiedRequestData, + requestPostData as Map<String, Object?>?, + responseContent as Map<String, Object?>?, + ), + ); + } + + final DartIOHttpRequestData request; + + /// Converts the instance to a JSON object. + /// + /// This method returns a Map representing a single HAR entry, suitable for + /// serialization. + static Map<String, Object?> toJson(DartIOHttpRequestData e) { + // Implement the logic to convert DartIOHttpRequestData to HAR entry format + final requestCookies = e.requestCookies.map((cookie) { + return <String, Object?>{ + 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 <String, Object?>{ + NetworkEventKeys.name.name: header.key, + NetworkEventKeys.value.name: value, + }; + }).toList(); + + final queryString = Uri.parse(e.uri).queryParameters.entries.map((param) { + return <String, Object?>{ + NetworkEventKeys.name.name: param.key, + NetworkEventKeys.value.name: param.value, + }; + }).toList(); + + final responseCookies = e.responseCookies.map((cookie) { + return <String, Object?>{ + 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 <String, Object?>{ + NetworkEventKeys.name.name: header.key, + NetworkEventKeys.value.name: value, + }; + }).toList(); + + return <String, Object?>{ + NetworkEventKeys.startedDateTime.name: + e.startTimestamp.toUtc().toIso8601String(), + NetworkEventKeys.time.name: e.duration?.inMilliseconds, + // Request + NetworkEventKeys.request.name: <String, Object?>{ + NetworkEventKeys.method.name: e.method.toUpperCase(), + NetworkEventKeys.url.name: e.uri.toString(), + NetworkEventKeys.httpVersion.name: NetworkEventDefaults.httpVersion, + NetworkEventKeys.cookies.name: requestCookies, + NetworkEventKeys.headers.name: requestHeaders, + NetworkEventKeys.queryString.name: queryString, + NetworkEventKeys.postData.name: <String, Object?>{ + NetworkEventKeys.mimeType.name: e.contentType, + NetworkEventKeys.text.name: e.requestBody, + }, + NetworkEventKeys.headersSize.name: + _calculateHeadersSize(e.requestHeaders), + NetworkEventKeys.bodySize.name: _calculateBodySize(e.requestBody), + }, + // Response + NetworkEventKeys.response.name: <String, Object?>{ + NetworkEventKeys.status.name: e.status, + NetworkEventKeys.statusText.name: + e.general[NetworkEventKeys.reasonPhrase.name] ?? '', + NetworkEventKeys.httpVersion.name: + NetworkEventDefaults.responseHttpVersion, + NetworkEventKeys.cookies.name: responseCookies, + NetworkEventKeys.headers.name: responseHeaders, + NetworkEventKeys.content.name: <String, Object?>{ + NetworkEventKeys.size.name: e.responseBody?.length, + NetworkEventKeys.mimeType.name: e.type, + NetworkEventKeys.text.name: e.responseBody, + }, + NetworkEventKeys.redirectURL.name: '', + NetworkEventKeys.headersSize.name: + _calculateHeadersSize(e.responseHeaders), + NetworkEventKeys.bodySize.name: _calculateBodySize(e.responseBody), + }, + // Cache + NetworkEventKeys.cache.name: <String, Object?>{}, + NetworkEventKeys.timings.name: <String, Object?>{ + NetworkEventKeys.blocked.name: NetworkEventDefaults.blocked, + NetworkEventKeys.dns.name: NetworkEventDefaults.dns, + NetworkEventKeys.connect.name: NetworkEventDefaults.connect, + NetworkEventKeys.send.name: NetworkEventDefaults.send, + NetworkEventKeys.wait.name: e.duration?.inMilliseconds ?? 0, + NetworkEventKeys.receive.name: NetworkEventDefaults.receive, + NetworkEventKeys.ssl.name: NetworkEventDefaults.ssl, + }, + NetworkEventKeys.connection.name: e.hashCode.toString(), + NetworkEventKeys.comment.name: '', + + // Custom fields + // har spec requires underscore to be added for custom fields, hence removing them + // Note: the 'isolateId' field is kept empty because DartIOHttpRequestData does not expose it + // (but it is required by HttpProfileRequestRef.parse) + NetworkEventCustomFieldKeys.isolateId: '', + NetworkEventCustomFieldKeys.id: e.id, + NetworkEventCustomFieldKeys.startTime: + e.startTimestamp.microsecondsSinceEpoch, + // Note: The 'events' field is kept empty because DartIOHttpRequestData does not expose it + // (but it is required by HttpProfileRequestRef.parse) + NetworkEventCustomFieldKeys.events: [], + }; + } + + /// Returns the original [DartIOHttpRequestData] that this HAR entry was created from or parsed into. + DartIOHttpRequestData toDartIOHttpRequest() { + return request; + } + + static Map<String, Object?> _convertHeadersListToMap( + List<Object?> serializedHeaders, + ) { + final transformedHeaders = <String, Object?>{}; + + for (final header in serializedHeaders) { + if (header is Map<String, Object?>) { + final key = header[NetworkEventKeys.name.name] as String?; + final value = header[NetworkEventKeys.value.name]; + + if (key != null) { + if (transformedHeaders.containsKey(key)) { + if (transformedHeaders[key] is List) { + (transformedHeaders[key] as List).add(value); + } else { + transformedHeaders[key] = [transformedHeaders[key], value]; + } + } else { + transformedHeaders[key] = value; + } + } + } + } + + return transformedHeaders; + } + + /// Convert list of headers to map + static void _convertHeaders(Map<String, Object?> requestData) { + final reqData = + requestData[NetworkEventKeys.request.name] as Map<String, Object?>; + // Request Headers + if (reqData[NetworkEventKeys.headers.name] is List) { + reqData[NetworkEventKeys.headers.name] = _convertHeadersListToMap( + (reqData[NetworkEventKeys.headers.name]) as List<Object?>, + ); + } + + // Response Headers + final resData = + requestData[NetworkEventKeys.response.name] as Map<String, Object?>; + if (resData[NetworkEventKeys.headers.name] is List) { + resData[NetworkEventKeys.headers.name] = _convertHeadersListToMap( + (resData[NetworkEventKeys.headers.name]) as List<Object?>, + ); + } + } + + /// Removing underscores from custom fields + static Map<String, Object?> _remapCustomFieldKeys( + Map<String, Object?> originalMap, + ) { + final replacementMap = { + NetworkEventCustomFieldKeys.isolateId: + NetworkEventCustomFieldRemappedKeys.isolateId.name, + NetworkEventCustomFieldKeys.id: + NetworkEventCustomFieldRemappedKeys.id.name, + NetworkEventCustomFieldKeys.startTime: + NetworkEventCustomFieldRemappedKeys.startTime.name, + NetworkEventCustomFieldKeys.events: + NetworkEventCustomFieldRemappedKeys.events.name, + }; + + final convertedMap = <String, Object?>{}; + + originalMap.forEach((key, value) { + if (replacementMap.containsKey(key)) { + convertedMap[replacementMap[key]!] = value; + } else { + convertedMap[key] = value; + } + }); + + return convertedMap; + } +} + +int _calculateHeadersSize(Map<String, Object?>? 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<String>) { + 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; + } + return utf8.encode(requestBody!).length; +}
diff --git a/packages/devtools_app/lib/src/screens/network/har_network_data.dart b/packages/devtools_app/lib/src/screens/network/har_network_data.dart new file mode 100644 index 0000000..60b02ac --- /dev/null +++ b/packages/devtools_app/lib/src/screens/network/har_network_data.dart
@@ -0,0 +1,60 @@ +// 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 'constants.dart'; +import 'har_builder.dart'; +import 'har_data_entry.dart'; + +/// A class that represents network data in the HTTP Archive (HAR) format. +/// +/// This class implements the [Serializable] interface, allowing instances to +/// be serialized to and from JSON. +class HarNetworkData with Serializable { + /// Creates an instance of [HarNetworkData] with a list of [DartIOHttpRequestData] requests. + /// + /// The [requests] parameter should contain the list of [DartIOHttpRequestData] request data. + HarNetworkData(this.requests); + + /// Creates an instance of [HarNetworkData] from a JSON object. + /// + /// This factory constructor expects the [json] parameter to be a Map + /// representing the HAR data, with a structure containing a 'log' key, + /// which in turn contains an 'entries' key. Each entry in the 'entries' + /// list should be a Map representing an HTTP request. + /// + /// ```dart + /// final harData = HarNetworkData.fromJson(json); + /// ``` + factory HarNetworkData.fromJson(Map<String, Object?> json) { + final entries = ((json[NetworkEventKeys.log.name] + as Map<String, Object?>)[NetworkEventKeys.entries.name] + as List<Object?>) + .map( + (entryJson) => + HarDataEntry.fromJson(entryJson as Map<String, Object?>) + .toDartIOHttpRequest(), + ) + .toList(); + + return HarNetworkData(entries); + } + + /// The list of [DartIOHttpRequestData] request data. + final List<DartIOHttpRequestData> requests; + + /// Converts the instance to a JSON object. + /// + /// This method returns a Map representing the HAR data, suitable for + /// serialization. + /// + /// ```dart + /// final json = harData.toJson(); + /// ``` + @override + Map<String, Object?> toJson() { + return buildHar(requests); + } +}
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 cf3c1dd..dc76ad1 100644 --- a/packages/devtools_app/lib/src/screens/network/network_controller.dart +++ b/packages/devtools_app/lib/src/screens/network/network_controller.dart
@@ -3,11 +3,13 @@ // found in the LICENSE file. import 'dart:async'; +import 'dart:convert'; import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/foundation.dart'; import 'package:vm_service/vm_service.dart'; +import '../../shared/config_specific/import_export/import_export.dart'; import '../../shared/config_specific/logger/allowed_error.dart'; import '../../shared/globals.dart'; import '../../shared/http/http_request_data.dart'; @@ -16,6 +18,7 @@ import '../../shared/ui/filter.dart'; import '../../shared/ui/search.dart'; import '../../shared/utils.dart'; +import 'har_network_data.dart'; import 'network_model.dart'; import 'network_screen.dart'; import 'network_service.dart'; @@ -56,6 +59,29 @@ ); subscribeToFilterChanges(); } + List<DartIOHttpRequestData>? _httpRequests; + + String? exportAsHarFile() { + _httpRequests = + filteredData.value.whereType<DartIOHttpRequestData>().toList(); + + if (_httpRequests.isNullOrEmpty) { + debugPrint('No valid request data to export'); + return ''; + } + + try { + // Build the HAR object + final har = HarNetworkData(_httpRequests!); + return ExportController().downloadFile( + json.encode(har.toJson()), + type: ExportFileType.har, + ); + } catch (e) { + debugPrint('Exception in export $e'); + } + return null; + } static const methodFilterId = 'network-method-filter';
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 7464e7a..12e0d3a 100644 --- a/packages/devtools_app/lib/src/screens/network/network_screen.dart +++ b/packages/devtools_app/lib/src/screens/network/network_screen.dart
@@ -234,6 +234,14 @@ onPressed: widget.controller.clear, ), const SizedBox(width: defaultSpacing), + DownloadButton( + minScreenWidthForTextBeforeScaling: + _NetworkProfilerControls._includeTextWidth, + onPressed: widget.controller.exportAsHarFile, + gaScreen: gac.network, + gaSelection: gac.NetworkEvent.downloadAsHar.name, + ), + const SizedBox(width: defaultSpacing), const Expanded(child: SizedBox()), // TODO(kenz): fix focus issue when state is refreshed SearchField<NetworkController>(
diff --git a/packages/devtools_app/lib/src/shared/analytics/constants.dart b/packages/devtools_app/lib/src/shared/analytics/constants.dart index 28dbc03..420f31f 100644 --- a/packages/devtools_app/lib/src/shared/analytics/constants.dart +++ b/packages/devtools_app/lib/src/shared/analytics/constants.dart
@@ -12,6 +12,7 @@ part 'constants/_deep_links_constants.dart'; part 'constants/_extension_constants.dart'; part 'constants/_memory_constants.dart'; +part 'constants/_network_constants.dart'; part 'constants/_logging_constants.dart'; part 'constants/_performance_constants.dart'; part 'constants/_vs_code_sidebar_constants.dart';
diff --git a/packages/devtools_app/lib/src/shared/analytics/constants/_network_constants.dart b/packages/devtools_app/lib/src/shared/analytics/constants/_network_constants.dart new file mode 100644 index 0000000..5ac4bbf --- /dev/null +++ b/packages/devtools_app/lib/src/shared/analytics/constants/_network_constants.dart
@@ -0,0 +1,9 @@ +// 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. + +part of '../constants.dart'; + +enum NetworkEvent { + downloadAsHar, +}
diff --git a/packages/devtools_app/lib/src/shared/config_specific/import_export/import_export.dart b/packages/devtools_app/lib/src/shared/config_specific/import_export/import_export.dart index d90d0bf..52ec275 100644 --- a/packages/devtools_app/lib/src/shared/config_specific/import_export/import_export.dart +++ b/packages/devtools_app/lib/src/shared/config_specific/import_export/import_export.dart
@@ -113,7 +113,8 @@ json, csv, yaml, - data; + data, + har; @override String toString() => name;
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 1196793..bef81dc 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
@@ -46,6 +46,19 @@ } } + factory DartIOHttpRequestData.fromJson( + Map<String, Object?> modifiedRequestData, + Map<String, Object?>? requestPostData, + Map<String, Object?>? responseContent, + ) { + return DartIOHttpRequestData( + HttpProfileRequestRef.parse(modifiedRequestData)!, + requestFullDataFromVmService: false, + ) + .._responseBody = responseContent?['text'].toString() + .._requestBody = requestPostData?['text'].toString(); + } + static const _connectionInfoKey = 'connectionInfo'; static const _contentTypeKey = 'content-type'; static const _localPortKey = 'localPort'; @@ -147,7 +160,7 @@ } /// Extracts the extension from [mime], with overrides for shortened - /// extenstions of common types (e.g., jpe -> jpeg). + /// extensions of common types (e.g., jpe -> jpeg). String _extensionFromMime(String mime) { final extension = extensionFromMime(mime); if (extension == 'jpe') {
diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md index c848254..90c581f 100644 --- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md +++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
@@ -38,6 +38,8 @@ ## Network profiler updates +* Added support to export network requests to HAR file. + - [#7970](https://github.com/flutter/devtools/pull/7970) * Fixed issue where socket statistics were being reported as web sockets. - [#8061](https://github.com/flutter/devtools/pull/8061) 
diff --git a/packages/devtools_app/test/network/har_network_test.dart b/packages/devtools_app/test/network/har_network_test.dart new file mode 100644 index 0000000..0520c93 --- /dev/null +++ b/packages/devtools_app/test/network/har_network_test.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 'dart:convert'; +import 'dart:io'; + +import 'package:devtools_app/src/screens/network/constants.dart'; +import 'package:devtools_app/src/screens/network/har_data_entry.dart'; +import 'package:devtools_app/src/screens/network/har_network_data.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final file = File('test/network/sample_requests.json'); + final fileContent = file.readAsStringSync(); + final jsonData = jsonDecode(fileContent) as Map<String, Object?>; + + group('HarNetworkData', () { + test('toJson serializes correctly', () { + // Parse the HAR data + final harData = HarNetworkData.fromJson(jsonData); + + // Serialize the HAR data back to JSON + final json = harData.toJson(); + + // Verify the serialization + expect(json['log'], isNotNull); + final log = json['log'] as Map<String, Object?>?; + expect(log?['version'], '1.2'); + expect((log?['creator'] as Map<String, Object?>)['name'], 'devtools'); + + final entries = log?['entries'] as List<Object?>?; + expect(entries?.length, 2); + + final entry = entries?[0] as Map<String, Object?>?; + expect(entry?['startedDateTime'], '2024-07-11T13:19:35.156Z'); + expect((entry?['request'] as Map<String, Object?>)['method'], 'GET'); + expect( + (entry?['request'] as Map<String, Object?>)['url'], + 'https://jsonplaceholder.typicode.com/albums/1', + ); + expect( + (entry?['request'] as Map<String, Object?>)['httpVersion'], + 'HTTP/1.1', + ); + expect((entry?['request'] as Map<String, Object?>)['cookies'], isEmpty); + + expect(entry?['cache'], isEmpty); + expect((entry?['timings'] as Map<String, Object?>)['blocked'], -1); + expect((entry?['timings'] as Map<String, Object?>)['dns'], -1); + expect((entry?['timings'] as Map<String, Object?>)['connect'], -1); + expect((entry?['timings'] as Map<String, Object?>)['send'], 1); + expect((entry?['timings'] as Map<String, Object?>)['receive'], 1); + expect((entry?['timings'] as Map<String, Object?>)['ssl'], -1); + expect(entry?['comment'], ''); + }); + }); + + group('HarDataEntry', () { + test('fromJson parses correctly', () { + final entryJson = + ((jsonData['log'] as Map<String, Object?>)['entries'] as List).first + as Map<String, Object?>; + final harDataEntry = HarDataEntry.fromJson(entryJson); + + expect( + harDataEntry.request.uri.toString(), + 'https://jsonplaceholder.typicode.com/albums/1', + ); + expect(harDataEntry.request.method, 'GET'); + expect(harDataEntry.request.requestHeaders, isNotEmpty); + expect(harDataEntry.request.requestCookies, isEmpty); + }); + + test('toJson serializes correctly', () { + final entryJson = + ((jsonData['log'] as Map<String, Object?>)['entries'] as List).first + as Map<String, Object?>; + final harDataEntry = HarDataEntry.fromJson(entryJson); + final json = HarDataEntry.toJson(harDataEntry.request); + + expect(json['startedDateTime'], '2024-07-11T13:19:35.156Z'); + expect(json['request'], isNotNull); + final request = json['request'] as Map<String, Object?>?; + expect(request?['method'], 'GET'); + expect(request?['url'], 'https://jsonplaceholder.typicode.com/albums/1'); + expect(request?['httpVersion'], 'HTTP/1.1'); + expect(request?['cookies'], isEmpty); + + expect(json['cache'], isEmpty); + final timings = json['timings'] as Map<String, Object?>?; + expect(timings?['blocked'], NetworkEventDefaults.blocked); + expect(timings?['dns'], NetworkEventDefaults.dns); + expect(timings?['connect'], NetworkEventDefaults.connect); + expect(timings?['send'], 1); + expect(timings?['receive'], 1); + expect(timings?['ssl'], NetworkEventDefaults.ssl); + expect(json['comment'], ''); + }); + }); +}
diff --git a/packages/devtools_app/test/network/sample_requests.json b/packages/devtools_app/test/network/sample_requests.json new file mode 100644 index 0000000..9d54338 --- /dev/null +++ b/packages/devtools_app/test/network/sample_requests.json
@@ -0,0 +1,228 @@ +{ + "log": { + "version": "1.2", + "creator": { + "name": "devtools", + "version": "2.38.0-dev.0" + }, + "entries": [ + { + "startedDateTime": "2024-07-11T13:19:35.156Z", + "time": 313, + "request": { + "method": "GET", + "url": "https://jsonplaceholder.typicode.com/albums/1", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": { "name": "x-ratelimit-reset", + "value": "1709091028"}, + "queryString": [], + "postData": { + "mimeType": "[application/json; charset=utf-8]", + "text": null + }, + "headersSize": 120, + "bodySize": 0 + }, + "response": { + "status": "200", + "statusText": "", + "httpVersion": "http/2.0", + "cookies": [], + "headers": [{"": ""}], + "content": { + "size": 0, + "mimeType": "json", + "text": "" + }, + "redirectURL": "", + "headersSize": 1164, + "bodySize": 0 + }, + "cache": {}, + "timings": { + "blocked": -1, + "dns": -1, + "connect": -1, + "send": 1, + "wait": 313, + "receive": 1, + "ssl": -1 + }, + "connection": "525659655", + "comment": "", + "_isolateId": "", + "_id": "-2285651906968511108", + "_startTime": 1720703975156000, + "_events": [] + }, + { + "startedDateTime": "2024-07-11T13:19:37.155Z", + "time": 182, + "request": { + "method": "GET", + "url": "https://jsonplaceholder.typicode.com/albums/1", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "user-agent", + "value": "Dart/3.4 (dart:io)" + }, + { + "name": "accept-encoding", + "value": "gzip" + }, + { + "name": "content-length", + "value": "0" + }, + { + "name": "host", + "value": "jsonplaceholder.typicode.com" + } + ], + "queryString": [], + "postData": { + "mimeType": "[application/json; charset=utf-8]", + "text": null + }, + "headersSize": 120, + "bodySize": 0 + }, + "response": { + "status": "200", + "statusText": "", + "httpVersion": "http/2.0", + "cookies": [], + "headers": [ + { + "name": "x-ratelimit-reset", + "value": "1709091028" + }, + { + "name": "x-ratelimit-limit", + "value": "1000" + }, + { + "name": "date", + "value": "Thu, 11 Jul 2024 13:19:37 GMT" + }, + { + "name": "transfer-encoding", + "value": "chunked" + }, + { + "name": "vary", + "value": "Origin, Accept-Encoding" + }, + { + "name": "content-encoding", + "value": "gzip" + }, + { + "name": "x-ratelimit-remaining", + "value": "999" + }, + { + "name": "pragma", + "value": "no-cache" + }, + { + "name": "server", + "value": "cloudflare" + }, + { + "name": "reporting-endpoints", + "value": "heroku-nel=https://nel.heroku.com/reports?ts=1709090976&sid=e11707d5-02a7-43ef-b45e-2cf4d2036f7d&s=YpsV0iwpAFY4lRLb4CpTkx%2B929Gjbe5%2BZ7X1RzUnIzs%3D" + }, + { + "name": "cf-ray", + "value": "8a1916135be93c5e-BOM" + }, + { + "name": "etag", + "value": "W/\"40-74G1+b66MteeTYAz6G+NybtDGFA\"" + }, + { + "name": "connection", + "value": "keep-alive" + }, + { + "name": "cache-control", + "value": "max-age=43200" + }, + { + "name": "age", + "value": "3993" + }, + { + "name": "report-to", + "value": "{\"group\":\"heroku-nel\",\"max_age\":3600,\"endpoints\":[{\"url\":\"https://nel.heroku.com/reports?ts=1709090976&sid=e11707d5-02a7-43ef-b45e-2cf4d2036f7d&s=YpsV0iwpAFY4lRLb4CpTkx%2B929Gjbe5%2BZ7X1RzUnIzs%3D\"}]}" + }, + { + "name": "cf-cache-status", + "value": "HIT" + }, + { + "name": "content-type", + "value": "application/json; charset=utf-8" + }, + { + "name": "access-control-allow-credentials", + "value": "true" + }, + { + "name": "x-powered-by", + "value": "Express" + }, + { + "name": "alt-svc", + "value": "h3=\":443\"; ma=86400" + }, + { + "name": "nel", + "value": "{\"report_to\":\"heroku-nel\",\"max_age\":3600,\"success_fraction\":0.005,\"failure_fraction\":0.05,\"response_headers\":[\"Via\"]}" + }, + { + "name": "via", + "value": "1.1 vegur" + }, + { + "name": "x-content-type-options", + "value": "nosniff" + }, + { + "name": "expires", + "value": "-1" + } + ], + "content": { + "size": 0, + "mimeType": "json", + "text": "" + }, + "redirectURL": "", + "headersSize": 1164, + "bodySize": 0 + }, + "cache": {}, + "timings": { + "blocked": -1, + "dns": -1, + "connect": -1, + "send": 1, + "wait": 182, + "receive": 1, + "ssl": -1 + }, + "connection": "249624739", + "comment": "", + "_isolateId": "", + "_id": "-2285651906968511106", + "_startTime": 1720703977155000, + "_events": [] + } + ] + } +} \ No newline at end of file