Use service extension in networking integration test (#9323)

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 351cd51..93dd9eb 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_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,62 @@
 
   tearDown(() async {
     await resetHistory();
-    await http.get(Uri.parse('http://localhost:${testApp.controlPort}/exit/'));
   });
 
-  testWidgets('nnn', (tester) async {
+  testWidgets('network screen test', timeout: mediumTimeout, (tester) async {
     await pumpAndConnectDevTools(tester, testApp);
     await _prepareNetworkScreen(tester);
 
-    final helper = _NetworkScreenHelper(tester, testApp.controlPort!);
+    final helper = _NetworkScreenHelper(tester);
 
     // Instruct the app to make a GET request via the dart:io HttpClient.
-    await helper.triggerRequest('get/');
+    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/');
+    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/');
+    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/');
+    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/');
+    await helper.triggerRequest('packageHttpGet');
     _expectInRequestTable('GET');
     await helper.clear();
 
     // Instruct the app to make a POST request via the 'http' package.
-    await helper.triggerRequest('packageHttp/post/');
+    await helper.triggerRequest('packageHttpPost');
     _expectInRequestTable('POST');
     await helper.clear();
 
     // Instruct the app to make a GET request via Dio.
-    await helper.triggerRequest('dio/get/');
+    await helper.triggerRequest('dioGet');
     _expectInRequestTable('GET');
     await helper.clear();
 
     // Instruct the app to make a POST request via Dio.
-    await helper.triggerRequest('dio/post/');
+    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<void> clear() async {
     // Press the 'Clear' button between tests.
     await _tester.tap(find.text('Clear'));
@@ -95,11 +93,28 @@
     );
   }
 
-  Future<void> triggerRequest(String path) async {
-    await http.get(Uri.parse('http://localhost:$_controlPort/$path'));
+  Future<void> 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<void> 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 _tester.pump(safePumpDuration);
+  }
 }
 
 void _expectInRequestTable(String text) {
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 7f7a11f..cd3c843 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 @@
     : 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<void> startProcess() async {
@@ -216,75 +212,44 @@
 
   @override
   Future<void> 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<T> _waitFor<T>({
-    required String message,
-    Duration? timeout,
-    bool optional = false,
-  }) {
-    final response = Completer<T>();
+  /// After a timeout, if no such message has appeared, then an exception is
+  /// thrown.
+  Future<String> _waitForVmServicePrefix() {
+    final response = Completer<String>();
     late StreamSubscription<String> 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<T>(
+    return _timeoutWithMessages<String>(
       () => 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<T>(
+  void _handleStdout(
     String line, {
     required StreamSubscription<String> subscription,
-    required Completer<T> response,
-    required String message,
+    required Completer<String> 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 094e237..5f7cba5 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 @@
   // Run the flutter integration test.
   final testRunner = IntegrationTestRunner();
   try {
-    final testArgs = <String, Object?>{
-      if (!offline) 'service_uri': testAppUri,
-      if (testApp is TestDartCliApp) 'control_port': testApp.controlPort,
-    };
+    final testArgs = <String, Object?>{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 29c00d8..73d1d30 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,61 @@
 
 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();
+      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 {
+    // 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)),
+    );
+    return ServiceExtensionResponse.result(json.encode({'type': 'success'}));
+  });
 }
 
 /// Binds a "test" HTTP server to an available port.
@@ -30,50 +85,6 @@
   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<io.HttpServer> _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 +147,13 @@
     print('Received DELETE response: $response');
   }
 
+  void packageHttpGet() 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 4c6ae02..61c922c 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 @@
     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 4b3d520..68c321e 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 @@
 }
 
 class TestApp {
-  TestApp._({required this.vmServiceUri, required this.controlPort});
+  TestApp._({required this.vmServiceUri});
 
   factory TestApp._fromJson(Map<String, Object> 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 @@
 
   static const serviceUriKey = 'service_uri';
 
-  static const controlPortKey = 'control_port';
-
   final String vmServiceUri;
-
-  final int? controlPort;
 }
 
 Future<void> verifyScreenshot(