Skip to content

Use service extension in networking integration test #9323

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jul 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -28,63 +27,62 @@ void main() {

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'));
Expand All @@ -95,11 +93,28 @@ final class _NetworkScreenHelper {
);
}

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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> startProcess() async {
Expand All @@ -216,75 +212,44 @@ class TestDartCliApp extends IntegrationTestApp {

@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);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,7 @@ Future<void> runFlutterIntegrationTest(
// 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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,69 @@

import 'dart:async';
import 'dart:convert' show json;
import 'dart:developer';
import 'dart:io' as io;

import 'package:dio/dio.dart';
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();
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)),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same question about delayed here

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I'll add a comment here. The issue is that this service extension triggers io.exit(0), but we still need to return a value. I tried unawaited(Future.microtask(() => io.exit(0)));, but that resulted in a test error, that the connected app shutdown unexpectedly. So I think we need to return a ServiceExtensionResponse, let it get through some code layers asynchronously via microtasks, so that the response makes it all the way to DevTools, and then we can exit.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, thanks!

);
return ServiceExtensionResponse.result(json.encode({'type': 'success'}));
});
}

/// Binds a "test" HTTP server to an available port.
Expand All @@ -30,50 +85,6 @@ Future<io.HttpServer> _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<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
Expand Down Expand Up @@ -136,6 +147,13 @@ class _HttpClient {
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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ T _accessGlobalOrThrow<T>({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.",
);
}
Expand Down
Loading
Loading