// Copyright 2014 The Flutter 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 'package:dds/src/dap/logging.dart'; import 'package:dds/src/dap/protocol_generated.dart'; import 'package:dds/src/dap/protocol_stream.dart'; import 'package:flutter_tools/src/debug_adapters/flutter_adapter_args.dart'; import 'test_server.dart'; /// A helper class to simplify acting as a client for interacting with the /// [DapTestServer] in tests. /// /// Methods on this class should map directly to protocol methods. Additional /// helpers are available in [DapTestClientExtension]. class DapTestClient { DapTestClient._( this._channel, this._logger, { this.captureVmServiceTraffic = false, }) { // Set up a future that will complete when the 'dart.debuggerUris' event is // emitted by the debug adapter so tests have easy access to it. vmServiceUri = event('dart.debuggerUris').then<Uri?>((Event event) { final Map<String, Object?> body = event.body! as Map<String, Object?>; return Uri.parse(body['vmServiceUri']! as String); }).catchError((Object? e) => null); _subscription = _channel.listen( _handleMessage, onDone: () { if (_pendingRequests.isNotEmpty) { _logger?.call( 'Application terminated without a response to ${_pendingRequests.length} requests'); } _pendingRequests.forEach((int id, _OutgoingRequest request) => request.completer.completeError( 'Application terminated without a response to request $id (${request.name})')); _pendingRequests.clear(); }, ); } final ByteStreamServerChannel _channel; late final StreamSubscription<String> _subscription; final Logger? _logger; final bool captureVmServiceTraffic; final Map<int, _OutgoingRequest> _pendingRequests = <int, _OutgoingRequest>{}; final StreamController<Event> _eventController = StreamController<Event>.broadcast(); int _seq = 1; late final Future<Uri?> vmServiceUri; /// Returns a stream of [OutputEventBody] events. Stream<OutputEventBody> get outputEvents => events('output') .map((Event e) => OutputEventBody.fromJson(e.body! as Map<String, Object?>)); /// Returns a stream of [StoppedEventBody] events. Stream<StoppedEventBody> get stoppedEvents => events('stopped') .map((Event e) => StoppedEventBody.fromJson(e.body! as Map<String, Object?>)); /// Returns a stream of the string output from [OutputEventBody] events. Stream<String> get output => outputEvents.map((OutputEventBody output) => output.output); /// Returns a stream of the string output from [OutputEventBody] events with the category 'stdout'. Stream<String> get stdoutOutput => outputEvents .where((OutputEventBody output) => output.category == 'stdout') .map((OutputEventBody output) => output.output); /// Sends a custom request to the server and waits for a response. Future<Response> custom(String name, [Object? args]) async { return sendRequest(args, overrideCommand: name); } /// Returns a Future that completes with the next [event] event. Future<Event> event(String event) => _eventController.stream.firstWhere( (Event e) => e.event == event, orElse: () => throw Exception('Did not receive $event event before stream closed')); /// Returns a stream for [event] events. Stream<Event> events(String event) { return _eventController.stream.where((Event e) => e.event == event); } /// Returns a stream of custom 'dart.serviceExtensionAdded' events. Stream<Map<String, Object?>> get serviceExtensionAddedEvents => events('dart.serviceExtensionAdded') .map((Event e) => e.body! as Map<String, Object?>); /// Returns a stream of custom 'flutter.serviceExtensionStateChanged' events. Stream<Map<String, Object?>> get serviceExtensionStateChangedEvents => events('flutter.serviceExtensionStateChanged') .map((Event e) => e.body! as Map<String, Object?>); /// Returns a stream of 'dart.testNotification' custom events from the /// package:test JSON reporter. Stream<Map<String, Object?>> get testNotificationEvents => events('dart.testNotification') .map((Event e) => e.body! as Map<String, Object?>); /// Sends a custom request to the debug adapter to trigger a Hot Reload. Future<Response> hotReload() { return custom('hotReload'); } /// Sends a custom request to the debug adapter to trigger a Hot Restart. Future<Response> hotRestart() { return custom('hotRestart'); } /// Send an initialize request to the server. /// /// This occurs before the request to start running/debugging a script and is /// used to exchange capabilities and send breakpoints and other settings. Future<Response> initialize({ String exceptionPauseMode = 'None', bool? supportsRunInTerminalRequest, }) async { final List<ProtocolMessage> responses = await Future.wait(<Future<ProtocolMessage>>[ event('initialized'), sendRequest(InitializeRequestArguments( adapterID: 'test', supportsRunInTerminalRequest: supportsRunInTerminalRequest, )), sendRequest( SetExceptionBreakpointsArguments( filters: <String>[exceptionPauseMode], ), ), ]); await sendRequest(ConfigurationDoneArguments()); return responses[1] as Response; // Return the initialize response. } /// Send a launchRequest to the server, asking it to start a Flutter app. Future<Response> launch({ String? program, List<String>? args, List<String>? toolArgs, String? cwd, bool? noDebug, List<String>? additionalProjectPaths, bool? debugSdkLibraries, bool? debugExternalPackageLibraries, bool? evaluateGettersInDebugViews, bool? evaluateToStringInDebugViews, }) { return sendRequest( FlutterLaunchRequestArguments( noDebug: noDebug, program: program, cwd: cwd, args: args, toolArgs: toolArgs, additionalProjectPaths: additionalProjectPaths, debugSdkLibraries: debugSdkLibraries, debugExternalPackageLibraries: debugExternalPackageLibraries, evaluateGettersInDebugViews: evaluateGettersInDebugViews, evaluateToStringInDebugViews: evaluateToStringInDebugViews, // When running out of process, VM Service traffic won't be available // to the client-side logger, so force logging on which sends VM Service // traffic in a custom event. sendLogsToClient: captureVmServiceTraffic, ), // We can't automatically pick the command when using a custom type // (FlutterLaunchRequestArguments). overrideCommand: 'launch', ); } /// Send an attachRequest to the server, asking it to attach to an already-running Flutter app. Future<Response> attach({ List<String>? toolArgs, String? vmServiceUri, String? cwd, List<String>? additionalProjectPaths, bool? debugSdkLibraries, bool? debugExternalPackageLibraries, bool? evaluateGettersInDebugViews, bool? evaluateToStringInDebugViews, }) { return sendRequest( FlutterAttachRequestArguments( cwd: cwd, toolArgs: toolArgs, vmServiceUri: vmServiceUri, additionalProjectPaths: additionalProjectPaths, debugSdkLibraries: debugSdkLibraries, debugExternalPackageLibraries: debugExternalPackageLibraries, evaluateGettersInDebugViews: evaluateGettersInDebugViews, evaluateToStringInDebugViews: evaluateToStringInDebugViews, // When running out of process, VM Service traffic won't be available // to the client-side logger, so force logging on which sends VM Service // traffic in a custom event. sendLogsToClient: captureVmServiceTraffic, ), // We can't automatically pick the command when using a custom type // (FlutterAttachRequestArguments). overrideCommand: 'attach', ); } /// Sends an arbitrary request to the server. /// /// Returns a Future that completes when the server returns a corresponding /// response. Future<Response> sendRequest(Object? arguments, {bool allowFailure = false, String? overrideCommand}) { final String command = overrideCommand ?? commandTypes[arguments.runtimeType]!; final Request request = Request(seq: _seq++, command: command, arguments: arguments); final Completer<Response> completer = Completer<Response>(); _pendingRequests[request.seq] = _OutgoingRequest(completer, command, allowFailure); _channel.sendRequest(request); return completer.future; } /// Returns a Future that completes with the next serviceExtensionAdded /// event for [extension]. Future<Map<String, Object?>> serviceExtensionAdded(String extension) => serviceExtensionAddedEvents.firstWhere( (Map<String, Object?> body) => body['extensionRPC'] == extension, orElse: () => throw Exception('Did not receive $extension extension added event before stream closed')); /// Returns a Future that completes with the next serviceExtensionStateChanged /// event for [extension]. Future<Map<String, Object?>> serviceExtensionStateChanged(String extension) => serviceExtensionStateChangedEvents.firstWhere( (Map<String, Object?> body) => body['extension'] == extension, orElse: () => throw Exception('Did not receive $extension extension state changed event before stream closed')); /// Initializes the debug adapter and launches [program]/[cwd] or calls the /// custom [launch] method. Future<void> start({ String? program, String? cwd, String exceptionPauseMode = 'None', Future<Object?> Function()? launch, }) { return Future.wait(<Future<Object?>>[ initialize(exceptionPauseMode: exceptionPauseMode), launch?.call() ?? this.launch(program: program, cwd: cwd), ], eagerError: true); } Future<void> stop() async { _channel.close(); await _subscription.cancel(); } Future<Response> terminate() => sendRequest(TerminateArguments()); /// Handles an incoming message from the server, completing the relevant request /// of raising the appropriate event. Future<void> _handleMessage(Object? message) async { if (message is Response) { final _OutgoingRequest? pendingRequest = _pendingRequests.remove(message.requestSeq); if (pendingRequest == null) { return; } final Completer<Response> completer = pendingRequest.completer; if (message.success || pendingRequest.allowFailure) { completer.complete(message); } else { completer.completeError(message); } } else if (message is Event && !_eventController.isClosed) { _eventController.add(message); // When we see a terminated event, close the event stream so if any // tests are waiting on something that will never come, they fail at // a useful location. if (message.event == 'terminated') { unawaited(_eventController.close()); } } } /// Creates a [DapTestClient] that connects the server listening on /// [host]:[port]. static Future<DapTestClient> connect( DapTestServer server, { bool captureVmServiceTraffic = false, Logger? logger, }) async { final ByteStreamServerChannel channel = ByteStreamServerChannel(server.stream, server.sink, logger); return DapTestClient._(channel, logger, captureVmServiceTraffic: captureVmServiceTraffic); } } /// Useful events produced by the debug adapter during a debug session. class TestEvents { TestEvents({ required this.output, required this.testNotifications, }); final List<OutputEventBody> output; final List<Map<String, Object?>> testNotifications; } class _OutgoingRequest { _OutgoingRequest(this.completer, this.name, this.allowFailure); final Completer<Response> completer; final String name; final bool allowFailure; } /// Additional helper method for tests to simplify interaction with [DapTestClient]. /// /// Unlike the methods on [DapTestClient] these methods might not map directly /// onto protocol methods. They may call multiple protocol methods and/or /// simplify assertion specific conditions/results. extension DapTestClientExtension on DapTestClient { /// Collects all output events until the program terminates. /// /// These results include all events in the order they are received, including /// console, stdout and stderr. /// /// Only one of [start] or [launch] may be provided. Use [start] to customise /// the whole start of the session (including initialise) or [launch] to only /// customise the [launchRequest]. Future<List<OutputEventBody>> collectAllOutput({ String? program, String? cwd, Future<Response> Function()? start, Future<Response> Function()? launch, bool skipInitialPubGetOutput = true }) async { assert( start == null || launch == null, 'Only one of "start" or "launch" may be provided', ); final Future<List<OutputEventBody>> outputEventsFuture = outputEvents.toList(); // Don't await these, in case they don't complete (eg. an error prevents // the app from starting). if (start != null) { unawaited(start()); } else { unawaited(this.start(program: program, cwd: cwd, launch: launch)); } final List<OutputEventBody> output = await outputEventsFuture; // TODO(dantup): Integration tests currently trigger "flutter pub get" at // the start due to some timestamp manipulation writing the pubspec. // It may be possible to remove this if // https://github.com/flutter/flutter/pull/91300 lands. return skipInitialPubGetOutput ? output.skipWhile((OutputEventBody output) => output.output.startsWith('Running "flutter pub get"')).toList() : output; } /// Collects all output and test events until the program terminates. /// /// These results include all events in the order they are received, including /// console, stdout, stderr and test notifications from the test JSON reporter. /// /// Only one of [start] or [launch] may be provided. Use [start] to customise /// the whole start of the session (including initialise) or [launch] to only /// customise the [launchRequest]. Future<TestEvents> collectTestOutput({ String? program, String? cwd, Future<Response> Function()? start, Future<Object?> Function()? launch, }) async { assert( start == null || launch == null, 'Only one of "start" or "launch" may be provided', ); final Future<List<OutputEventBody>> outputEventsFuture = outputEvents.toList(); final Future<List<Map<String, Object?>>> testNotificationEventsFuture = testNotificationEvents.toList(); if (start != null) { await start(); } else { await this.start(program: program, cwd: cwd, launch: launch); } return TestEvents( output: await outputEventsFuture, testNotifications: await testNotificationEventsFuture, ); } /// Sets a breakpoint at [line] in [file]. Future<void> setBreakpoint(String filePath, int line) async { await sendRequest( SetBreakpointsArguments( source: Source(path: filePath), breakpoints: <SourceBreakpoint>[ SourceBreakpoint(line: line), ], ), ); } /// Sends a continue request for the given thread. /// /// Returns a Future that completes when the server returns a corresponding /// response. Future<Response> continue_(int threadId) => sendRequest(ContinueArguments(threadId: threadId)); /// Clears breakpoints in [file]. Future<void> clearBreakpoints(String filePath) async { await sendRequest( SetBreakpointsArguments( source: Source(path: filePath), breakpoints: <SourceBreakpoint>[], ), ); } }