Skip to content

Commit a71efc5

Browse files
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
1 parent 295d4b4 commit a71efc5

1 file changed

Lines changed: 187 additions & 0 deletions

File tree

‎packages/devtools_app/test/http/curl_command_test.dart‎

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import 'dart:typed_data';
77

88
import 'package:devtools_app/devtools_app.dart';
99
import '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';
1012
import 'package:flutter_test/flutter_test.dart';
1113
import '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+
280467
class _TestDartIOHttpRequestData extends DartIOHttpRequestData {
281468
_TestDartIOHttpRequestData(this._request) : super(_request);
282469

0 commit comments

Comments
 (0)