// Copyright 2022 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:dds/devtools_server.dart'; import 'package:devtools_shared/devtools_test_utils.dart'; import 'package:vm_service/vm_service.dart'; const verbose = true; class DevToolsServerDriver { DevToolsServerDriver._( this._process, this._stdin, Stream _stdout, Stream _stderr, ) : stdout = _convertToMapStream(_stdout), stderr = _stderr.map((line) { _trace('<== STDERR $line'); return line; }); final Process _process; final Stream?> stdout; final Stream stderr; final StringSink _stdin; void write(Map request) { final line = jsonEncode(request); _trace('==> $line'); _stdin.writeln(line); } static Stream?> _convertToMapStream( Stream stream, ) { return stream.map((line) { _trace('<== $line'); return line; }).map((line) { try { return jsonDecode(line) as Map; } catch (e) { return null; } }).where((item) => item != null); } static void _trace(String message) { if (verbose) { print(message); } } bool kill() => _process.kill(); static Future create({ int port = 0, int? tryPorts, List additionalArgs = const [], }) async { final script = Platform.script.resolveUri(Uri.parse('./serve_devtools.dart')); final args = [ script.path, '--machine', '--port', '$port', ...additionalArgs, ]; if (tryPorts != null) { args.addAll(['--try-ports', '$tryPorts']); } if (useChromeHeadless && headlessModeIsSupported) { args.add('--headless'); } final Process process = await Process.start( Platform.resolvedExecutable, args, ); return DevToolsServerDriver._( process, process.stdin, process.stdout.transform(utf8.decoder).transform(const LineSplitter()), process.stderr.transform(utf8.decoder).transform(const LineSplitter()), ); } } class DevToolsServerTestController { static const defaultDelay = Duration(milliseconds: 500); late CliAppFixture appFixture; late DevToolsServerDriver server; final completers = >>{}; /// A broadcast stream controller for streaming events from the server. late StreamController> eventController; /// A broadcast stream of events from the server. /// /// Listening for "server.started" events on this stream may be unreliable /// because it may have occurred before the test starts. Use the /// [serverStartedEvent] instead. Stream> get events => eventController.stream; /// Completer that signals when the server started event has been received. late Completer> serverStartedEvent; final Map registeredServices = {}; /// A list of PIDs for Chrome instances spawned by tests that should be /// cleaned up. final List browserPids = []; late StreamSubscription stderrSub; late StreamSubscription?> stdoutSub; Future setUp() async { serverStartedEvent = Completer>(); eventController = StreamController>.broadcast(); // Start the command-line server. server = await DevToolsServerDriver.create(); // Fail tests on any stderr. stderrSub = server.stderr.listen((text) => throw 'STDERR: $text'); stdoutSub = server.stdout.listen((map) { if (map!.containsKey('id')) { if (map.containsKey('result')) { completers[map['id']]!.complete(map['result']); } else { completers[map['id']]!.completeError(map['error']); } } else if (map.containsKey('event')) { if (map['event'] == 'server.started') { serverStartedEvent.complete(map); } eventController.add(map); } }); await serverStartedEvent.future; await startApp(); } Future tearDown() async { browserPids ..forEach((pid) => Process.killPid(pid, ProcessSignal.sigkill)) ..clear(); await stdoutSub.cancel(); await stderrSub.cancel(); server.kill(); await appFixture.teardown(); } Future> sendLaunchDevToolsRequest({ required bool useVmService, String? page, bool notify = false, bool reuseWindows = false, }) async { final launchEvent = events.where((e) => e['event'] == 'client.launch').first; if (useVmService) { await appFixture.serviceConnection.callMethod( registeredServices[DevToolsServer.launchDevToolsService]!, args: { 'reuseWindows': reuseWindows, 'page': page, 'notify': notify, }, ); } else { await send( 'devTools.launch', { 'vmServiceUri': appFixture.serviceUri.toString(), 'reuseWindows': reuseWindows, 'page': page, }, ); } final response = await launchEvent; final pid = response['params']['pid']; if (pid != null) { browserPids.add(pid); } return response['params']; } Future startApp() async { final appUri = Platform.script .resolveUri(Uri.parse('../fixtures/empty_dart_app.dart')); appFixture = await CliAppFixture.create(appUri.path); // Track services method names as they're registered. appFixture.serviceConnection .onEvent(EventStreams.kService) .where((e) => e.kind == EventKind.kServiceRegistered) .listen((e) => registeredServices[e.service!] = e.method!); await appFixture.serviceConnection.streamListen(EventStreams.kService); } int nextId = 0; Future> send( String method, [ Map? params, ]) { final id = (nextId++).toString(); completers[id] = Completer>(); server.write({'id': id.toString(), 'method': method, 'params': params}); return completers[id]!.future; } /// Waits for the server's client list to be updated with the expected state, /// and then returns the client list. /// /// It may take time for the servers client list to be updated as the web app /// connects, so this helper just polls and waits for the expected state. If /// the expected state is never found, the test will timeout. Future> waitForClients({ bool? requiredConnectionState, String? requiredPage, bool expectNone = false, bool useLongTimeout = false, Duration delayDuration = defaultDelay, }) async { late Map serverResponse; final isOnPage = (client) => client['currentPage'] == requiredPage; final hasConnectionState = (client) => requiredConnectionState ?? false // If we require a connected client, also require a non-null page. This // avoids a race in tests where we may proceed to send messages to a client // that is not fully initialised. ? (client['hasConnection'] && client['currentPage'] != null) : !client['hasConnection']; await _waitFor( () async { // Await a short delay to give the client time to connect. await delay(); serverResponse = await send('client.list'); final clients = serverResponse['clients']; return clients is List && (clients.isEmpty == expectNone) && (requiredPage == null || clients.any(isOnPage)) && (requiredConnectionState == null || clients.any(hasConnectionState)); }, delayDuration: delayDuration, ); return serverResponse; } Future _waitFor( Future condition(), { Duration delayDuration = defaultDelay, }) async { while (true) { if (await condition()) { return; } await delay(duration: delayDuration); } } }