diff --git a/packages/devtools_app/lib/src/screens/network/network_request_inspector.dart b/packages/devtools_app/lib/src/screens/network/network_request_inspector.dart index da3bf207a5e..ffc33136c90 100644 --- a/packages/devtools_app/lib/src/screens/network/network_request_inspector.dart +++ b/packages/devtools_app/lib/src/screens/network/network_request_inspector.dart @@ -76,7 +76,13 @@ class NetworkRequestInspector extends StatelessWidget { ), if (data is DartIOHttpRequestData) ...[ ( - tab: _buildTab(tabName: NetworkRequestInspector._headersTabTitle), + tab: _buildTab( + tabName: NetworkRequestInspector._headersTabTitle, + trailing: HttpViewTrailingCopyButton( + data, + HttpRequestHeadersView.copyText, + ), + ), tabView: HttpRequestHeadersView(data), ), if (data.requestBody != null) @@ -113,7 +119,13 @@ class NetworkRequestInspector extends StatelessWidget { ), if (data.hasCookies) ( - tab: _buildTab(tabName: NetworkRequestInspector._cookiesTabTitle), + tab: _buildTab( + tabName: NetworkRequestInspector._cookiesTabTitle, + trailing: HttpViewTrailingCopyButton( + data, + HttpRequestCookiesView.copyText, + ), + ), tabView: HttpRequestCookiesView(data), ), ], diff --git a/packages/devtools_app/lib/src/screens/network/network_request_inspector_views.dart b/packages/devtools_app/lib/src/screens/network/network_request_inspector_views.dart index 46d35a92151..f13f69fe4fa 100644 --- a/packages/devtools_app/lib/src/screens/network/network_request_inspector_views.dart +++ b/packages/devtools_app/lib/src/screens/network/network_request_inspector_views.dart @@ -53,15 +53,45 @@ ExpansionTile _buildTile( class HttpRequestHeadersView extends StatelessWidget { const HttpRequestHeadersView(this.data, {super.key}); + static const _generalTitle = 'General'; + static const _responseHeadersTitle = 'Response Headers'; + static const _requestHeadersTitle = 'Request Headers'; + @visibleForTesting - static const generalKey = Key('General'); + static const generalKey = Key(_generalTitle); @visibleForTesting - static const requestHeadersKey = Key('Request Headers'); + static const requestHeadersKey = Key(_requestHeadersTitle); @visibleForTesting - static const responseHeadersKey = Key('Response Headers'); + static const responseHeadersKey = Key(_responseHeadersTitle); final DartIOHttpRequestData data; + /// The contents of the Headers tab as plain text, for copying. + /// + /// Each non-empty section is listed under its title, in the same order as + /// the tab displays them, separated by a blank line. Values are formatted as + /// they would appear on the wire (see [headersToText]), so multi-value + /// headers are not wrapped in brackets as they are in the tab. + static String copyText(DartIOHttpRequestData data) { + final sections = [ + (title: _generalTitle, headers: data.general, format: generalInfoToText), + ( + title: _responseHeadersTitle, + headers: data.responseHeaders, + format: headersToText, + ), + ( + title: _requestHeadersTitle, + headers: data.requestHeaders, + format: headersToText, + ), + ]; + return [ + for (final (:title, :headers, :format) in sections) + if (headers != null && headers.isNotEmpty) '$title\n${format(headers)}', + ].join('\n\n'); + } + @override Widget build(BuildContext context) { final general = data.general; @@ -73,19 +103,19 @@ class HttpRequestHeadersView extends StatelessWidget { child: ListView( children: [ _buildHeadersTile( - 'General', + _generalTitle, general, constraints, key: generalKey, ), _buildHeadersTile( - 'Response Headers', + _responseHeadersTitle, responseHeaders, constraints, key: responseHeadersKey, ), _buildHeadersTile( - 'Request Headers', + _requestHeadersTitle, requestHeaders, constraints, key: requestHeadersKey, @@ -413,25 +443,30 @@ class ImageResponseView extends StatelessWidget { Widget build(BuildContext context) { final encodedResponse = data.encodedResponse!; final img = image.decodeImage(encodedResponse)!; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildTile('Image Preview', [ - Padding( - padding: const EdgeInsets.all(denseSpacing), - child: Image.memory(encodedResponse), - ), - ]), - _buildTile('Metadata', [ - _buildRow(context, 'Format', data.type), - _buildRow( - context, - 'Size', - prettyPrintBytes(encodedResponse.lengthInBytes, includeUnit: true)!, - ), - _buildRow(context, 'Dimensions', '${img.width} x ${img.height}'), - ]), - ], + return SelectionArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildTile('Image Preview', [ + Padding( + padding: const EdgeInsets.all(denseSpacing), + child: Image.memory(encodedResponse), + ), + ]), + _buildTile('Metadata', [ + _buildRow(context, 'Format', data.type), + _buildRow( + context, + 'Size', + prettyPrintBytes( + encodedResponse.lengthInBytes, + includeUnit: true, + )!, + ), + _buildRow(context, 'Dimensions', '${img.width} x ${img.height}'), + ]), + ], + ), ); } @@ -463,13 +498,36 @@ bool _isJsonDecodable(String source) { class HttpRequestCookiesView extends StatelessWidget { const HttpRequestCookiesView(this.data, {super.key}); + static const _requestCookiesTitle = 'Request Cookies'; + static const _responseCookiesTitle = 'Response Cookies'; + @visibleForTesting - static const requestCookiesKey = Key('Request Cookies'); + static const requestCookiesKey = Key(_requestCookiesTitle); @visibleForTesting - static const responseCookiesKey = Key('Response Cookies'); + static const responseCookiesKey = Key(_responseCookiesTitle); final DartIOHttpRequestData data; + /// The contents of the Cookies tab as plain text, for copying. + /// + /// Response cookies are listed as raw `Set-Cookie` values, one per line, and + /// request cookies as a `Cookie` header value. Sections are in the same + /// order as the tab displays them, separated by a blank line. + static String copyText(DartIOHttpRequestData data) { + final responseCookies = data.responseCookies; + final requestCookies = data.requestCookies; + final responseCookiesText = responseCookiesToText( + responseCookies, + data.responseHeaders, + ); + return [ + if (responseCookies.isNotEmpty) + '$_responseCookiesTitle\n$responseCookiesText', + if (requestCookies.isNotEmpty) + '$_requestCookiesTitle\n${requestCookiesToText(requestCookies)}', + ].join('\n\n'); + } + DataRow _buildRow(int index, Cookie cookie, {bool requestCookies = false}) { return DataRow.byIndex( index: index, @@ -568,30 +626,32 @@ class HttpRequestCookiesView extends StatelessWidget { final responseCookies = data.responseCookies; return LayoutBuilder( builder: (context, constraints) { - return ListView( - children: [ - if (responseCookies.isNotEmpty) - _buildCookiesTable( - context, - 'Response Cookies', - responseCookies, - constraints, - responseCookiesKey, - ), - // Add padding between the cookie tables if displaying both - // response and request cookies. - if (responseCookies.isNotEmpty && requestCookies.isNotEmpty) - const Padding(padding: EdgeInsets.only(bottom: 24.0)), - if (requestCookies.isNotEmpty) - _buildCookiesTable( - context, - 'Request Cookies', - requestCookies, - constraints, - requestCookiesKey, - requestCookies: true, - ), - ], + return SelectionArea( + child: ListView( + children: [ + if (responseCookies.isNotEmpty) + _buildCookiesTable( + context, + _responseCookiesTitle, + responseCookies, + constraints, + responseCookiesKey, + ), + // Add padding between the cookie tables if displaying both + // response and request cookies. + if (responseCookies.isNotEmpty && requestCookies.isNotEmpty) + const Padding(padding: EdgeInsets.only(bottom: 24.0)), + if (requestCookies.isNotEmpty) + _buildCookiesTable( + context, + _requestCookiesTitle, + requestCookies, + constraints, + requestCookiesKey, + requestCookies: true, + ), + ], + ), ); }, ); 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 index b4c30797df0..3edb3b6993f 100644 --- a/packages/devtools_app/lib/src/screens/network/utils/http_utils.dart +++ b/packages/devtools_app/lib/src/screens/network/utils/http_utils.dart @@ -4,6 +4,38 @@ import 'dart:convert'; +import '../../../shared/http/http.dart'; + +const _setCookieHeader = 'set-cookie'; +const _cookieHeader = 'cookie'; + +/// Returns [headers] as `key: value` lines, as they would appear on the wire. +/// +/// Header values decoded from JSON are a `List` rather than a +/// `List`, so any [Iterable] is treated as a multi-value header: +/// - `set-cookie` gets one line per value, since cookie dates +/// (`Expires=...`) contain commas. +/// - `cookie` values are joined with `; `. +/// - all other values are joined with `, `. +Iterable _headerLines(Map headers) sync* { + for (final MapEntry(:key, :value) in headers.entries) { + if (value is! Iterable) { + yield '$key: $value'; + continue; + } + switch (key.toLowerCase()) { + case _setCookieHeader: + for (final cookie in value) { + yield '$key: $cookie'; + } + case _cookieHeader: + yield '$key: ${value.join('; ')}'; + default: + yield '$key: ${value.join(', ')}'; + } + } +} + /// Calculates the size of the headers in bytes. /// /// Takes a map of headers [headers], where keys are header names and values @@ -13,15 +45,9 @@ 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(); + String headersString = _headerLines( + headers, + ).map((line) => '$line\r\n').join(); // Add final CRLF to indicate end of headers headersString += '\r\n'; @@ -30,6 +56,52 @@ int calculateHeadersSize(Map? headers) { return utf8.encode(headersString).length; } +/// Formats [headers] as `key: value` lines, as they would appear on the wire, +/// suitable for copying. +/// +/// Returns an empty string if [headers] is null. +String headersToText(Map? headers) => + headers == null ? '' : _headerLines(headers).join('\n'); + +/// Formats [info] as plain `key: value` lines, the way the General section of +/// the Headers tab displays them. +/// +/// Unlike [headersToText], values are not treated as multi-value headers, since +/// the General section holds request details like `redirects` rather than HTTP +/// headers. +String generalInfoToText(Map info) => + info.entries.map((entry) => '${entry.key}: ${entry.value}').join('\n'); + +String _cookieNameAndValue(Cookie cookie) => '${cookie.name}=${cookie.value}'; + +/// Formats request [cookies] the way they are sent in a `Cookie` header: +/// `name=value; name2=value2`. +String requestCookiesToText(List cookies) => + cookies.map(_cookieNameAndValue).join('; '); + +/// Formats response cookies as one `Set-Cookie` value per line. +/// +/// Prefers the raw `set-cookie` values in [responseHeaders], since parsing a +/// [Cookie] drops attributes like `Max-Age` and `SameSite`. Falls back to +/// `name=value` from [cookies] when the header is not available. +String responseCookiesToText( + List cookies, + Map? responseHeaders, +) { + final rawCookies = responseHeaders?.entries + .where((entry) => entry.key.toLowerCase() == _setCookieHeader) + .expand( + (entry) => + entry.value is Iterable ? entry.value as Iterable : [entry.value], + ) + .nonNulls + .toList(); + if (rawCookies != null && rawCookies.isNotEmpty) { + return rawCookies.join('\n'); + } + return cookies.map(_cookieNameAndValue).join('\n'); +} + // Output Formats: // - 512 → "512 B" // - 2000 → "2.0 kB" diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md index 7d47c6b982a..cf0b748fe49 100644 --- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md +++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md @@ -42,7 +42,12 @@ TODO: Remove this section if there are not any updates. ## Network profiler updates -TODO: Remove this section if there are not any updates. +* Added copy buttons to the Headers and Cookies tabs of the request + inspector, and made cookie values and image metadata selectable. + [#10052](https://github.com/flutter/devtools/pull/10052) +* Fixed the `headersSize` reported in HAR exports for requests with + multi-value headers. + [#10052](https://github.com/flutter/devtools/pull/10052) ## Logging updates 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 2373bb6c18f..38184183e53 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 @@ -12,6 +12,7 @@ import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as image; import 'package:material_ui/material_ui.dart'; import 'package:vm_service/vm_service.dart'; @@ -355,5 +356,122 @@ void main() { await tester.pumpAndSettle(const Duration(seconds: 1)); }, ); + + group('copy headers and cookies', () { + Future pumpInspector(WidgetTester tester) async { + await tester.pumpWidget( + wrapWithControllers( + const NetworkRequestInspector(), + network: NetworkController(), + debugger: createMockDebuggerControllerWithDefaults(), + ), + ); + screenControllers.lookup().selectedRequest.value = + httpGetWithCookies; + await tester.pumpAndSettle(); + } + + /// Stops polling so no timers are pending when the test is disposed. + Future stopPolling(WidgetTester tester) async { + await screenControllers.lookup().stopRecording(); + await tester.pumpAndSettle(const Duration(seconds: 1)); + } + + testWidgets('copies all headers from the Headers tab', (tester) async { + await pumpInspector(tester); + await tester.tap(find.text('Headers')); + await tester.pumpAndSettle(); + + expect(clipboardContents, isEmpty); + await tester.tap(find.byType(CopyToClipboardControl)); + + expect( + clipboardContents, + HttpRequestHeadersView.copyText(httpGetWithCookies), + ); + expect( + clipboardContents, + allOf( + startsWith('General\nmethod: GET\n'), + contains( + '\n\nResponse Headers\n' + 'content-type: text/plain\n' + 'set-cookie: sid=1; Path=/; HttpOnly\n' + 'set-cookie: theme=dark; Expires=Wed, 21 Oct 2026 07:28:00 GMT; ' + 'SameSite=Lax', + ), + endsWith( + '\n\nRequest Headers\naccept: text/html, application/json', + ), + ), + ); + + await stopPolling(tester); + }); + + testWidgets('copies all cookies from the Cookies tab', (tester) async { + await pumpInspector(tester); + await tester.tap(find.text('Cookies')); + await tester.pumpAndSettle(); + + expect(clipboardContents, isEmpty); + await tester.tap(find.byType(CopyToClipboardControl)); + + expect( + clipboardContents, + 'Response Cookies\n' + 'sid=1; Path=/; HttpOnly\n' + 'theme=dark; Expires=Wed, 21 Oct 2026 07:28:00 GMT; SameSite=Lax\n' + '\n' + 'Request Cookies\n' + 'a=b; c=d', + ); + + await stopPolling(tester); + }); + + testWidgets('cookie values are selectable', (tester) async { + await pumpInspector(tester); + await tester.tap(find.text('Cookies')); + await tester.pumpAndSettle(); + + expect( + find.ancestor( + of: find.byKey(HttpRequestCookiesView.responseCookiesKey), + matching: find.byType(SelectionArea), + ), + findsOneWidget, + ); + + await stopPolling(tester); + }); + }); + + testWidgets('image metadata is selectable', (tester) async { + final pngBytes = image.encodePng(image.Image(width: 1, height: 1)); + final imageJson = Map.of(httpGetWithCookiesJson) + ..['response'] = { + ...httpGetWithCookiesJson['response'] as Map, + 'headers': { + 'content-type': ['image/png'], + }, + 'cookies': [], + } + ..['responseBody'] = pngBytes; + final imageRequest = DartIOHttpRequestData( + HttpProfileRequest.parse(imageJson)!, + requestFullDataFromVmService: false, + ); + + await tester.pumpWidget(wrap(ImageResponseView(imageRequest))); + + expect( + find.ancestor( + of: find.text('Dimensions: '), + matching: find.byType(SelectionArea), + ), + findsOneWidget, + ); + }); }); } diff --git a/packages/devtools_app/test/shared/http/http_utils_test.dart b/packages/devtools_app/test/shared/http/http_utils_test.dart index 94853734b4d..ed0cf4d6f91 100644 --- a/packages/devtools_app/test/shared/http/http_utils_test.dart +++ b/packages/devtools_app/test/shared/http/http_utils_test.dart @@ -1,7 +1,126 @@ import 'package:devtools_app/src/screens/network/utils/http_utils.dart'; +import 'package:devtools_app/src/shared/http/http.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + group('calculateHeadersSize', () { + test('joins JSON list values without brackets', () { + // Header values decoded from JSON are List, not List. + final headers = { + 'accept': ['a', 'b'], + }; + // 'accept: a, b\r\n' + '\r\n' + expect(calculateHeadersSize(headers), 16); + }); + + test('counts each set-cookie value as its own line', () { + final headers = { + 'set-cookie': ['a=1', 'b=2'], + }; + // 'set-cookie: a=1\r\n' * 2 + '\r\n' + expect(calculateHeadersSize(headers), 36); + }); + + test('returns -1 for null headers', () { + expect(calculateHeadersSize(null), -1); + }); + }); + + group('headersToText', () { + test('formats headers as key: value lines', () { + final headers = { + 'content-type': ['application/json'], + 'content-length': [15], + 'accept': ['text/html', 'application/json'], + 'statusCode': 200, + }; + expect( + headersToText(headers), + 'content-type: application/json\n' + 'content-length: 15\n' + 'accept: text/html, application/json\n' + 'statusCode: 200', + ); + }); + + test('puts each set-cookie value on its own line', () { + final headers = { + 'set-cookie': [ + 'sid=1; Path=/', + 'theme=dark; Expires=Wed, 21 Oct 2026 07:28:00 GMT', + ], + }; + expect( + headersToText(headers), + 'set-cookie: sid=1; Path=/\n' + 'set-cookie: theme=dark; Expires=Wed, 21 Oct 2026 07:28:00 GMT', + ); + }); + + test('joins cookie header values with a semicolon', () { + final headers = { + 'cookie': ['a=b', 'c=d'], + }; + expect(headersToText(headers), 'cookie: a=b; c=d'); + }); + + test('treats any iterable value as a multi-value header', () { + final headers = { + 'accept': {'text/html', 'application/json'}, + }; + expect(headersToText(headers), 'accept: text/html, application/json'); + }); + + test('handles null and empty headers', () { + expect(headersToText(null), isEmpty); + expect(headersToText({}), isEmpty); + }); + }); + + group('generalInfoToText', () { + test('formats values as they are displayed', () { + final info = { + 'method': 'GET', + 'redirects': [], + 'isRedirect': false, + }; + expect( + generalInfoToText(info), + 'method: GET\nredirects: []\nisRedirect: false', + ); + }); + }); + + group('requestCookiesToText', () { + test('formats cookies as a Cookie header value', () { + final cookies = [ + Cookie.fromSetCookieValue('a=b'), + Cookie.fromSetCookieValue('c=d'), + ]; + expect(requestCookiesToText(cookies), 'a=b; c=d'); + }); + }); + + group('responseCookiesToText', () { + final cookies = [Cookie.fromSetCookieValue('sid=1; Path=/; Max-Age=60')]; + + test('uses the raw set-cookie header values', () { + final headers = { + 'Set-Cookie': ['sid=1; Path=/; Max-Age=60'], + }; + expect( + responseCookiesToText(cookies, headers), + 'sid=1; Path=/; Max-Age=60', + ); + }); + + test('falls back to name=value without a set-cookie header', () { + expect(responseCookiesToText(cookies, null), 'sid=1'); + expect(responseCookiesToText(cookies, {}), 'sid=1'); + expect(responseCookiesToText(cookies, {'set-cookie': null}), 'sid=1'); + }); + }); + group('formatBytes', () { // Verifies correct formatting across different unit ranges. test('formats bytes correctly', () { diff --git a/packages/devtools_app/test/test_infra/test_data/network.dart b/packages/devtools_app/test/test_infra/test_data/network.dart index 7509af45cae..ae01bcbaab9 100644 --- a/packages/devtools_app/test/test_infra/test_data/network.dart +++ b/packages/devtools_app/test/test_infra/test_data/network.dart @@ -244,6 +244,74 @@ final httpPostJson = { 'requestBody': httpPostRequestBodyData, 'responseBody': httpPostResponseBodyData, }; +final httpGetWithCookiesRequest = HttpProfileRequest.parse( + httpGetWithCookiesJson, +)!; +final httpGetWithCookies = DartIOHttpRequestData( + httpGetWithCookiesRequest, + requestFullDataFromVmService: false, +); +final httpGetWithCookiesJson = { + 'type': 'HttpProfileRequest', + 'id': '3', + 'isolateId': 'isolates/2013291945734727', + 'method': 'GET', + 'uri': 'https://example.com/account', + 'events': [ + {'timestamp': 6326808941, 'event': 'Connection established'}, + {'timestamp': 6326808965, 'event': 'Request sent'}, + ], + 'startTime': 6326279935, + 'endTime': 6326808974, + 'request': { + 'headers': { + 'accept': ['text/html', 'application/json'], + }, + 'connectionInfo': { + 'localPort': 45648, + 'remoteAddress': '93.184.216.34', + 'remotePort': 443, + }, + 'contentLength': 0, + 'cookies': ['a=b', 'c=d'], + 'followRedirects': true, + 'maxRedirects': 5, + 'method': 'GET', + 'persistentConnection': true, + 'uri': 'https://example.com/account', + 'filterKey': 'HTTP/client', + }, + 'response': { + 'startTime': 6327090749, + 'headers': { + 'content-type': ['text/plain'], + 'set-cookie': [ + 'sid=1; Path=/; HttpOnly', + 'theme=dark; Expires=Wed, 21 Oct 2026 07:28:00 GMT; SameSite=Lax', + ], + }, + 'compressionState': 'HttpClientResponseCompressionState.notCompressed', + 'connectionInfo': { + 'localPort': 45648, + 'remoteAddress': '93.184.216.34', + 'remotePort': 443, + }, + 'contentLength': 2, + 'cookies': [ + 'sid=1; Path=/; HttpOnly', + 'theme=dark; Expires=Wed, 21 Oct 2026 07:28:00 GMT; SameSite=Lax', + ], + 'isRedirect': false, + 'persistentConnection': true, + 'reasonPhrase': 'OK', + 'redirects': [], + 'statusCode': 200, + 'endTime': 6327091628, + }, + 'requestBody': [], + 'responseBody': [111, 107], +}; + final httpPostRequestBodyData = [ ...[123, 10, 32, 34, 116, 105, 116, 108, 101, 34, 58, 32, 34, 102, 111, 111], ...[34, 44, 32, 34, 98, 111, 100, 121, 34, 58, 32, 34, 98, 97, 114, 34],