From 67871bd17413e0d1d5fbce77e0f2337b9bd9c036 Mon Sep 17 00:00:00 2001 From: prathamswe Date: Wed, 23 Sep 2026 13:46:30 +0530 Subject: [PATCH 1/2] fix: show request body for pending requests in Network tab The request body was hidden until the response completed, so the Request tab and Copy as cURL omitted it for requests still awaiting a response. Gate it on the request having been sent instead, and never cache or show a body fetched while the request was still being sent. --- .../src/shared/http/http_request_data.dart | 17 +- .../release_notes/NEXT_RELEASE_NOTES.md | 4 + .../test/http/curl_command_test.dart | 256 ++++++++++++++++++ 3 files changed, 275 insertions(+), 2 deletions(-) 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 619db21ec71..67c0109889b 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 @@ -89,6 +89,13 @@ class DartIOHttpRequestData extends NetworkRequest { bool isFetchingFullData = false; + /// Whether the last full data fetch happened before the request finished + /// sending, meaning its request body may have been truncated. + /// + /// While this is true, [requestBody] is hidden. Fetching the full data again + /// after the request has been sent (e.g. by re-selecting it) clears it. + bool _fetchedBeforeRequestSent = false; + Future getFullRequestData() async { try { if (isFetchingFullData) return; // We are already fetching @@ -110,7 +117,8 @@ class DartIOHttpRequestData extends NetworkRequest { } } - if (fullRequest.requestBody != null) { + _fetchedBeforeRequestSent = !fullRequest.isRequestComplete; + if (fullRequest.requestBody != null && !_fetchedBeforeRequestSent) { try { _requestBody = utf8.decode(fullRequest.requestBody!); } catch (_) { @@ -401,7 +409,12 @@ class DartIOHttpRequestData extends NetworkRequest { } final fullRequest = _request as HttpProfileRequest; try { - if (!_request.isResponseComplete) return null; + // The request body is fully known once the request has been sent (or + // has failed), so it does not need to wait for the response. This keeps + // it available for pending and failed requests, e.g. for Copy as cURL. + if (!_request.isRequestComplete || _fetchedBeforeRequestSent) { + return null; + } final acceptedMethods = {'POST', 'PUT', 'PATCH'}; if (!acceptedMethods.contains(_request.method)) return null; if (_requestBody != null) return _requestBody; diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md index 1e4cda3e2a4..9b415d736b4 100644 --- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md +++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md @@ -67,6 +67,10 @@ To learn more about DevTools, check out the * Fixed an issue where the Network tab would stop capturing new HTTP requests after pressing Clear while recording. - [#9856](https://github.com/flutter/devtools/pull/9856) +* Fixed the Request tab and Copy as cURL missing the body of HTTP requests + that are still waiting for a response. A request body is only shown once it + has been fully sent, so it is never shown truncated. - + [#TODO](https://github.com/flutter/devtools/pull/TODO) ## Logging updates diff --git a/packages/devtools_app/test/http/curl_command_test.dart b/packages/devtools_app/test/http/curl_command_test.dart index 81cda7cb8fc..2ce937eb136 100644 --- a/packages/devtools_app/test/http/curl_command_test.dart +++ b/packages/devtools_app/test/http/curl_command_test.dart @@ -2,10 +2,13 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. +import 'dart:convert'; import 'dart:typed_data'; import 'package:devtools_app/devtools_app.dart'; import 'package:devtools_app/src/shared/http/curl_command.dart'; +import 'package:devtools_app_shared/utils.dart'; +import 'package:devtools_test/devtools_test.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:vm_service/vm_service.dart'; @@ -206,8 +209,261 @@ void main() { ); }); }); + + group('NetworkCurlCommand request body lifecycle', () { + const body = '{"email":"user@example.com"}'; + const curlWithHeaderAndBody = + "curl --location --request POST 'https://example.com/api/login' " + "\\\n--header 'content-type: application/json' " + "\\\n--data-raw '$body'"; + + test('includes body for a pending request awaiting its response', () { + final data = DartIOHttpRequestData( + _parseProfileRequest( + requestSent: true, + response: null, + requestBody: utf8.encode(body), + ), + requestFullDataFromVmService: false, + ); + + expect(data.inProgress, isTrue); + expect(data.requestBody, body); + expect(CurlCommand.from(data).toString(), curlWithHeaderAndBody); + }); + + test('omits body while the request is still being sent', () { + final data = DartIOHttpRequestData( + _parseProfileRequest( + requestSent: false, + response: null, + requestBody: utf8.encode('{"email":'), + ), + requestFullDataFromVmService: false, + ); + + expect(data.inProgress, isTrue); + expect(data.requestBody, isNull); + expect( + CurlCommand.from(data).toString(), + "curl --location --request POST 'https://example.com/api/login'", + ); + }); + + test('includes body for a completed request', () { + final data = DartIOHttpRequestData( + _parseProfileRequest( + requestSent: true, + response: _completedResponseJson, + requestBody: utf8.encode(body), + ), + requestFullDataFromVmService: false, + ); + + expect(data.inProgress, isFalse); + expect(data.requestBody, body); + expect(CurlCommand.from(data).toString(), curlWithHeaderAndBody); + }); + + test('includes body for a request that failed without a response', () { + final data = DartIOHttpRequestData( + _parseProfileRequest( + requestSent: true, + requestError: 'Connection timed out', + response: null, + requestBody: utf8.encode(body), + ), + requestFullDataFromVmService: false, + ); + + expect(data.didFail, isTrue); + expect(data.requestBody, body); + expect(CurlCommand.from(data).toString(), contains("--data-raw '$body'")); + }); + + group('with VM service', () { + tearDown(() => removeGlobal(ServiceConnectionManager)); + + test( + 'retains body fetched while pending across profile refreshes', + () async { + _serveFullRequestFromVmService( + _parseProfileRequest( + requestSent: true, + response: null, + requestBody: utf8.encode(body), + ), + ); + + // Entries from `getHttpProfile` polling never carry bodies. + final data = DartIOHttpRequestData( + _parseProfileRequest(requestSent: true, response: null), + requestFullDataFromVmService: false, + ); + + // Selecting the request in the Network tab fetches its full data. + await data.getFullRequestData(); + expect(data.inProgress, isTrue); + expect(data.requestBody, body); + expect(CurlCommand.from(data).toString(), curlWithHeaderAndBody); + + // The next poll replaces the profile entry while still pending. + data.merge( + DartIOHttpRequestData( + _parseProfileRequest(requestSent: true, response: null), + requestFullDataFromVmService: false, + ), + ); + expect(data.inProgress, isTrue); + expect(data.requestBody, body); + expect(CurlCommand.from(data).toString(), curlWithHeaderAndBody); + + // The response eventually completes. + data.merge( + DartIOHttpRequestData( + _parseProfileRequest( + requestSent: true, + response: _completedResponseJson, + ), + requestFullDataFromVmService: false, + ), + ); + expect(data.inProgress, isFalse); + expect(data.requestBody, body); + expect(CurlCommand.from(data).toString(), curlWithHeaderAndBody); + }, + ); + + test( + 'never exposes a partial body when selected while still being sent', + () async { + // Selecting the request while its body is being written fetches a + // partial body. + _serveFullRequestFromVmService( + _parseProfileRequest( + requestSent: false, + response: null, + requestBody: utf8.encode('{"email":'), + ), + ); + final data = DartIOHttpRequestData( + _parseProfileRequest(requestSent: false, response: null), + requestFullDataFromVmService: false, + ); + await data.getFullRequestData(); + expect(data.requestBody, isNull); + expect(CurlCommand.from(data).toString(), isNot(contains('--data'))); + + // The request finishes sending. The next poll must not expose the + // partial body, or the empty body of the poll entry. + data.merge( + DartIOHttpRequestData( + _parseProfileRequest(requestSent: true, response: null), + requestFullDataFromVmService: false, + ), + ); + expect(data.requestBody, isNull); + expect(CurlCommand.from(data).toString(), isNot(contains('--data'))); + + // Selecting the request again fetches the complete body. + _serveFullRequestFromVmService( + _parseProfileRequest( + requestSent: true, + response: null, + requestBody: utf8.encode(body), + ), + ); + await data.getFullRequestData(); + expect(data.inProgress, isTrue); + expect(data.requestBody, body); + expect(CurlCommand.from(data).toString(), curlWithHeaderAndBody); + + // The response eventually completes. + data.merge( + DartIOHttpRequestData( + _parseProfileRequest( + requestSent: true, + response: _completedResponseJson, + ), + requestFullDataFromVmService: false, + ), + ); + expect(data.inProgress, isFalse); + expect(data.requestBody, body); + }, + ); + }); + }); } +/// Sets up a fake VM service whose `getHttpProfileRequest` returns [request]. +void _serveFullRequestFromVmService(HttpProfileRequest request) { + setGlobal( + ServiceConnectionManager, + FakeServiceConnectionManager( + service: FakeServiceManager.createFakeService( + httpProfile: HttpProfile( + requests: [request], + timestamp: DateTime.fromMicrosecondsSinceEpoch(0), + ), + ), + ), + ); +} + +/// Parses an [HttpProfileRequest] shaped like the dart:io HTTP profiler JSON. +/// +/// dart:io only reports `endTime` and `request` once the request has been +/// fully sent ([requestSent]), and `response` once a response starts. +HttpProfileRequest _parseProfileRequest({ + required bool requestSent, + required Map? response, + String? requestError, + List? requestBody, +}) { + return HttpProfileRequest.parse({ + 'id': '1', + 'isolateId': 'isolates/0', + 'method': 'POST', + 'uri': 'https://example.com/api/login', + 'events': [], + 'startTime': 0, + if (requestSent) ...{ + 'endTime': 1000, + 'request': requestError != null + ? {'error': requestError} + : { + 'headers': { + 'content-type': ['application/json'], + }, + 'connectionInfo': {}, + 'contentLength': requestBody?.length ?? 0, + 'cookies': [], + 'followRedirects': true, + 'maxRedirects': 5, + 'persistentConnection': true, + }, + }, + 'response': ?response, + 'requestBody': ?requestBody, + })!; +} + +const _completedResponseJson = { + 'startTime': 2000, + 'endTime': 3000, + 'headers': {}, + 'compressionState': 'notCompressed', + 'connectionInfo': {}, + 'contentLength': 0, + 'cookies': [], + 'isRedirect': false, + 'persistentConnection': true, + 'reasonPhrase': 'OK', + 'redirects': [], + 'statusCode': 200, +}; + class _TestDartIOHttpRequestData extends DartIOHttpRequestData { _TestDartIOHttpRequestData(this._request) : super(_request); From 2044da88aaac4bf832587185cafb1d736230815a Mon Sep 17 00:00:00 2001 From: prathamswe Date: Wed, 23 Sep 2026 13:46:30 +0530 Subject: [PATCH 2/2] docs: link release note to #10019 --- packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md index 9b415d736b4..e041ed85f87 100644 --- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md +++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md @@ -70,7 +70,7 @@ To learn more about DevTools, check out the * Fixed the Request tab and Copy as cURL missing the body of HTTP requests that are still waiting for a response. A request body is only shown once it has been fully sent, so it is never shown truncated. - - [#TODO](https://github.com/flutter/devtools/pull/TODO) + [#10019](https://github.com/flutter/devtools/pull/10019) ## Logging updates