test: add request body lifecycle tests for Copy as cURL (#10020) * 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. * docs: link release note to #10020
diff --git a/packages/devtools_app/test/http/curl_command_test.dart b/packages/devtools_app/test/http/curl_command_test.dart index e23825c..9cee579 100644 --- a/packages/devtools_app/test/http/curl_command_test.dart +++ b/packages/devtools_app/test/http/curl_command_test.dart
@@ -7,6 +7,8 @@ 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'; @@ -275,8 +277,193 @@ expect(CurlCommand.from(data).toString(), contains("--data-raw '{}'")); }); }); + + // Regression coverage for the request body across the lifecycle of a + // request in the Network tab. The body is known once the request has been + // sent, so it must be available for Copy as cURL whether the request is + // still awaiting its response, completed, or failed, and it must survive the + // profile refreshes that replace the underlying request while it is pending. + // See https://github.com/flutter/devtools/pull/9963. + 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('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'")); + }); + + // These tests exercise `getFullRequestData`, which fetches the body from + // the VM service when a request is selected, followed by `merge` calls + // that simulate `getHttpProfile` polling. + 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); + }, + ); + }); + }); } +/// 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<String, Object?>? response, + String? requestError, + List<int>? requestBody, +}) { + return HttpProfileRequest.parse({ + 'id': '1', + 'isolateId': 'isolates/0', + 'method': 'POST', + 'uri': 'https://example.com/api/login', + 'events': <Object>[], + 'startTime': 0, + if (requestSent) ...{ + 'endTime': 1000, + 'request': requestError != null + ? {'error': requestError} + : { + 'headers': { + 'content-type': ['application/json'], + }, + 'connectionInfo': <String, Object?>{}, + 'contentLength': requestBody?.length ?? 0, + 'cookies': <Object>[], + 'followRedirects': true, + 'maxRedirects': 5, + 'persistentConnection': true, + }, + }, + 'response': ?response, + 'requestBody': ?requestBody, + })!; +} + +const _completedResponseJson = <String, Object?>{ + 'startTime': 2000, + 'endTime': 3000, + 'headers': <String, Object?>{}, + 'compressionState': 'notCompressed', + 'connectionInfo': <String, Object?>{}, + 'contentLength': 0, + 'cookies': <Object>[], + 'isRedirect': false, + 'persistentConnection': true, + 'reasonPhrase': 'OK', + 'redirects': <Object>[], + 'statusCode': 200, +}; + class _TestDartIOHttpRequestData extends DartIOHttpRequestData { _TestDartIOHttpRequestData(this._request) : super(_request);