@@ -7,6 +7,8 @@ import 'dart:typed_data';
77
88import 'package:devtools_app/devtools_app.dart' ;
99import 'package:devtools_app/src/shared/http/curl_command.dart' ;
10+ import 'package:devtools_app_shared/utils.dart' ;
11+ import 'package:devtools_test/devtools_test.dart' ;
1012import 'package:flutter_test/flutter_test.dart' ;
1113import 'package:vm_service/vm_service.dart' ;
1214
@@ -275,8 +277,193 @@ void main() {
275277 expect (CurlCommand .from (data).toString (), contains ("--data-raw '{}'" ));
276278 });
277279 });
280+
281+ // Regression coverage for the request body across the lifecycle of a
282+ // request in the Network tab. The body is known once the request has been
283+ // sent, so it must be available for Copy as cURL whether the request is
284+ // still awaiting its response, completed, or failed, and it must survive the
285+ // profile refreshes that replace the underlying request while it is pending.
286+ // See https://github.com/flutter/devtools/pull/9963.
287+ group ('NetworkCurlCommand request body lifecycle' , () {
288+ const body = '{"email":"user@example.com"}' ;
289+ const curlWithHeaderAndBody =
290+ "curl --location --request POST 'https://example.com/api/login' "
291+ "\\\n --header 'content-type: application/json' "
292+ "\\\n --data-raw '$body '" ;
293+
294+ test ('includes body for a pending request awaiting its response' , () {
295+ final data = DartIOHttpRequestData (
296+ _parseProfileRequest (
297+ requestSent: true ,
298+ response: null ,
299+ requestBody: utf8.encode (body),
300+ ),
301+ requestFullDataFromVmService: false ,
302+ );
303+
304+ expect (data.inProgress, isTrue);
305+ expect (data.requestBody, body);
306+ expect (CurlCommand .from (data).toString (), curlWithHeaderAndBody);
307+ });
308+
309+ test ('includes body for a completed request' , () {
310+ final data = DartIOHttpRequestData (
311+ _parseProfileRequest (
312+ requestSent: true ,
313+ response: _completedResponseJson,
314+ requestBody: utf8.encode (body),
315+ ),
316+ requestFullDataFromVmService: false ,
317+ );
318+
319+ expect (data.inProgress, isFalse);
320+ expect (data.requestBody, body);
321+ expect (CurlCommand .from (data).toString (), curlWithHeaderAndBody);
322+ });
323+
324+ test ('includes body for a request that failed without a response' , () {
325+ final data = DartIOHttpRequestData (
326+ _parseProfileRequest (
327+ requestSent: true ,
328+ requestError: 'Connection timed out' ,
329+ response: null ,
330+ requestBody: utf8.encode (body),
331+ ),
332+ requestFullDataFromVmService: false ,
333+ );
334+
335+ expect (data.didFail, isTrue);
336+ expect (data.requestBody, body);
337+ expect (CurlCommand .from (data).toString (), contains ("--data-raw '$body '" ));
338+ });
339+
340+ // These tests exercise `getFullRequestData`, which fetches the body from
341+ // the VM service when a request is selected, followed by `merge` calls
342+ // that simulate `getHttpProfile` polling.
343+ group ('with VM service' , () {
344+ tearDown (() => removeGlobal (ServiceConnectionManager ));
345+
346+ test (
347+ 'retains body fetched while pending across profile refreshes' ,
348+ () async {
349+ _serveFullRequestFromVmService (
350+ _parseProfileRequest (
351+ requestSent: true ,
352+ response: null ,
353+ requestBody: utf8.encode (body),
354+ ),
355+ );
356+
357+ // Entries from `getHttpProfile` polling never carry bodies.
358+ final data = DartIOHttpRequestData (
359+ _parseProfileRequest (requestSent: true , response: null ),
360+ requestFullDataFromVmService: false ,
361+ );
362+
363+ // Selecting the request in the Network tab fetches its full data.
364+ await data.getFullRequestData ();
365+ expect (data.inProgress, isTrue);
366+ expect (data.requestBody, body);
367+ expect (CurlCommand .from (data).toString (), curlWithHeaderAndBody);
368+
369+ // The next poll replaces the profile entry while still pending.
370+ data.merge (
371+ DartIOHttpRequestData (
372+ _parseProfileRequest (requestSent: true , response: null ),
373+ requestFullDataFromVmService: false ,
374+ ),
375+ );
376+ expect (data.inProgress, isTrue);
377+ expect (data.requestBody, body);
378+ expect (CurlCommand .from (data).toString (), curlWithHeaderAndBody);
379+
380+ // The response eventually completes.
381+ data.merge (
382+ DartIOHttpRequestData (
383+ _parseProfileRequest (
384+ requestSent: true ,
385+ response: _completedResponseJson,
386+ ),
387+ requestFullDataFromVmService: false ,
388+ ),
389+ );
390+ expect (data.inProgress, isFalse);
391+ expect (data.requestBody, body);
392+ expect (CurlCommand .from (data).toString (), curlWithHeaderAndBody);
393+ },
394+ );
395+ });
396+ });
397+ }
398+
399+ /// Sets up a fake VM service whose `getHttpProfileRequest` returns [request] .
400+ void _serveFullRequestFromVmService (HttpProfileRequest request) {
401+ setGlobal (
402+ ServiceConnectionManager ,
403+ FakeServiceConnectionManager (
404+ service: FakeServiceManager .createFakeService (
405+ httpProfile: HttpProfile (
406+ requests: [request],
407+ timestamp: DateTime .fromMicrosecondsSinceEpoch (0 ),
408+ ),
409+ ),
410+ ),
411+ );
278412}
279413
414+ /// Parses an [HttpProfileRequest] shaped like the dart:io HTTP profiler JSON.
415+ ///
416+ /// dart:io only reports `endTime` and `request` once the request has been
417+ /// fully sent ([requestSent] ), and `response` once a response starts.
418+ HttpProfileRequest _parseProfileRequest ({
419+ required bool requestSent,
420+ required Map <String , Object ?>? response,
421+ String ? requestError,
422+ List <int >? requestBody,
423+ }) {
424+ return HttpProfileRequest .parse ({
425+ 'id' : '1' ,
426+ 'isolateId' : 'isolates/0' ,
427+ 'method' : 'POST' ,
428+ 'uri' : 'https://example.com/api/login' ,
429+ 'events' : < Object > [],
430+ 'startTime' : 0 ,
431+ if (requestSent) ...{
432+ 'endTime' : 1000 ,
433+ 'request' : requestError != null
434+ ? {'error' : requestError}
435+ : {
436+ 'headers' : {
437+ 'content-type' : ['application/json' ],
438+ },
439+ 'connectionInfo' : < String , Object ? > {},
440+ 'contentLength' : requestBody? .length ?? 0 ,
441+ 'cookies' : < Object > [],
442+ 'followRedirects' : true ,
443+ 'maxRedirects' : 5 ,
444+ 'persistentConnection' : true ,
445+ },
446+ },
447+ 'response' : ? response,
448+ 'requestBody' : ? requestBody,
449+ })! ;
450+ }
451+
452+ const _completedResponseJson = < String , Object ? > {
453+ 'startTime' : 2000 ,
454+ 'endTime' : 3000 ,
455+ 'headers' : < String , Object ? > {},
456+ 'compressionState' : 'notCompressed' ,
457+ 'connectionInfo' : < String , Object ? > {},
458+ 'contentLength' : 0 ,
459+ 'cookies' : < Object > [],
460+ 'isRedirect' : false ,
461+ 'persistentConnection' : true ,
462+ 'reasonPhrase' : 'OK' ,
463+ 'redirects' : < Object > [],
464+ 'statusCode' : 200 ,
465+ };
466+
280467class _TestDartIOHttpRequestData extends DartIOHttpRequestData {
281468 _TestDartIOHttpRequestData (this ._request) : super (_request);
282469
0 commit comments