From de966d8a492152be5b0f13cb025f0d3935c30abe Mon Sep 17 00:00:00 2001 From: Danny Tuppeny Date: Wed, 27 Oct 2021 14:48:06 +0100 Subject: [PATCH] Add a "flutter debug_adapter" command that runs a DAP server (#91802) --- packages/flutter_tools/lib/executable.dart | 2 + .../lib/src/commands/debug_adapter.dart | 63 +++ .../lib/src/debug_adapters/README.md | 62 +++ .../src/debug_adapters/flutter_adapter.dart | 450 ++++++++++++++++++ .../debug_adapters/flutter_adapter_args.dart | 109 +++++ .../lib/src/debug_adapters/mixins.dart | 64 +++ .../lib/src/debug_adapters/server.dart | 51 ++ .../debug_adapter/flutter_adapter_test.dart | 205 ++++++++ .../debug_adapter/test_client.dart | 276 +++++++++++ .../debug_adapter/test_server.dart | 135 ++++++ .../debug_adapter/test_support.dart | 76 +++ .../overall_experience_test.dart | 1 - .../test_data/compile_error_project.dart | 32 ++ 13 files changed, 1525 insertions(+), 1 deletion(-) create mode 100644 packages/flutter_tools/lib/src/commands/debug_adapter.dart create mode 100644 packages/flutter_tools/lib/src/debug_adapters/README.md create mode 100644 packages/flutter_tools/lib/src/debug_adapters/flutter_adapter.dart create mode 100644 packages/flutter_tools/lib/src/debug_adapters/flutter_adapter_args.dart create mode 100644 packages/flutter_tools/lib/src/debug_adapters/mixins.dart create mode 100644 packages/flutter_tools/lib/src/debug_adapters/server.dart create mode 100644 packages/flutter_tools/test/integration.shard/debug_adapter/flutter_adapter_test.dart create mode 100644 packages/flutter_tools/test/integration.shard/debug_adapter/test_client.dart create mode 100644 packages/flutter_tools/test/integration.shard/debug_adapter/test_server.dart create mode 100644 packages/flutter_tools/test/integration.shard/debug_adapter/test_support.dart create mode 100644 packages/flutter_tools/test/integration.shard/test_data/compile_error_project.dart diff --git a/packages/flutter_tools/lib/executable.dart b/packages/flutter_tools/lib/executable.dart index 7eba9bf1ac..dffc0097a1 100644 --- a/packages/flutter_tools/lib/executable.dart +++ b/packages/flutter_tools/lib/executable.dart @@ -26,6 +26,7 @@ import 'src/commands/config.dart'; import 'src/commands/create.dart'; import 'src/commands/custom_devices.dart'; import 'src/commands/daemon.dart'; +import 'src/commands/debug_adapter.dart'; import 'src/commands/devices.dart'; import 'src/commands/doctor.dart'; import 'src/commands/downgrade.dart'; @@ -160,6 +161,7 @@ List generateCommands({ ), CreateCommand(verboseHelp: verboseHelp), DaemonCommand(hidden: !verboseHelp), + DebugAdapterCommand(verboseHelp: verboseHelp), DevicesCommand(verboseHelp: verboseHelp), DoctorCommand(verbose: verbose), DowngradeCommand(verboseHelp: verboseHelp), diff --git a/packages/flutter_tools/lib/src/commands/debug_adapter.dart b/packages/flutter_tools/lib/src/commands/debug_adapter.dart new file mode 100644 index 0000000000..77b1134164 --- /dev/null +++ b/packages/flutter_tools/lib/src/commands/debug_adapter.dart @@ -0,0 +1,63 @@ +// 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. + +// @dart = 2.8 + +import 'dart:async'; + +import '../debug_adapters/server.dart'; +import '../globals.dart' as globals; +import '../runner/flutter_command.dart'; + +/// This command will start up a Debug Adapter that communicates using the Debug Adapter Protocol (DAP). +/// +/// This is for use by editors and IDEs that have DAP clients to launch and +/// debug Flutter apps/tests. It extends the standard Dart DAP implementation +/// from DDS with Flutter-specific functionality (such as Hot Restart). +/// +/// The server is intended to be single-use. It should live only for the +/// duration of a single debug session in the editor, and terminate when the +/// user stops debugging. If a user starts multiple debug sessions +/// simultaneously it is expected that the editor will start multiple debug +/// adapters. +/// +/// The DAP specification can be found at +/// https://microsoft.github.io/debug-adapter-protocol/. +class DebugAdapterCommand extends FlutterCommand { + DebugAdapterCommand({ bool verboseHelp = false}) : hidden = !verboseHelp { + usesIpv6Flag(verboseHelp: verboseHelp); + addDdsOptions(verboseHelp: verboseHelp); + } + + @override + final String name = 'debug-adapter'; + + @override + List get aliases => const ['debug_adapter']; + + @override + final String description = 'Run a Debug Adapter Protocol (DAP) server to communicate with the Flutter tool.'; + + @override + final String category = FlutterCommandCategory.tools; + + @override + final bool hidden; + + @override + Future runCommand() async { + final DapServer server = DapServer( + globals.stdio.stdin, + globals.stdio.stdout.nonBlocking, + fileSystem: globals.fs, + platform: globals.platform, + ipv6: ipv6, + enableDds: enableDds, + ); + + await server.channel.closed; + + return FlutterCommandResult.success(); + } +} diff --git a/packages/flutter_tools/lib/src/debug_adapters/README.md b/packages/flutter_tools/lib/src/debug_adapters/README.md new file mode 100644 index 0000000000..0a1190a5e6 --- /dev/null +++ b/packages/flutter_tools/lib/src/debug_adapters/README.md @@ -0,0 +1,62 @@ +# Debug Adapter Protocol (DAP) + +This document is Flutter-specific. For information on the standard Dart DAP implementation, [see this document](https://github.com/dart-lang/sdk/blob/main/pkg/dds/tool/dap/README.md). + +Flutter includes support for debugging using [the Debug Adapter Protocol](https://microsoft.github.io/debug-adapter-protocol/) as an alternative to using the [VM Service](https://github.com/dart-lang/sdk/blob/master/runtime/vm/service/service.md) directly, simplying the integration for new editors. + +The debug adapter is started with the `flutter debug-adapter` command and is intended to be consumed by DAP-compliant tools such as Flutter-specific extensions for editors, or configured by users whose editors include generic configurable DAP clients. + +Because in the DAP protocol the client speaks first, running this command from the terminal will result in no output (nor will the process terminate). This is expected behaviour. + +For details on the standard DAP functionality, see [the Debug Adapter Protocol Overview](https://microsoft.github.io/debug-adapter-protocol/) and [the Debug Adapter Protocol Specification](https://microsoft.github.io/debug-adapter-protocol/specification). Custom extensions are detailed below. + +## Launch/Attach Arguments + +Arguments common to both `launchRequest` and `attachRequest` are: + +- `bool? debugExternalPackageLibraries` - whether to enable debugging for packages that are not inside the current workspace (if not supplied, defaults to `true`) +- `bool? debugSdkLibraries` - whether to enable debugging for SDK libraries (if not supplied, defaults to `true`) +- `bool? evaluateGettersInDebugViews` - whether to evaluate getters in expression evaluation requests (inc. hovers/watch windows) (if not supplied, defaults to `false`) +- `bool? evaluateToStringInDebugViews` - whether to invoke `toString()` in expression evaluation requests (inc. hovers/watch windows) (if not supplied, defaults to `false`) +- `bool? sendLogsToClient` - used to proxy all VM Service traffic back to the client in custom `dart.log` events (has performance implications, intended for troubleshooting) (if not supplied, defaults to `false`) +- `List? additionalProjectPaths` - paths of any projects (outside of `cwd`) that are open in the users workspace +- `String? cwd` - the working directory for the Dart process to be spawned in + +Arguments specific to `launchRequest` are: + +- `bool? noDebug` - whether to run in debug or noDebug mode (if not supplied, defaults to debug) +- `String program` - the path of the Flutter application to run +- `List? args` - arguments to be passed to the Flutter program +- `List? toolArgs` - arguments for the `flutter` tool +- `String? console` - if set to `"terminal"` or `"externalTerminal"` will be run using the `runInTerminal` reverse-request; otherwise the debug adapter spawns the Dart process +- `bool? enableAsserts` - whether to enable asserts (if not supplied, defaults to `true`) + +`attachRequest` is not currently supported, but will be documented here when it is. + +## Custom Requests + +Some custom requests are available for clients to call. Below are the Flutter-specific custom requests, but the standard Dart DAP custom requests are also [documented here](https://github.com/dart-lang/sdk/blob/main/pkg/dds/tool/dap/README.md#custom-requests). + +### `hotReload` + +`hotReload` injects updated source code files into the running VM and then rebuilds the widget tree. An optional `reason` can be provided and should usually be `"manual"` or `"save"` to indicate what how the reload was triggered (for example by the user clicking a button, versus a hot-reload-on-save feature). + +``` +{ + "reason": "manual" +} +``` + +### `hotRestart` + +`hotRestart` updates the code on the device and performs a full restart (which does not preserve state). An optional `reason` can be provided and should usually be `"manual"` or `"save"` to indicate what how the reload was triggered (for example by the user clicking a button, versus a hot-reload-on-save feature). + +``` +{ + "reason": "manual" +} +``` + +## Custom Events + +The debug adapter may emit several custom events that are useful to clients. There are not currently any custom Flutter events, but the standard Dart DAP custom requests are [documented here](https://github.com/dart-lang/sdk/blob/main/pkg/dds/tool/dap/README.md#custom-events). diff --git a/packages/flutter_tools/lib/src/debug_adapters/flutter_adapter.dart b/packages/flutter_tools/lib/src/debug_adapters/flutter_adapter.dart new file mode 100644 index 0000000000..d6a7fb16ea --- /dev/null +++ b/packages/flutter_tools/lib/src/debug_adapters/flutter_adapter.dart @@ -0,0 +1,450 @@ +// 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/dap.dart' hide PidTracker, PackageConfigUtils; +import 'package:vm_service/vm_service.dart' as vm; + +import '../base/file_system.dart'; +import '../base/io.dart'; +import '../base/platform.dart'; +import '../cache.dart'; +import '../convert.dart'; +import 'flutter_adapter_args.dart'; +import 'mixins.dart'; + +/// A DAP Debug Adapter for running and debugging Flutter applications. +class FlutterDebugAdapter extends DartDebugAdapter + with PidTracker, PackageConfigUtils { + FlutterDebugAdapter( + ByteStreamServerChannel channel, { + required this.fileSystem, + required this.platform, + bool ipv6 = false, + bool enableDds = true, + bool enableAuthCodes = true, + Logger? logger, + }) : super( + channel, + ipv6: ipv6, + enableDds: enableDds, + enableAuthCodes: enableAuthCodes, + logger: logger, + ); + + @override + FileSystem fileSystem; + Platform platform; + Process? _process; + + @override + final FlutterLaunchRequestArguments Function(Map obj) + parseLaunchArgs = FlutterLaunchRequestArguments.fromJson; + + @override + final FlutterAttachRequestArguments Function(Map obj) + parseAttachArgs = FlutterAttachRequestArguments.fromJson; + + /// A completer that completes when the app.started event has been received. + final Completer _appStartedCompleter = Completer(); + + /// Whether or not the app.started event has been received. + bool get _receivedAppStarted => _appStartedCompleter.isCompleted; + + /// The VM Service URI received from the app.debugPort event. + Uri? _vmServiceUri; + + /// The appId of the current running Flutter app. + String? _appId; + + /// The ID to use for the next request sent to the Flutter run daemon. + int _flutterRequestId = 1; + + /// Outstanding requests that have been sent to the Flutter run daemon and + /// their handlers. + final Map> _flutterRequestCompleters = >{}; + + /// Whether or not this adapter can handle the restartRequest. + /// + /// For Flutter apps we can handle this with a Hot Restart rather than having + /// the whole debug session stopped and restarted. + @override + bool get supportsRestartRequest => true; + + /// Whether the VM Service closing should be used as a signal to terminate the debug session. + /// + /// Since we always have a process for Flutter (whether run or attach) we'll + /// always use its termination instead, so this is always false. + @override + bool get terminateOnVmServiceClose => false; + + /// Called by [attachRequest] to request that we actually connect to the app to be debugged. + @override + Future attachImpl() async { + sendOutput('console', '\nAttach is not currently supported'); + handleSessionTerminate(); + } + + /// [customRequest] handles any messages that do not match standard messages in the spec. + /// + /// This is used to allow a client/DA to have custom methods outside of the + /// spec. It is up to the client/DA to negotiate which custom messages are + /// allowed. + /// + /// [sendResponse] must be called when handling a message, even if it is with + /// a null response. Otherwise the client will never be informed that the + /// request has completed. + /// + /// Any requests not handled must call super which will respond with an error + /// that the message was not supported. + /// + /// Unless they start with _ to indicate they are private, custom messages + /// should not change in breaking ways if client IDEs/editors may be calling + /// them. + @override + Future customRequest( + Request request, + RawRequestArguments? args, + void Function(Object?) sendResponse, + ) async { + switch (request.command) { + case 'hotRestart': + case 'hotReload': + final bool isFullRestart = request.command == 'hotRestart'; + await _performRestart(isFullRestart, args?.args['reason'] as String?); + sendResponse(null); + break; + + default: + await super.customRequest(request, args, sendResponse); + } + } + + @override + Future debuggerConnected(vm.VM vmInfo) async { + // Capture the PID from the VM Service so that we can terminate it when + // cleaning up. Terminating the process might not be enough as it could be + // just a shell script (e.g. flutter.bat on Windows) and may not pass the + // signal on correctly. + // See: https://github.com/Dart-Code/Dart-Code/issues/907 + final int? pid = vmInfo.pid; + if (pid != null) { + pidsToTerminate.add(pid); + } + } + + /// Called by [disconnectRequest] to request that we forcefully shut down the app being run (or in the case of an attach, disconnect). + /// + /// Client IDEs/editors should send a terminateRequest before a + /// disconnectRequest to allow a graceful shutdown. This method must terminate + /// quickly and therefore may leave orphaned processes. + @override + Future disconnectImpl() async { + terminatePids(ProcessSignal.sigkill); + } + + /// Called by [launchRequest] to request that we actually start the app to be run/debugged. + /// + /// For debugging, this should start paused, connect to the VM Service, set + /// breakpoints, and resume. + @override + Future launchImpl() async { + final FlutterLaunchRequestArguments args = this.args as FlutterLaunchRequestArguments; + final String flutterToolPath = fileSystem.path.join(Cache.flutterRoot!, 'bin', platform.isWindows ? 'flutter.bat' : 'flutter'); + + // "debug"/"noDebug" refers to the DAP "debug" mode and not the Flutter + // debug mode (vs Profile/Release). It is possible for the user to "Run" + // from VS Code (eg. not want to hit breakpoints/etc.) but still be running + // a debug build. + final bool debug = !(args.noDebug ?? false); + final String? program = args.program; + + final List toolArgs = [ + 'run', + '--machine', + if (debug) ...[ + '--start-paused', + ], + ]; + final List processArgs = [ + ...toolArgs, + ...?args.toolArgs, + if (program != null) ...[ + '--target', + program, + ], + ...?args.args, + ]; + + // Find the package_config file for this script. This is used by the + // debugger to map package: URIs to file paths to check whether they're in + // the editors workspace (args.cwd/args.additionalProjectPaths) so they can + // be correctly classes as "my code", "sdk" or "external packages". + // TODO(dantup): Remove this once https://github.com/dart-lang/sdk/issues/45530 + // is done as it will not be necessary. + final String? possibleRoot = program == null + ? args.cwd + : fileSystem.path.isAbsolute(program) + ? fileSystem.path.dirname(program) + : fileSystem.path.dirname( + fileSystem.path.normalize(fileSystem.path.join(args.cwd ?? '', args.program))); + if (possibleRoot != null) { + final File? packageConfig = findPackageConfigFile(possibleRoot); + if (packageConfig != null) { + usePackageConfigFile(packageConfig); + } + } + + logger?.call('Spawning $flutterToolPath with $processArgs in ${args.cwd}'); + final Process process = await Process.start( + flutterToolPath, + processArgs, + workingDirectory: args.cwd, + ); + _process = process; + pidsToTerminate.add(process.pid); + + process.stdout.transform(ByteToLineTransformer()).listen(_handleStdout); + process.stderr.listen(_handleStderr); + unawaited(process.exitCode.then(_handleExitCode)); + + // Delay responding until the app is launched and (optionally) the debugger + // is connected. + await _appStartedCompleter.future; + if (debug) { + await debuggerInitialized; + } + } + + /// restart is called by the client when the user invokes a restart (for example with the button on the debug toolbar). + /// + /// For Flutter, we handle this ourselves be sending a Hot Restart request + /// to the running app. + @override + Future restartRequest( + Request request, + RestartArguments? args, + void Function() sendResponse, + ) async { + await _performRestart(true); + + sendResponse(); + } + + /// Sends a request to the Flutter daemon that is running/attaching to the app and waits for a response. + /// + /// If [failSilently] is `true` (the default) and there is no process, the + /// message will be silently ignored (this is common during the application + /// being stopped, where async messages may be processed). Setting it to + /// `false` will cause a [DebugAdapterException] to be thrown in that case. + Future sendFlutterRequest( + String method, + Map? params, { + bool failSilently = true, + }) async { + final Process? process = _process; + + if (process == null) { + if (failSilently) { + return null; + } else { + throw DebugAdapterException( + 'Unable to Restart because Flutter process is not available', + ); + } + } + + final Completer completer = Completer(); + final int id = _flutterRequestId++; + _flutterRequestCompleters[id] = completer; + + // Flutter requests are always wrapped in brackets as an array. + final String messageString = jsonEncode( + {'id': id, 'method': method, 'params': params}, + ); + final String payload = '[$messageString]\n'; + + process.stdin.writeln(payload); + + return completer.future; + } + + /// Called by [terminateRequest] to request that we gracefully shut down the app being run (or in the case of an attach, disconnect). + @override + Future terminateImpl() async { + terminatePids(ProcessSignal.sigterm); + await _process?.exitCode; + } + + /// Connects to the VM Service if the app.started event has fired, and a VM Service URI is available. + void _connectDebuggerIfReady() { + final Uri? serviceUri = _vmServiceUri; + if (_receivedAppStarted && serviceUri != null) { + connectDebugger(serviceUri, resumeIfStarting: true); + } + } + + /// Handles the app.start event from Flutter. + void _handleAppStart(Map params) { + _appId = params['appId'] as String?; + assert(_appId != null); + } + + /// Handles the app.started event from Flutter. + void _handleAppStarted() { + _appStartedCompleter.complete(); + _connectDebuggerIfReady(); + } + + /// Handles the app.debugPort event from Flutter, connecting to the VM Service if everything else is ready. + void _handleDebugPort(Map params) { + // When running in noDebug mode, Flutter may still provide us a VM Service + // URI, but we will not connect it because we don't want to do any debugging. + final FlutterLaunchRequestArguments args = this.args as FlutterLaunchRequestArguments; + final bool debug = !(args.noDebug ?? false); + if (!debug) { + return; + } + + // Capture the VM Service URL which we'll connect to when we get app.started. + final String? wsUri = params['wsUri'] as String?; + if (wsUri != null) { + _vmServiceUri = Uri.parse(wsUri); + } + _connectDebuggerIfReady(); + } + + /// Handles the Flutter process exiting, terminating the debug session if it has not already begun terminating. + void _handleExitCode(int code) { + final String codeSuffix = code == 0 ? '' : ' ($code)'; + logger?.call('Process exited ($code)'); + handleSessionTerminate(codeSuffix); + } + + /// Handles incoming JSON events from `flutter run --machine`. + void _handleJsonEvent(String event, Map? params) { + params ??= {}; + switch (event) { + case 'app.debugPort': + _handleDebugPort(params); + break; + case 'app.start': + _handleAppStart(params); + break; + case 'app.started': + _handleAppStarted(); + break; + } + } + + /// Handles incoming JSON messages from `flutter run --machine` that are responses to requests that we sent. + void _handleJsonResponse(int id, Map response) { + final Completer? handler = _flutterRequestCompleters.remove(id); + if (handler == null) { + logger?.call( + 'Received response from Flutter run daemon with ID $id ' + 'but had not matching handler', + ); + return; + } + + final Object? error = response['error']; + final Object? result = response['result']; + if (error != null) { + handler.completeError(DebugAdapterException('$error')); + } else { + handler.complete(result); + } + } + + void _handleStderr(List data) { + logger?.call('stderr: $data'); + sendOutput('stderr', utf8.decode(data)); + } + + /// Handles stdout from the `flutter run --machine` process, decoding the JSON and calling the appropriate handlers. + void _handleStdout(String data) { + // Output intended for us to parse is JSON wrapped in brackets: + // [{"event":"app.foo","params":{"bar":"baz"}}] + // However, it's also possible a user printed things that look a little like + // this so try to detect only things we're interested in: + // - parses as JSON + // - is a List of only a single item that is a Map + // - the item has an "event" field that is a String + // - the item has a "params" field that is a Map? + + logger?.call('stdout: $data'); + + // Output is sent as console (eg. output from tooling) until the app has + // started, then stdout (users output). This is so info like + // "Launching lib/main.dart on Device foo" is formatted differently to + // general output printed by the user. + final String outputCategory = _receivedAppStarted ? 'stdout' : 'console'; + + // Output in stdout can include both user output (eg. print) and Flutter + // daemon output. Since it's not uncommon for users to print JSON while + // debugging, we must try to detect which messages are likely Flutter + // messages as reliably as possible, as trying to process users output + // as a Flutter message may result in an unhandled error that will + // terminate the debug adater in a way that does not provide feedback + // because the standard crash violates the DAP protocol. + Object? jsonData; + try { + jsonData = jsonDecode(data); + } on FormatException { + // If the output wasn't valid JSON, it was standard stdout that should + // be passed through to the user. + sendOutput(outputCategory, data); + return; + } + + final Map? payload = jsonData is List && + jsonData.length == 1 && + jsonData.first is Map + ? jsonData.first as Map + : null; + + if (payload == null) { + // JSON didn't match expected format for Flutter responses, so treat as + // standard user output. + sendOutput(outputCategory, data); + return; + } + + final Object? event = payload['event']; + final Object? params = payload['params']; + final Object? id = payload['id']; + if (event is String && params is Map?) { + _handleJsonEvent(event, params); + } else if (id is int && _flutterRequestCompleters.containsKey(id)) { + _handleJsonResponse(id, payload); + } else { + // If it wasn't processed above, + sendOutput(outputCategory, data); + } + } + + /// Performs a restart/reload by sending the `app.restart` message to the `flutter run --machine` process. + Future _performRestart( + bool fullRestart, [ + String? reason, + ]) async { + final DartCommonLaunchAttachRequestArguments args = this.args; + final bool debug = + args is! FlutterLaunchRequestArguments || args.noDebug != true; + try { + await sendFlutterRequest('app.restart', { + 'appId': _appId, + 'fullRestart': fullRestart, + 'pause': debug, + 'reason': reason, + 'debounce': true, + }); + } on DebugAdapterException catch (error) { + final String action = fullRestart ? 'Hot Restart' : 'Hot Reload'; + sendOutput('console', 'Failed to $action: $error'); + } + } +} diff --git a/packages/flutter_tools/lib/src/debug_adapters/flutter_adapter_args.dart b/packages/flutter_tools/lib/src/debug_adapters/flutter_adapter_args.dart new file mode 100644 index 0000000000..ddd67b7149 --- /dev/null +++ b/packages/flutter_tools/lib/src/debug_adapters/flutter_adapter_args.dart @@ -0,0 +1,109 @@ +// 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 'package:dds/dap.dart'; + +/// An implementation of [AttachRequestArguments] that includes all fields used by the Flutter debug adapter. +/// +/// This class represents the data passed from the client editor to the debug +/// adapter in attachRequest, which is a request to start debugging an +/// application. +class FlutterAttachRequestArguments + extends DartCommonLaunchAttachRequestArguments + implements AttachRequestArguments { + FlutterAttachRequestArguments({ + Object? restart, + String? name, + String? cwd, + List? additionalProjectPaths, + bool? debugSdkLibraries, + bool? debugExternalPackageLibraries, + bool? evaluateGettersInDebugViews, + bool? evaluateToStringInDebugViews, + bool? sendLogsToClient, + }) : super( + name: name, + cwd: cwd, + restart: restart, + additionalProjectPaths: additionalProjectPaths, + debugSdkLibraries: debugSdkLibraries, + debugExternalPackageLibraries: debugExternalPackageLibraries, + evaluateGettersInDebugViews: evaluateGettersInDebugViews, + evaluateToStringInDebugViews: evaluateToStringInDebugViews, + sendLogsToClient: sendLogsToClient, + ); + + FlutterAttachRequestArguments.fromMap(Map obj): + super.fromMap(obj); + + static FlutterAttachRequestArguments fromJson(Map obj) => + FlutterAttachRequestArguments.fromMap(obj); +} + +/// An implementation of [LaunchRequestArguments] that includes all fields used by the Flutter debug adapter. +/// +/// This class represents the data passed from the client editor to the debug +/// adapter in launchRequest, which is a request to start debugging an +/// application. +class FlutterLaunchRequestArguments + extends DartCommonLaunchAttachRequestArguments + implements LaunchRequestArguments { + FlutterLaunchRequestArguments({ + this.noDebug, + required this.program, + this.args, + this.toolArgs, + Object? restart, + String? name, + String? cwd, + List? additionalProjectPaths, + bool? debugSdkLibraries, + bool? debugExternalPackageLibraries, + bool? evaluateGettersInDebugViews, + bool? evaluateToStringInDebugViews, + bool? sendLogsToClient, + }) : super( + restart: restart, + name: name, + cwd: cwd, + additionalProjectPaths: additionalProjectPaths, + debugSdkLibraries: debugSdkLibraries, + debugExternalPackageLibraries: debugExternalPackageLibraries, + evaluateGettersInDebugViews: evaluateGettersInDebugViews, + evaluateToStringInDebugViews: evaluateToStringInDebugViews, + sendLogsToClient: sendLogsToClient, + ); + + FlutterLaunchRequestArguments.fromMap(Map obj) + : noDebug = obj['noDebug'] as bool?, + program = obj['program'] as String?, + args = (obj['args'] as List?)?.cast(), + toolArgs = (obj['toolArgs'] as List?)?.cast(), + super.fromMap(obj); + + /// If noDebug is true the launch request should launch the program without enabling debugging. + @override + final bool? noDebug; + + /// The program/Flutter app to be run. + final String? program; + + /// Arguments to be passed to [program]. + final List? args; + + /// Arguments to be passed to the tool that will run [program] (for example, the VM or Flutter tool). + final List? toolArgs; + + @override + Map toJson() => { + ...super.toJson(), + if (noDebug != null) 'noDebug': noDebug, + if (program != null) 'program': program, + if (args != null) 'args': args, + if (toolArgs != null) 'toolArgs': toolArgs, + }; + + static FlutterLaunchRequestArguments fromJson(Map obj) => + FlutterLaunchRequestArguments.fromMap(obj); +} diff --git a/packages/flutter_tools/lib/src/debug_adapters/mixins.dart b/packages/flutter_tools/lib/src/debug_adapters/mixins.dart new file mode 100644 index 0000000000..c6dbe43199 --- /dev/null +++ b/packages/flutter_tools/lib/src/debug_adapters/mixins.dart @@ -0,0 +1,64 @@ +// 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 '../base/file_system.dart'; +import '../base/io.dart'; + +/// A mixin providing some utility functions for locating/working with +/// package_config.json files. +/// +/// Adapted from package:dds/src/dap/adapters/mixins.dart to use Flutter's +/// dart:io wrappers. +mixin PackageConfigUtils { + abstract FileSystem fileSystem; + + /// Find the `package_config.json` file for the program being launched. + File? findPackageConfigFile(String possibleRoot) { + // TODO(dantup): Remove this once + // https://github.com/dart-lang/sdk/issues/45530 is done as it will not be + // necessary. + File? packageConfig; + while (true) { + packageConfig = fileSystem.file( + fileSystem.path.join(possibleRoot, '.dart_tool', 'package_config.json'), + ); + + // If this packageconfig exists, use it. + if (packageConfig.existsSync()) { + break; + } + + final String parent = fileSystem.path.dirname(possibleRoot); + + // If we can't go up anymore, the search failed. + if (parent == possibleRoot) { + packageConfig = null; + break; + } + + possibleRoot = parent; + } + + return packageConfig; + } +} + +/// A mixin for tracking additional PIDs that can be shut down at the end of a debug session. +/// +/// Adapted from package:dds/src/dap/adapters/mixins.dart to use Flutter's +/// dart:io wrappers. +mixin PidTracker { + /// Process IDs to terminate during shutdown. + /// + /// This may be populated with pids from the VM Service to ensure we clean up + /// properly where signals may not be passed through the shell to the + /// underlying VM process. + /// https://github.com/Dart-Code/Dart-Code/issues/907 + final Set pidsToTerminate = {}; + + /// Terminates all processes with the PIDs registered in [pidsToTerminate]. + void terminatePids(ProcessSignal signal) { + pidsToTerminate.forEach(signal.send); + } +} diff --git a/packages/flutter_tools/lib/src/debug_adapters/server.dart b/packages/flutter_tools/lib/src/debug_adapters/server.dart new file mode 100644 index 0000000000..9528c05a51 --- /dev/null +++ b/packages/flutter_tools/lib/src/debug_adapters/server.dart @@ -0,0 +1,51 @@ +// 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/dap.dart' hide DapServer; + +import '../base/file_system.dart'; +import '../base/platform.dart'; +import '../debug_adapters/flutter_adapter.dart'; +import '../debug_adapters/flutter_adapter_args.dart'; + +/// A DAP server that communicates over a [ByteStreamServerChannel], usually constructed from the processes stdin/stdout streams. +/// +/// The server is intended to be single-use. It should live only for the +/// duration of a single debug session in the editor, and terminate when the +/// user stops debugging. If a user starts multiple debug sessions +/// simultaneously it is expected that the editor will start multiple debug +/// adapters. +class DapServer { + DapServer( + Stream> _input, + StreamSink> _output, { + required FileSystem fileSystem, + required Platform platform, + this.ipv6 = false, + this.enableDds = true, + this.enableAuthCodes = true, + this.logger, + }) : channel = ByteStreamServerChannel(_input, _output, logger) { + adapter = FlutterDebugAdapter(channel, + fileSystem: fileSystem, + platform: platform, + ipv6: ipv6, + enableDds: enableDds, + enableAuthCodes: enableAuthCodes, + logger: logger); + } + + final ByteStreamServerChannel channel; + late final DartDebugAdapter adapter; + final bool ipv6; + final bool enableDds; + final bool enableAuthCodes; + final Logger? logger; + + void stop() { + channel.close(); + } +} diff --git a/packages/flutter_tools/test/integration.shard/debug_adapter/flutter_adapter_test.dart b/packages/flutter_tools/test/integration.shard/debug_adapter/flutter_adapter_test.dart new file mode 100644 index 0000000000..5e10d5c707 --- /dev/null +++ b/packages/flutter_tools/test/integration.shard/debug_adapter/flutter_adapter_test.dart @@ -0,0 +1,205 @@ +// 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. + +// @dart = 2.8 + +import 'dart:async'; + +import 'package:dds/src/dap/protocol_generated.dart'; +import 'package:file/file.dart'; +import 'package:flutter_tools/src/cache.dart'; + +import '../../src/common.dart'; +import '../test_data/basic_project.dart'; +import '../test_data/compile_error_project.dart'; +import '../test_utils.dart'; +import 'test_client.dart'; +import 'test_support.dart'; + +void main() { + Directory tempDir; + /*late*/ DapTestSession dap; + final String relativeMainPath = 'lib${fileSystem.path.separator}main.dart'; + + setUpAll(() { + Cache.flutterRoot = getFlutterRoot(); + }); + + setUp(() async { + tempDir = createResolvedTempDirectorySync('debug_adapter_test.'); + dap = await DapTestSession.setUp(); + }); + + tearDown(() async { + await dap.tearDown(); + tryToDelete(tempDir); + }); + + testWithoutContext('can run and terminate a Flutter app in debug mode', () async { + final BasicProject _project = BasicProject(); + await _project.setUpIn(tempDir); + + // Once the "topLevelFunction" output arrives, we can terminate the app. + unawaited( + dap.client.outputEvents + .firstWhere((OutputEventBody output) => output.output.startsWith('topLevelFunction')) + .whenComplete(() => dap.client.terminate()), + ); + + final List outputEvents = await dap.client.collectAllOutput( + launch: () => dap.client + .launch( + cwd: _project.dir.path, + toolArgs: ['-d', 'flutter-tester'], + ), + ); + + final String output = _uniqueOutputLines(outputEvents); + + expectLines(output, [ + 'Launching $relativeMainPath on Flutter test device in debug mode...', + startsWith('Connecting to VM Service at'), + 'topLevelFunction', + '', + startsWith('Exited'), + ]); + }); + + testWithoutContext('can run and terminate a Flutter app in noDebug mode', () async { + final BasicProject _project = BasicProject(); + await _project.setUpIn(tempDir); + + // Once the "topLevelFunction" output arrives, we can terminate the app. + unawaited( + dap.client.outputEvents + .firstWhere((OutputEventBody output) => output.output.startsWith('topLevelFunction')) + .whenComplete(() => dap.client.terminate()), + ); + + final List outputEvents = await dap.client.collectAllOutput( + launch: () => dap.client + .launch( + cwd: _project.dir.path, + noDebug: true, + toolArgs: ['-d', 'flutter-tester'], + ), + ); + + final String output = _uniqueOutputLines(outputEvents); + + expectLines(output, [ + 'Launching $relativeMainPath on Flutter test device in debug mode...', + 'topLevelFunction', + '', + startsWith('Exited'), + ]); + }); + + testWithoutContext('correctly outputs launch errors and terminates', () async { + final CompileErrorProject _project = CompileErrorProject(); + await _project.setUpIn(tempDir); + + final List outputEvents = await dap.client.collectAllOutput( + launch: () => dap.client + .launch( + cwd: _project.dir.path, + toolArgs: ['-d', 'flutter-tester'], + ), + ); + + final String output = _uniqueOutputLines(outputEvents); + expect(output, contains('this code does not compile')); + expect(output, contains('Exception: Failed to build')); + expect(output, contains('Exited (1)')); + }); + + testWithoutContext('can hot reload', () async { + final BasicProject _project = BasicProject(); + await _project.setUpIn(tempDir); + + // Launch the app and wait for it to print "topLevelFunction". + await Future.wait(>[ + dap.client.outputEvents.firstWhere((OutputEventBody output) => output.output.startsWith('topLevelFunction')), + dap.client.start( + launch: () => dap.client.launch( + cwd: _project.dir.path, + noDebug: true, + toolArgs: ['-d', 'flutter-tester'], + ), + ), + ], eagerError: true); + + // Capture the next two output events that we expect to be the Reload + // notification and then topLevelFunction being printed again. + final Future> outputEventsFuture = dap.client.output + // But skip any topLevelFunctions that come before the reload. + .skipWhile((String output) => output.startsWith('topLevelFunction')) + .take(2) + .toList(); + + await dap.client.hotReload(); + + expectLines( + (await outputEventsFuture).join(), + [ + startsWith('Reloaded'), + 'topLevelFunction', + ], + ); + + await dap.client.terminate(); + }); + + testWithoutContext('can hot restart', () async { + final BasicProject _project = BasicProject(); + await _project.setUpIn(tempDir); + + // Launch the app and wait for it to print "topLevelFunction". + await Future.wait(>[ + dap.client.outputEvents.firstWhere((OutputEventBody output) => output.output.startsWith('topLevelFunction')), + dap.client.start( + launch: () => dap.client.launch( + cwd: _project.dir.path, + noDebug: true, + toolArgs: ['-d', 'flutter-tester'], + ), + ), + ], eagerError: true); + + // Capture the next two output events that we expect to be the Restart + // notification and then topLevelFunction being printed again. + final Future> outputEventsFuture = dap.client.output + // But skip any topLevelFunctions that come before the restart. + .skipWhile((String output) => output.startsWith('topLevelFunction')) + .take(2) + .toList(); + + await dap.client.hotRestart(); + + expectLines( + (await outputEventsFuture).join(), + [ + startsWith('Restarted application'), + 'topLevelFunction', + ], + ); + + await dap.client.terminate(); + }); +} + +/// Extracts the output from a set of [OutputEventBody], removing any +/// adjacent duplicates and combining into a single string. +String _uniqueOutputLines(List outputEvents) { + String/*?*/ lastItem; + return outputEvents + .map((OutputEventBody e) => e.output) + .where((String output) { + // Skip the item if it's the same as the previous one. + final bool isDupe = output == lastItem; + lastItem = output; + return !isDupe; + }) + .join(); +} diff --git a/packages/flutter_tools/test/integration.shard/debug_adapter/test_client.dart b/packages/flutter_tools/test/integration.shard/debug_adapter/test_client.dart new file mode 100644 index 0000000000..6caa3b2d57 --- /dev/null +++ b/packages/flutter_tools/test/integration.shard/debug_adapter/test_client.dart @@ -0,0 +1,276 @@ +// 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((Event event) { + final Map body = event.body! as Map; + 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 _subscription; + final Logger? _logger; + final bool captureVmServiceTraffic; + final Map _pendingRequests = {}; + final StreamController _eventController = StreamController.broadcast(); + int _seq = 1; + late final Future vmServiceUri; + + /// Returns a stream of [OutputEventBody] events. + Stream get outputEvents => events('output') + .map((Event e) => OutputEventBody.fromJson(e.body! as Map)); + + /// Returns a stream of the string output from [OutputEventBody] events. + Stream get output => outputEvents.map((OutputEventBody output) => output.output); + + /// Sends a custom request to the server and waits for a response. + Future custom(String name, [Object? args]) async { + return sendRequest(args, overrideCommand: name); + } + + /// Returns a Future that completes with the next [event] event. + Future event(String event) => _eventController.stream.firstWhere( + (Event e) => e.event == event, + orElse: () => throw 'Did not recieve $event event before stream closed'); + + /// Returns a stream for [event] events. + Stream events(String event) { + return _eventController.stream.where((Event e) => e.event == event); + } + + /// Sends a custom request to the debug adapter to trigger a Hot Reload. + Future hotReload() { + return custom('hotReload'); + } + + /// Sends a custom request to the debug adapter to trigger a Hot Restart. + Future 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 initialize({ + String exceptionPauseMode = 'None', + bool? supportsRunInTerminalRequest, + }) async { + final List responses = await Future.wait(>[ + event('initialized'), + sendRequest(InitializeRequestArguments( + adapterID: 'test', + supportsRunInTerminalRequest: supportsRunInTerminalRequest, + )), + sendRequest( + SetExceptionBreakpointsArguments( + filters: [exceptionPauseMode], + ), + ), + ]); + await sendRequest(ConfigurationDoneArguments()); + return responses[1] as Response; // Return the initialize response. + } + + /// Send a launchRequest to the server, asking it to start a Dart program. + Future launch({ + String? program, + List? args, + List? toolArgs, + String? cwd, + bool? noDebug, + List? additionalProjectPaths, + String? console, + 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 + // (DartLaunchRequestArguments). + overrideCommand: 'launch', + ); + } + + /// Sends an arbitrary request to the server. + /// + /// Returns a Future that completes when the server returns a corresponding + /// response. + Future 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 completer = Completer(); + _pendingRequests[request.seq] = + _OutgoingRequest(completer, command, allowFailure); + _channel.sendRequest(request); + return completer.future; + } + + /// Initializes the debug adapter and launches [program]/[cwd] or calls the + /// custom [launch] method. + Future start({ + String? program, + String? cwd, + Future Function()? launch, + }) { + return Future.wait(>[ + initialize(), + launch?.call() ?? this.launch(program: program, cwd: cwd), + ], eagerError: true); + } + + Future stop() async { + _channel.close(); + await _subscription.cancel(); + } + + Future terminate() => sendRequest(TerminateArguments()); + + /// Handles an incoming message from the server, completing the relevant request + /// of raising the appropriate event. + Future _handleMessage(Object? message) async { + if (message is Response) { + final _OutgoingRequest? pendingRequest = _pendingRequests.remove(message.requestSeq); + if (pendingRequest == null) { + return; + } + final Completer completer = pendingRequest.completer; + if (message.success || pendingRequest.allowFailure) { + completer.complete(message); + } else { + completer.completeError(message); + } + } else if (message is Event) { + _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 connect( + DapTestServer server, { + bool captureVmServiceTraffic = false, + Logger? logger, + }) async { + final ByteStreamServerChannel channel = ByteStreamServerChannel(server.stream, server.sink, logger); + return DapTestClient._(channel, logger, + captureVmServiceTraffic: captureVmServiceTraffic); + } +} + +class _OutgoingRequest { + _OutgoingRequest(this.completer, this.name, this.allowFailure); + + final Completer 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 recieved, 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> collectAllOutput({ + String? program, + String? cwd, + Future Function()? start, + Future Function()? launch, + bool skipInitialPubGetOutput = true + }) async { + assert( + start == null || launch == null, + 'Only one of "start" or "launch" may be provided', + ); + final Future> 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 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; + } +} diff --git a/packages/flutter_tools/test/integration.shard/debug_adapter/test_server.dart b/packages/flutter_tools/test/integration.shard/debug_adapter/test_server.dart new file mode 100644 index 0000000000..59b6e9d122 --- /dev/null +++ b/packages/flutter_tools/test/integration.shard/debug_adapter/test_server.dart @@ -0,0 +1,135 @@ +// 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 'dart:convert'; +import 'dart:io'; + +import 'package:dds/src/dap/logging.dart'; +import 'package:flutter_tools/src/cache.dart'; +import 'package:flutter_tools/src/debug_adapters/server.dart'; +import 'package:flutter_tools/src/globals_null_migrated.dart' as globals; + +/// Enable to run from local source when running out-of-process (useful in +/// development to avoid having to keep rebuilding the flutter tool). +const bool _runFromSource = false; + +abstract class DapTestServer { + Future stop(); + StreamSink> get sink; + Stream> get stream; +} + +/// An instance of a DAP server running in-process (to aid debugging). +/// +/// All communication still goes over the socket to ensure all messages are +/// serialized and deserialized but it's not quite the same running out of +/// process. +class InProcessDapTestServer extends DapTestServer { + InProcessDapTestServer._(List args) { + _server = DapServer( + stdinController.stream, + stdoutController.sink, + fileSystem: globals.fs, + platform: globals.platform, + // Simulate flags based on the args to aid testing. + enableDds: !args.contains('--no-dds'), + ipv6: args.contains('--ipv6'), + ); + } + + late final DapServer _server; + final StreamController> stdinController = StreamController>(); + final StreamController> stdoutController = StreamController>(); + + @override + StreamSink> get sink => stdinController.sink; + + @override + Stream> get stream => stdoutController.stream; + + @override + Future stop() async { + _server.stop(); + } + + static Future create({ + Logger? logger, + List? additionalArgs, + }) async { + return InProcessDapTestServer._(additionalArgs ?? []); + } +} + +/// An instance of a DAP server running out-of-process. +/// +/// This is how an editor will usually consume DAP so is a more accurate test +/// but will be a little more difficult to debug tests as the debugger will not +/// be attached to the process. +class OutOfProcessDapTestServer extends DapTestServer { + OutOfProcessDapTestServer._( + this._process, + Logger? logger, + ) { + // Treat anything written to stderr as the DAP crashing and fail the test + // unless it's "Waiting for another flutter command to release the startup lock". + _process.stderr + .transform(utf8.decoder) + .where((String error) => !error.contains('Waiting for another flutter command to release the startup lock')) + .listen((String error) { + logger?.call(error); + throw error; + }); + unawaited(_process.exitCode.then((int code) { + final String message = 'Out-of-process DAP server terminated with code $code'; + logger?.call(message); + if (!_isShuttingDown && code != 0) { + throw message; + } + })); + } + + bool _isShuttingDown = false; + final Process _process; + + @override + StreamSink> get sink => _process.stdin; + + @override + Stream> get stream => _process.stdout; + + @override + Future stop() async { + _isShuttingDown = true; + _process.kill(); + await _process.exitCode; + } + + static Future create({ + Logger? logger, + List? additionalArgs, + }) async { + // runFromSource=true will run "dart bin/flutter_tools.dart ..." to avoid + // having to rebuild the flutter_tools snapshot. + // runFromSource=false will run "flutter ..." + + final String flutterToolPath = globals.fs.path.join(Cache.flutterRoot!, 'bin', Platform.isWindows ? 'flutter.bat' : 'flutter'); + final String flutterToolsEntryScript = globals.fs.path.join(Cache.flutterRoot!, 'packages', 'flutter_tools', 'bin', 'flutter_tools.dart'); + + // When running from source, run "dart bin/flutter_tools.dart debug_adapter" + // instead of directly using "flutter debug_adapter". + final String executable = _runFromSource + ? Platform.resolvedExecutable + : flutterToolPath; + final List args = [ + if (_runFromSource) flutterToolsEntryScript, + 'debug-adapter', + ...?additionalArgs, + ]; + + final Process _process = await Process.start(executable, args); + + return OutOfProcessDapTestServer._(_process, logger); + } +} diff --git a/packages/flutter_tools/test/integration.shard/debug_adapter/test_support.dart b/packages/flutter_tools/test/integration.shard/debug_adapter/test_support.dart new file mode 100644 index 0000000000..5aafa9497f --- /dev/null +++ b/packages/flutter_tools/test/integration.shard/debug_adapter/test_support.dart @@ -0,0 +1,76 @@ +// 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 'dart:io'; + +import 'package:dds/src/dap/logging.dart'; +import 'package:test/test.dart'; + +import 'test_client.dart'; +import 'test_server.dart'; + +/// Whether to run the DAP server in-process with the tests, or externally in +/// another process. +/// +/// By default tests will run the DAP server out-of-process to match the real +/// use from editors, but this complicates debugging the adapter. Set this env +/// variables to run the server in-process for easier debugging (this can be +/// simplified in VS Code by using a launch config with custom CodeLens links). +final bool useInProcessDap = Platform.environment['DAP_TEST_INTERNAL'] == 'true'; + +/// Whether to print all protocol traffic to stdout while running tests. +/// +/// This is useful for debugging locally or on the bots and will include both +/// DAP traffic (between the test DAP client and the DAP server) and the VM +/// Service traffic (wrapped in a custom 'dart.log' event). +final bool verboseLogging = Platform.environment['DAP_TEST_VERBOSE'] == 'true'; + +/// Expects the lines in [actual] to match the relevant matcher in [expected], +/// ignoring differences in line endings and trailing whitespace. +void expectLines(String actual, List expected) { + expect( + actual.replaceAll('\r\n', '\n').trim().split('\n'), + equals(expected), + ); +} + +/// A helper class containing the DAP server/client for DAP integration tests. +class DapTestSession { + DapTestSession._(this.server, this.client); + + DapTestServer server; + DapTestClient client; + + Future tearDown() async { + await client.stop(); + await server.stop(); + } + + static Future setUp({List? additionalArgs}) async { + final DapTestServer server = await _startServer(additionalArgs: additionalArgs); + final DapTestClient client = await DapTestClient.connect( + server, + captureVmServiceTraffic: verboseLogging, + logger: verboseLogging ? print : null, + ); + return DapTestSession._(server, client); + } + + /// Starts a DAP server that can be shared across tests. + static Future _startServer({ + Logger? logger, + List? additionalArgs, + }) async { + return useInProcessDap + ? await InProcessDapTestServer.create( + logger: logger, + additionalArgs: additionalArgs, + ) + : await OutOfProcessDapTestServer.create( + logger: logger, + additionalArgs: additionalArgs, + ); + } +} diff --git a/packages/flutter_tools/test/integration.shard/overall_experience_test.dart b/packages/flutter_tools/test/integration.shard/overall_experience_test.dart index 37cfe86a7c..8f49bba039 100644 --- a/packages/flutter_tools/test/integration.shard/overall_experience_test.dart +++ b/packages/flutter_tools/test/integration.shard/overall_experience_test.dart @@ -33,7 +33,6 @@ import 'dart:convert'; import 'dart:io'; import 'package:meta/meta.dart'; -import 'package:pedantic/pedantic.dart'; import 'package:process/process.dart'; import '../src/common.dart'; diff --git a/packages/flutter_tools/test/integration.shard/test_data/compile_error_project.dart b/packages/flutter_tools/test/integration.shard/test_data/compile_error_project.dart new file mode 100644 index 0000000000..bca20f15f7 --- /dev/null +++ b/packages/flutter_tools/test/integration.shard/test_data/compile_error_project.dart @@ -0,0 +1,32 @@ +// 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. + +// @dart = 2.8 + +import 'project.dart'; + +class CompileErrorProject extends Project { + + @override + final String pubspec = ''' + name: test + environment: + sdk: ">=2.12.0-0 <3.0.0" + + dependencies: + flutter: + sdk: flutter + '''; + + @override + final String main = r''' + import 'dart:async'; + + import 'package:flutter/material.dart'; + + Future main() async { + this code does not compile + } + '''; +}