From aa3d173c3593d3167b3833951f70c4f35eecf57e Mon Sep 17 00:00:00 2001 From: Sam Rawlins Date: Mon, 14 Jul 2025 11:07:47 -0700 Subject: [PATCH 1/3] Use service extension in networking test --- .../live_connection/network_screen_test.dart | 124 ++++++++++-------- .../test_infra/run/_test_app_driver.dart | 75 +++-------- .../test_infra/run/run_test.dart | 5 +- .../fixtures/networking_app/bin/main.dart | 101 +++++++------- .../lib/src/template/devtools_extension.dart | 2 +- .../integration_test_utils.dart | 9 +- 6 files changed, 152 insertions(+), 164 deletions(-) diff --git a/packages/devtools_app/integration_test/test/live_connection/network_screen_test.dart b/packages/devtools_app/integration_test/test/live_connection/network_screen_test.dart index 351cd512574..9d03f13a6ae 100644 --- a/packages/devtools_app/integration_test/test/live_connection/network_screen_test.dart +++ b/packages/devtools_app/integration_test/test/live_connection/network_screen_test.dart @@ -10,7 +10,6 @@ import 'package:devtools_app/src/shared/table/table.dart' show DevToolsTable; import 'package:devtools_test/helpers.dart'; import 'package:devtools_test/integration_test.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:http/http.dart' as http; import 'package:integration_test/integration_test.dart'; // To run: @@ -28,63 +27,66 @@ void main() { tearDown(() async { await resetHistory(); - await http.get(Uri.parse('http://localhost:${testApp.controlPort}/exit/')); }); - testWidgets('nnn', (tester) async { - await pumpAndConnectDevTools(tester, testApp); - await _prepareNetworkScreen(tester); - - final helper = _NetworkScreenHelper(tester, testApp.controlPort!); - - // Instruct the app to make a GET request via the dart:io HttpClient. - await helper.triggerRequest('get/'); - _expectInRequestTable('GET'); - await helper.clear(); - - // Instruct the app to make a POST request via the dart:io HttpClient. - await helper.triggerRequest('post/'); - _expectInRequestTable('POST'); - await helper.clear(); - - // Instruct the app to make a PUT request via the dart:io HttpClient. - await helper.triggerRequest('put/'); - _expectInRequestTable('PUT'); - await helper.clear(); - - // Instruct the app to make a DELETE request via the dart:io HttpClient. - await helper.triggerRequest('delete/'); - _expectInRequestTable('DELETE'); - await helper.clear(); - - // Instruct the app to make a GET request via the 'http' package. - await helper.triggerRequest('packageHttp/get/'); - _expectInRequestTable('GET'); - await helper.clear(); - - // Instruct the app to make a POST request via the 'http' package. - await helper.triggerRequest('packageHttp/post/'); - _expectInRequestTable('POST'); - await helper.clear(); - - // Instruct the app to make a GET request via Dio. - await helper.triggerRequest('dio/get/'); - _expectInRequestTable('GET'); - await helper.clear(); - - // Instruct the app to make a POST request via Dio. - await helper.triggerRequest('dio/post/'); - _expectInRequestTable('POST'); - }); + testWidgets( + 'network screen test', + timeout: const Timeout(Duration(minutes: 3)), + (tester) async { + await pumpAndConnectDevTools(tester, testApp); + await _prepareNetworkScreen(tester); + + final helper = _NetworkScreenHelper(tester); + + // Instruct the app to make a GET request via the dart:io HttpClient. + await helper.triggerRequest('get'); + _expectInRequestTable('GET'); + await helper.clear(); + + // Instruct the app to make a POST request via the dart:io HttpClient. + await helper.triggerRequest('post'); + _expectInRequestTable('POST'); + await helper.clear(); + + // Instruct the app to make a PUT request via the dart:io HttpClient. + await helper.triggerRequest('put'); + _expectInRequestTable('PUT'); + await helper.clear(); + + // Instruct the app to make a DELETE request via the dart:io HttpClient. + await helper.triggerRequest('delete'); + _expectInRequestTable('DELETE'); + await helper.clear(); + + // Instruct the app to make a GET request via the 'http' package. + await helper.triggerRequest('packageHttpGet'); + _expectInRequestTable('GET'); + await helper.clear(); + + // Instruct the app to make a POST request via the 'http' package. + await helper.triggerRequest('packageHttpPost'); + _expectInRequestTable('POST'); + await helper.clear(); + + // Instruct the app to make a GET request via Dio. + await helper.triggerRequest('dioGet'); + _expectInRequestTable('GET'); + await helper.clear(); + + // Instruct the app to make a POST request via Dio. + await helper.triggerRequest('dioPost'); + _expectInRequestTable('POST'); + + await helper.triggerExit(); + }, + ); } final class _NetworkScreenHelper { - _NetworkScreenHelper(this._tester, this._controlPort); + _NetworkScreenHelper(this._tester); final WidgetTester _tester; - final int _controlPort; - Future clear() async { // Press the 'Clear' button between tests. await _tester.tap(find.text('Clear')); @@ -95,8 +97,26 @@ final class _NetworkScreenHelper { ); } - Future triggerRequest(String path) async { - await http.get(Uri.parse('http://localhost:$_controlPort/$path')); + Future triggerExit() async { + final response = await serviceConnection.serviceManager + .callServiceExtensionOnMainIsolate('ext.networking_app.exit'); + logStatus(response.toString()); + + await Future.delayed(const Duration(milliseconds: 200)); + await _tester.pump(safePumpDuration); + } + + Future triggerRequest( + String requestType, { + bool hasBody = false, + }) async { + final response = await serviceConnection.serviceManager + .callServiceExtensionOnMainIsolate( + 'ext.networking_app.makeRequest', + args: {'requestType': requestType, 'hasBody': hasBody}, + ); + logStatus(response.toString()); + await Future.delayed(const Duration(milliseconds: 200)); await _tester.pump(safePumpDuration); } diff --git a/packages/devtools_app/integration_test/test_infra/run/_test_app_driver.dart b/packages/devtools_app/integration_test/test_infra/run/_test_app_driver.dart index 21305bf5624..e7e446ee7fe 100644 --- a/packages/devtools_app/integration_test/test_infra/run/_test_app_driver.dart +++ b/packages/devtools_app/integration_test/test_infra/run/_test_app_driver.dart @@ -196,10 +196,6 @@ class TestDartCliApp extends IntegrationTestApp { : super(appPath, TestAppDevice.cli); static const vmServicePrefix = 'The Dart VM service is listening on '; - static const controlPortKey = 'controlPort'; - - int? get controlPort => _controlPort; - late final int? _controlPort; @override Future startProcess() async { @@ -216,75 +212,44 @@ class TestDartCliApp extends IntegrationTestApp { @override Future waitForAppStart() async { - final vmServiceUriString = await _waitFor( - message: vmServicePrefix, - timeout: IntegrationTestApp._appStartTimeout, - ); + final vmServiceUriString = await _waitForVmServicePrefix(); final vmServiceUri = Uri.parse(vmServiceUriString); - _controlPort = await _waitFor( - message: controlPortKey, - timeout: const Duration(seconds: 1), - optional: true, - ); // Map to WS URI. _vmServiceWsUri = convertToWebSocketUrl(serviceProtocolUrl: vmServiceUri); } - /// Waits for [message] to appear on stdout. + /// Waits for [vmServicePrefix] to appear on stdout. /// - /// After [timeout], if no such message has appeared, then either `null` is - /// returned, if [optional] is `true`, or an exception is thrown, if - /// [optional] is `false`. - Future _waitFor({ - required String message, - Duration? timeout, - bool optional = false, - }) { - final response = Completer(); + /// After a timeout, if no such message has appeared, then an exception is + /// thrown. + Future _waitForVmServicePrefix() { + final response = Completer(); late StreamSubscription sub; sub = stdoutController.stream.listen( - (String line) => _handleStdout( - line, - subscription: sub, - response: response, - message: message, - ), + (String line) => + _handleStdout(line, subscription: sub, response: response), ); - if (optional) { - return response.future - .timeout( - timeout ?? IntegrationTestApp._defaultTimeout, - onTimeout: () => null as T, - ) - .whenComplete(() => sub.cancel()); - } - - return _timeoutWithMessages( + return _timeoutWithMessages( () => response.future, - timeout: timeout, - message: 'Did not receive expected message: $message.', + timeout: IntegrationTestApp._appStartTimeout, + message: 'Did not receive expected message: $vmServicePrefix.', ).whenComplete(() => sub.cancel()); } - void _handleStdout( + void _handleStdout( String line, { required StreamSubscription subscription, - required Completer response, - required String message, + required Completer response, }) async { - if (message == vmServicePrefix && line.startsWith(vmServicePrefix)) { - final vmServiceUri = line.substring( - line.indexOf(vmServicePrefix) + vmServicePrefix.length, - ); - await subscription.cancel(); - response.complete(vmServiceUri as T); - } else if (message == controlPortKey && line.contains(controlPortKey)) { - final asJson = jsonDecode(line) as Map; - await subscription.cancel(); - response.complete(asJson[controlPortKey] as T); - } + if (!line.startsWith(vmServicePrefix)) return; + + final vmServiceUri = line.substring( + line.indexOf(vmServicePrefix) + vmServicePrefix.length, + ); + await subscription.cancel(); + response.complete(vmServiceUri); } } diff --git a/packages/devtools_app/integration_test/test_infra/run/run_test.dart b/packages/devtools_app/integration_test/test_infra/run/run_test.dart index 094e237b781..5f7cba5846b 100644 --- a/packages/devtools_app/integration_test/test_infra/run/run_test.dart +++ b/packages/devtools_app/integration_test/test_infra/run/run_test.dart @@ -115,10 +115,7 @@ Future runFlutterIntegrationTest( // Run the flutter integration test. final testRunner = IntegrationTestRunner(); try { - final testArgs = { - if (!offline) 'service_uri': testAppUri, - if (testApp is TestDartCliApp) 'control_port': testApp.controlPort, - }; + final testArgs = {if (!offline) 'service_uri': testAppUri}; final testTarget = testRunnerArgs.testTarget!; debugLog('starting test run for $testTarget'); await testRunner.run( diff --git a/packages/devtools_app/test/test_infra/fixtures/networking_app/bin/main.dart b/packages/devtools_app/test/test_infra/fixtures/networking_app/bin/main.dart index 29c00d88ce1..470b495d96e 100644 --- a/packages/devtools_app/test/test_infra/fixtures/networking_app/bin/main.dart +++ b/packages/devtools_app/test/test_infra/fixtures/networking_app/bin/main.dart @@ -6,6 +6,7 @@ import 'dart:async'; import 'dart:convert' show json; +import 'dart:developer'; import 'dart:io' as io; import 'package:dio/dio.dart'; @@ -13,7 +14,54 @@ import 'package:http/http.dart' as http; void main() async { final testServer = await _bindTestServer(); - await _bindControlServer(testServer); + registerMakeRequestExtension(testServer); +} + +void registerMakeRequestExtension(io.HttpServer testServer) { + final client = _HttpClient(testServer.port); + registerExtension('ext.networking_app.makeRequest', (_, parameters) async { + final hasBody = bool.tryParse(parameters['hasBody'] ?? 'false') ?? false; + final requestType = parameters['requestType']; + if (requestType == null) { + return ServiceExtensionResponse.error( + ServiceExtensionResponse.invalidParams, + json.encode({'error': 'Missing "requestType" field'}), + ); + } + switch (requestType) { + case 'get': + client.get(); + case 'post': + client.post(hasBody: hasBody); + case 'put': + client.put(hasBody: hasBody); + case 'delete': + client.delete(hasBody: hasBody); + case 'dioGet': + client.dioGet(); + case 'dioPost': + client.dioPost(hasBody: hasBody); + case 'packageHttpGet': + client.packageHttpGet(hasBody: hasBody); + case 'packageHttpPost': + client.packageHttpPost(hasBody: hasBody); + case 'packageHttpPostStreamed': + client.packageHttpPostStreamed(); + default: + return ServiceExtensionResponse.error( + ServiceExtensionResponse.invalidParams, + json.encode({'error': 'Unknown requestType: "$requestType"'}), + ); + } + return ServiceExtensionResponse.result(json.encode({'type': 'success'})); + }); + + registerExtension('ext.networking_app.exit', (_, parameters) async { + unawaited( + Future.delayed(const Duration(milliseconds: 200)).then((_) => io.exit(0)), + ); + return ServiceExtensionResponse.result(json.encode({'type': 'success'})); + }); } /// Binds a "test" HTTP server to an available port. @@ -30,50 +78,6 @@ Future _bindTestServer() async { return server; } -/// Binds a "control" HTTP server to an available port. -/// -/// This server has an HTTP client, and can receive commands for that client to -/// send requests to the "test" HTTP server. -Future _bindControlServer(io.HttpServer testServer) async { - final client = _HttpClient(testServer.port); - - final server = await io.HttpServer.bind(io.InternetAddress.loopbackIPv4, 0); - print(json.encode({'controlPort': server.port})); - server.listen((request) async { - request.response.headers - ..add('Access-Control-Allow-Origin', '*') - ..add('Access-Control-Allow-Methods', 'POST,GET,DELETE,PUT,OPTIONS'); - final path = request.uri.path; - final hasBody = path.contains('/body/'); - request.response - ..statusCode = 200 - ..write('received request at: "$path"'); - - if (path.startsWith('/get/')) { - client.get(); - } else if (path.startsWith('/post/')) { - client.post(hasBody: hasBody); - } else if (path.startsWith('/put/')) { - client.put(hasBody: hasBody); - } else if (path.startsWith('/delete/')) { - client.delete(hasBody: hasBody); - } else if (path.startsWith('/dio/get/')) { - client.dioGet(); - } else if (path.startsWith('/dio/post/')) { - client.dioPost(hasBody: hasBody); - } else if (path.startsWith('/packageHttp/post/')) { - client.packageHttpPost(hasBody: hasBody); - } else if (path.startsWith('/packageHttp/postStreamed/')) { - client.packageHttpPostStreamed(); - } else if (path.startsWith('/exit/')) { - client.close(); - io.exit(0); - } - await request.response.close(); - }); - return server; -} - // TODO(https://github.com/flutter/devtools/issues/8223): Test support for // WebSockets. // TODO(https://github.com/flutter/devtools/issues/4829): Test support for the @@ -136,6 +140,13 @@ class _HttpClient { print('Received DELETE response: $response'); } + void packageHttpGet({bool hasBody = false}) async { + print('Sending package:http GET...'); + // No body. + final response = await http.get(_uri); + print('Received package:http GET response: $response'); + } + void packageHttpPost({bool hasBody = false}) async { print('Sending package:http POST...'); final response = await http.post( diff --git a/packages/devtools_extensions/lib/src/template/devtools_extension.dart b/packages/devtools_extensions/lib/src/template/devtools_extension.dart index 4c6ae020436..61c922c9cbf 100644 --- a/packages/devtools_extensions/lib/src/template/devtools_extension.dart +++ b/packages/devtools_extensions/lib/src/template/devtools_extension.dart @@ -89,7 +89,7 @@ T _accessGlobalOrThrow({required String globalName}) { throw StateError( "'$globalName' has not been initialized yet. You can only access " "'$globalName' below the 'DevToolsExtension' widget in the widget " - "tree, since it is initialized as part of the 'DevToolsExtension'" + "tree, since it is initialized as part of the 'DevToolsExtension' " "state's 'initState' lifecycle method.", ); } diff --git a/packages/devtools_test/lib/src/integration_test/integration_test_utils.dart b/packages/devtools_test/lib/src/integration_test/integration_test_utils.dart index 9074fb629c6..318dcef1944 100644 --- a/packages/devtools_test/lib/src/integration_test/integration_test_utils.dart +++ b/packages/devtools_test/lib/src/integration_test/integration_test_utils.dart @@ -118,15 +118,14 @@ Future disconnectFromTestApp(WidgetTester tester) async { } class TestApp { - TestApp._({required this.vmServiceUri, required this.controlPort}); + TestApp._({required this.vmServiceUri}); factory TestApp._fromJson(Map json) { final serviceUri = json[serviceUriKey] as String?; if (serviceUri == null) { throw Exception('Cannot create a TestApp with a null service uri.'); } - final controlPort = json[controlPortKey] as int?; - return TestApp._(vmServiceUri: serviceUri, controlPort: controlPort); + return TestApp._(vmServiceUri: serviceUri); } factory TestApp.fromEnvironment() { @@ -137,11 +136,7 @@ class TestApp { static const serviceUriKey = 'service_uri'; - static const controlPortKey = 'control_port'; - final String vmServiceUri; - - final int? controlPort; } Future verifyScreenshot( From 6f78d05bcfe5034b8161d625087221ee323ca4aa Mon Sep 17 00:00:00 2001 From: Sam Rawlins Date: Thu, 17 Jul 2025 10:34:43 -0700 Subject: [PATCH 2/3] nit --- .../test/test_infra/fixtures/networking_app/bin/main.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/devtools_app/test/test_infra/fixtures/networking_app/bin/main.dart b/packages/devtools_app/test/test_infra/fixtures/networking_app/bin/main.dart index 470b495d96e..7feb4b8662d 100644 --- a/packages/devtools_app/test/test_infra/fixtures/networking_app/bin/main.dart +++ b/packages/devtools_app/test/test_infra/fixtures/networking_app/bin/main.dart @@ -42,7 +42,7 @@ void registerMakeRequestExtension(io.HttpServer testServer) { case 'dioPost': client.dioPost(hasBody: hasBody); case 'packageHttpGet': - client.packageHttpGet(hasBody: hasBody); + client.packageHttpGet(); case 'packageHttpPost': client.packageHttpPost(hasBody: hasBody); case 'packageHttpPostStreamed': @@ -140,7 +140,7 @@ class _HttpClient { print('Received DELETE response: $response'); } - void packageHttpGet({bool hasBody = false}) async { + void packageHttpGet() async { print('Sending package:http GET...'); // No body. final response = await http.get(_uri); From d5cde19a9e8a4bd41bf0ae6bbb0b3b739492be5b Mon Sep 17 00:00:00 2001 From: Sam Rawlins Date: Mon, 21 Jul 2025 09:50:37 -0700 Subject: [PATCH 3/3] feedback --- .../live_connection/network_screen_test.dart | 99 +++++++++---------- .../fixtures/networking_app/bin/main.dart | 11 ++- 2 files changed, 56 insertions(+), 54 deletions(-) diff --git a/packages/devtools_app/integration_test/test/live_connection/network_screen_test.dart b/packages/devtools_app/integration_test/test/live_connection/network_screen_test.dart index 9d03f13a6ae..93dd9eb763b 100644 --- a/packages/devtools_app/integration_test/test/live_connection/network_screen_test.dart +++ b/packages/devtools_app/integration_test/test/live_connection/network_screen_test.dart @@ -29,57 +29,53 @@ void main() { await resetHistory(); }); - testWidgets( - 'network screen test', - timeout: const Timeout(Duration(minutes: 3)), - (tester) async { - await pumpAndConnectDevTools(tester, testApp); - await _prepareNetworkScreen(tester); - - final helper = _NetworkScreenHelper(tester); - - // Instruct the app to make a GET request via the dart:io HttpClient. - await helper.triggerRequest('get'); - _expectInRequestTable('GET'); - await helper.clear(); - - // Instruct the app to make a POST request via the dart:io HttpClient. - await helper.triggerRequest('post'); - _expectInRequestTable('POST'); - await helper.clear(); - - // Instruct the app to make a PUT request via the dart:io HttpClient. - await helper.triggerRequest('put'); - _expectInRequestTable('PUT'); - await helper.clear(); - - // Instruct the app to make a DELETE request via the dart:io HttpClient. - await helper.triggerRequest('delete'); - _expectInRequestTable('DELETE'); - await helper.clear(); - - // Instruct the app to make a GET request via the 'http' package. - await helper.triggerRequest('packageHttpGet'); - _expectInRequestTable('GET'); - await helper.clear(); - - // Instruct the app to make a POST request via the 'http' package. - await helper.triggerRequest('packageHttpPost'); - _expectInRequestTable('POST'); - await helper.clear(); - - // Instruct the app to make a GET request via Dio. - await helper.triggerRequest('dioGet'); - _expectInRequestTable('GET'); - await helper.clear(); - - // Instruct the app to make a POST request via Dio. - await helper.triggerRequest('dioPost'); - _expectInRequestTable('POST'); - - await helper.triggerExit(); - }, - ); + testWidgets('network screen test', timeout: mediumTimeout, (tester) async { + await pumpAndConnectDevTools(tester, testApp); + await _prepareNetworkScreen(tester); + + final helper = _NetworkScreenHelper(tester); + + // Instruct the app to make a GET request via the dart:io HttpClient. + await helper.triggerRequest('get'); + _expectInRequestTable('GET'); + await helper.clear(); + + // Instruct the app to make a POST request via the dart:io HttpClient. + await helper.triggerRequest('post'); + _expectInRequestTable('POST'); + await helper.clear(); + + // Instruct the app to make a PUT request via the dart:io HttpClient. + await helper.triggerRequest('put'); + _expectInRequestTable('PUT'); + await helper.clear(); + + // Instruct the app to make a DELETE request via the dart:io HttpClient. + await helper.triggerRequest('delete'); + _expectInRequestTable('DELETE'); + await helper.clear(); + + // Instruct the app to make a GET request via the 'http' package. + await helper.triggerRequest('packageHttpGet'); + _expectInRequestTable('GET'); + await helper.clear(); + + // Instruct the app to make a POST request via the 'http' package. + await helper.triggerRequest('packageHttpPost'); + _expectInRequestTable('POST'); + await helper.clear(); + + // Instruct the app to make a GET request via Dio. + await helper.triggerRequest('dioGet'); + _expectInRequestTable('GET'); + await helper.clear(); + + // Instruct the app to make a POST request via Dio. + await helper.triggerRequest('dioPost'); + _expectInRequestTable('POST'); + + await helper.triggerExit(); + }); } final class _NetworkScreenHelper { @@ -117,7 +113,6 @@ final class _NetworkScreenHelper { ); logStatus(response.toString()); - await Future.delayed(const Duration(milliseconds: 200)); await _tester.pump(safePumpDuration); } } diff --git a/packages/devtools_app/test/test_infra/fixtures/networking_app/bin/main.dart b/packages/devtools_app/test/test_infra/fixtures/networking_app/bin/main.dart index 7feb4b8662d..73d1d30a4ab 100644 --- a/packages/devtools_app/test/test_infra/fixtures/networking_app/bin/main.dart +++ b/packages/devtools_app/test/test_infra/fixtures/networking_app/bin/main.dart @@ -14,10 +14,10 @@ import 'package:http/http.dart' as http; void main() async { final testServer = await _bindTestServer(); - registerMakeRequestExtension(testServer); + _registerMakeRequestExtension(testServer); } -void registerMakeRequestExtension(io.HttpServer testServer) { +void _registerMakeRequestExtension(io.HttpServer testServer) { final client = _HttpClient(testServer.port); registerExtension('ext.networking_app.makeRequest', (_, parameters) async { final hasBody = bool.tryParse(parameters['hasBody'] ?? 'false') ?? false; @@ -57,6 +57,13 @@ void registerMakeRequestExtension(io.HttpServer testServer) { }); registerExtension('ext.networking_app.exit', (_, parameters) async { + // This service extension needs to trigger `io.exit(0)`, and also return a + // value. (You might expect `Future.microtask(() => io.exit(0))` to be + // sufficient, but that results in DevTools erroring, saying that the + // connected app unxexpectedly disconnected; it seems that returning a value + // needs to work through some microtasks.) A 200 ms delay seems to work, so + // that the following `ServiceExtensionResponse` makes it all the way to + // DevTools, and _then_ we can exit. unawaited( Future.delayed(const Duration(milliseconds: 200)).then((_) => io.exit(0)), );