diff --git a/packages/devtools_app/test/http/curl_command_test.dart b/packages/devtools_app/test/http/curl_command_test.dart index e23825c83a3..9cee579842e 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 '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'; @@ -275,8 +277,193 @@ void main() { 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? 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);