diff --git a/packages/flutter_tools/lib/src/debug_adapters/README.md b/packages/flutter_tools/lib/src/debug_adapters/README.md index 7aa0b6e0df5b..69ff23334f5a 100644 --- a/packages/flutter_tools/lib/src/debug_adapters/README.md +++ b/packages/flutter_tools/lib/src/debug_adapters/README.md @@ -27,18 +27,20 @@ Arguments common to both `launchRequest` and `attachRequest` are: - `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 +- `String? cwd` - the working directory for the Flutter process to be spawned in +- `List? toolArgs` - arguments for the `flutter run`, `flutter attach` or `flutter test` commands +- `String? customTool` - an optional tool to run instead of `flutter` - the custom tool must be completely compatible with the tool/command it is replacing +- `int? customToolReplacesArgs` - the number of arguments to delete from the beginning of the argument list when invoking `customTool` - e.g. setting `customTool` to `flutter_test_wrapper` and `customToolReplacesArgs` to `1` for a test run would invoke `flutter_test_wrapper foo_test.dart` instead of `flutter test foo_test.dart` (if larger than the number of computed arguments all arguments will be removed, if not supplied will default to `0`) 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 run` or `flutter test` commands -- `String? customTool` - an optional tool to run instead of `flutter` - the custom tool must be completely compatible with the tool/command it is replacing -- `int? customToolReplacesArgs` - the number of arguments to delete from the beginning of the argument list when invoking `customTool` - e.g. setting `customTool` to `flutter_test_wrapper` and `customToolReplacesArgs` to `1` for a test run would invoke `flutter_test_wrapper foo_test.dart` instead of `flutter test foo_test.dart` (if larger than the number of computed arguments all arguments will be removed, if not supplied will default to `0`) -`attachRequest` is not currently supported, but will be documented here when it is. +Arguments specific to `attachRequest` are: + +- `String? vmServiceUri` - the VM Service URI to attach to (if not supplied, Flutter will try to discover it from the device) ## Custom Requests diff --git a/packages/flutter_tools/lib/src/debug_adapters/flutter_adapter.dart b/packages/flutter_tools/lib/src/debug_adapters/flutter_adapter.dart index e74c1a79555c..94eb1a77d711 100644 --- a/packages/flutter_tools/lib/src/debug_adapters/flutter_adapter.dart +++ b/packages/flutter_tools/lib/src/debug_adapters/flutter_adapter.dart @@ -83,11 +83,51 @@ class FlutterDebugAdapter extends DartDebugAdapter false; + /// Whether or not the user requested debugging be enabled. + /// + /// debug/noDebug here refers to the DAP "debug" mode and not the Flutter + /// debug mode (vs Profile/Release). It is provided by the client editor based + /// on whether a user chooses to "Run" or "Debug" their app. + /// + /// This is always enabled for attach requests, but can be disabled for launch + /// requests via DAP's `noDebug` flag. If `noDebug` is not provided, will + /// default to debugging. + /// + /// When not debugging, we will not connect to the VM Service so some + /// functionality (breakpoints, evaluation, etc.) will not be available. + /// Functionality provided via the daemon (hot reload/restart) will still be + /// available. + bool get debug { + final DartCommonLaunchAttachRequestArguments args = this.args; + if (args is FlutterLaunchRequestArguments) { + // Invert DAP's noDebug flag, treating it as false (so _do_ debug) if not + // provided. + return !(args.noDebug ?? false); + } + + // Otherwise (attach), always debug. + return true; + } + /// 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(); + final FlutterAttachRequestArguments args = this.args as FlutterAttachRequestArguments; + + final String? vmServiceUri = args.vmServiceUri; + final List toolArgs = [ + 'attach', + '--machine', + if (vmServiceUri != null) + ...['--debug-uri', vmServiceUri], + ]; + + await _startProcess( + toolArgs: toolArgs, + customTool: args.customTool, + customToolReplacesArgs: args.customToolReplacesArgs, + userToolArgs: args.toolArgs, + ); } /// [customRequest] handles any messages that do not match standard messages in the spec. @@ -171,34 +211,46 @@ class FlutterDebugAdapter extends DartDebugAdapter launchImpl() async { final FlutterLaunchRequestArguments args = this.args as FlutterLaunchRequestArguments; - // "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', ]; + await _startProcess( + toolArgs: toolArgs, + customTool: args.customTool, + customToolReplacesArgs: args.customToolReplacesArgs, + targetProgram: args.program, + userToolArgs: args.toolArgs, + userArgs: args.args, + ); + } + + /// Starts the `flutter` process to run/attach to the required app. + Future _startProcess({ + required String? customTool, + required int? customToolReplacesArgs, + required List toolArgs, + required List? userToolArgs, + String? targetProgram, + List? userArgs, + }) async { // Handle customTool and deletion of any arguments for it. - final String executable = args.customTool ?? fileSystem.path.join(Cache.flutterRoot!, 'bin', platform.isWindows ? 'flutter.bat' : 'flutter'); - final int? removeArgs = args.customToolReplacesArgs; - if (args.customTool != null && removeArgs != null) { + final String executable = customTool ?? fileSystem.path.join(Cache.flutterRoot!, 'bin', platform.isWindows ? 'flutter.bat' : 'flutter'); + final int? removeArgs = customToolReplacesArgs; + if (customTool != null && removeArgs != null) { toolArgs.removeRange(0, math.min(removeArgs, toolArgs.length)); } final List processArgs = [ ...toolArgs, - ...?args.toolArgs, - if (program != null) ...[ + ...?userToolArgs, + if (targetProgram != null) ...[ '--target', - program, + targetProgram, ], - ...?args.args, + ...?userArgs, ]; // Find the package_config file for this script. This is used by the @@ -207,12 +259,12 @@ class FlutterDebugAdapter extends DartDebugAdapter 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; } @@ -411,13 +461,13 @@ class FlutterDebugAdapter extends DartDebugAdapter{ 'appId': _appId, 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 index b5c15a29b53e..efceb87afffc 100644 --- a/packages/flutter_tools/lib/src/debug_adapters/flutter_adapter_args.dart +++ b/packages/flutter_tools/lib/src/debug_adapters/flutter_adapter_args.dart @@ -7,12 +7,16 @@ 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 +/// adapter in attachRequest, which is a request to attach to/debug a running /// application. class FlutterAttachRequestArguments extends DartCommonLaunchAttachRequestArguments implements AttachRequestArguments { FlutterAttachRequestArguments({ + this.toolArgs, + this.customTool, + this.customToolReplacesArgs, + this.vmServiceUri, Object? restart, String? name, String? cwd, @@ -34,11 +38,49 @@ class FlutterAttachRequestArguments sendLogsToClient: sendLogsToClient, ); - FlutterAttachRequestArguments.fromMap(Map obj): + FlutterAttachRequestArguments.fromMap(Map obj) + : toolArgs = (obj['toolArgs'] as List?)?.cast(), + customTool = obj['customTool'] as String?, + customToolReplacesArgs = obj['customToolReplacesArgs'] as int?, + vmServiceUri = obj['vmServiceUri'] as String?, super.fromMap(obj); static FlutterAttachRequestArguments fromJson(Map obj) => FlutterAttachRequestArguments.fromMap(obj); + + /// Arguments to be passed to the tool that will run [program] (for example, the VM or Flutter tool). + final List? toolArgs; + + /// An optional tool to run instead of "flutter". + /// + /// In combination with [customToolReplacesArgs] allows invoking a custom + /// tool instead of "flutter" to launch scripts/tests. The custom tool must be + /// completely compatible with the tool/command it is replacing. + /// + /// This field should be a full absolute path if the tool may not be available + /// in `PATH`. + final String? customTool; + + /// The number of arguments to delete from the beginning of the argument list + /// when invoking [customTool]. + /// + /// For example, setting [customTool] to `flutter_test_wrapper` and + /// `customToolReplacesArgs` to `1` for a test run would invoke + /// `flutter_test_wrapper foo_test.dart` instead of `flutter test foo_test.dart`. + final int? customToolReplacesArgs; + + /// The VM Service URI of the running Flutter app to connect to. + final String? vmServiceUri; + + @override + Map toJson() => { + ...super.toJson(), + if (toolArgs != null) 'toolArgs': toolArgs, + if (customTool != null) 'customTool': customTool, + if (customToolReplacesArgs != null) + 'customToolReplacesArgs': customToolReplacesArgs, + if (vmServiceUri != null) 'vmServiceUri': vmServiceUri, + }; } /// An implementation of [LaunchRequestArguments] that includes all fields used by the Flutter debug adapter. 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 index 31405115394c..e6986409a324 100644 --- 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 @@ -4,9 +4,11 @@ import 'dart:async'; +import 'package:dds/dap.dart'; import 'package:dds/src/dap/protocol_generated.dart'; import 'package:file/file.dart'; import 'package:flutter_tools/src/cache.dart'; +import 'package:flutter_tools/src/globals.dart' as globals; import '../../src/common.dart'; import '../test_data/basic_project.dart'; @@ -34,236 +36,324 @@ void main() { 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.output - .firstWhere((String output) => output.startsWith('topLevelFunction')) - .whenComplete(() => dap.client.terminate()), - ); - - final List outputEvents = await dap.client.collectAllOutput( - launch: () => dap.client - .launch( + group('launch', () { + 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.output + .firstWhere((String 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.stdoutOutput + .firstWhere((String 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.stdoutOutput.firstWhere((String output) => output.startsWith('topLevelFunction')), + dap.client.start( + 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...', - 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.stdoutOutput - .firstWhere((String output) => output.startsWith('topLevelFunction')) - .whenComplete(() => dap.client.terminate()), - ); - - final List outputEvents = await dap.client.collectAllOutput( - launch: () => dap.client - .launch( + ), + ], 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.stdoutOutput + // 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.stdoutOutput.firstWhere((String output) => output.startsWith('topLevelFunction')), + dap.client.start( + 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( + ), + ], 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.stdoutOutput + // 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(); + }); + + testWithoutContext('can hot restart when exceptions occur on outgoing isolates', () async { + final BasicProjectThatThrows project = BasicProjectThatThrows(); + await project.setUpIn(tempDir); + + // Launch the app and wait for it to stop at an exception. + late int originalThreadId, newThreadId; + await Future.wait(>[ + // Capture the thread ID of the stopped thread. + dap.client.stoppedEvents.first.then((StoppedEventBody event) => originalThreadId = event.threadId!), + dap.client.start( + exceptionPauseMode: 'All', // Ensure we stop on all exceptions + 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.stdoutOutput.firstWhere((String 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.stdoutOutput - // 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.stdoutOutput.firstWhere((String output) => output.startsWith('topLevelFunction')), - dap.client.start( - launch: () => dap.client.launch( - cwd: project.dir.path, - noDebug: true, - toolArgs: ['-d', 'flutter-tester'], + ], eagerError: true); + + // Hot restart, ensuring it completes and capturing the ID of the new thread + // to pause. + await Future.wait(>[ + // Capture the thread ID of the newly stopped thread. + dap.client.stoppedEvents.first.then((StoppedEventBody event) => newThreadId = event.threadId!), + dap.client.hotRestart(), + ], eagerError: true); + + // We should not have stopped on the original thread, but the new thread + // from after the restart. + expect(newThreadId, isNot(equals(originalThreadId))); + + await dap.client.terminate(); + }); + + testWithoutContext('sends events for extension state updates', () async { + final BasicProject project = BasicProject(); + await project.setUpIn(tempDir); + const String debugPaintRpc = 'ext.flutter.debugPaint'; + + // Create a future to capture the isolate ID when the debug paint service + // extension loads, as we'll need that to call it later. + final Future isolateIdForDebugPaint = dap.client + .serviceExtensionAdded(debugPaintRpc) + .then((Map body) => body['isolateId']! as String); + + // Launch the app and wait for it to print "topLevelFunction" so we know + // it's up and running. + await Future.wait(>[ + dap.client.stdoutOutput.firstWhere((String output) => output.startsWith('topLevelFunction')), + dap.client.start( + launch: () => dap.client.launch( + cwd: project.dir.path, + 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.stdoutOutput - // 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(); - }); + ], eagerError: true); + + // Capture the next relevant state-change event (which should occur as a + // result of the call below). + final Future> stateChangeEventFuture = + dap.client.serviceExtensionStateChanged(debugPaintRpc); + + // Enable debug paint to trigger the state change. + await dap.client.custom( + 'callService', + { + 'method': debugPaintRpc, + 'params': { + 'enabled': true, + 'isolateId': await isolateIdForDebugPaint, + }, + }, + ); - testWithoutContext('can hot restart when exceptions occur on outgoing isolates', () async { - final BasicProjectThatThrows project = BasicProjectThatThrows(); - await project.setUpIn(tempDir); - - // Launch the app and wait for it to stop at an exception. - late int originalThreadId, newThreadId; - await Future.wait(>[ - // Capture the thread ID of the stopped thread. - dap.client.stoppedEvents.first.then((StoppedEventBody event) => originalThreadId = event.threadId!), - dap.client.start( - exceptionPauseMode: 'All', // Ensure we stop on all exceptions - launch: () => dap.client.launch( - cwd: project.dir.path, - toolArgs: ['-d', 'flutter-tester'], - ), - ), - ], eagerError: true); - - // Hot restart, ensuring it completes and capturing the ID of the new thread - // to pause. - await Future.wait(>[ - // Capture the thread ID of the newly stopped thread. - dap.client.stoppedEvents.first.then((StoppedEventBody event) => newThreadId = event.threadId!), - dap.client.hotRestart(), - ], eagerError: true); - - // We should not have stopped on the original thread, but the new thread - // from after the restart. - expect(newThreadId, isNot(equals(originalThreadId))); - - await dap.client.terminate(); + // Ensure the event occurred, and its value was as expected. + final Map stateChangeEvent = await stateChangeEventFuture; + expect(stateChangeEvent['value'], 'true'); // extension state change values are always strings + + await dap.client.terminate(); + }); }); - testWithoutContext('sends events for extension state updates', () async { - final BasicProject project = BasicProject(); - await project.setUpIn(tempDir); - const String debugPaintRpc = 'ext.flutter.debugPaint'; - - // Create a future to capture the isolate ID when the debug paint service - // extension loads, as we'll need that to call it later. - final Future isolateIdForDebugPaint = dap.client - .serviceExtensionAdded(debugPaintRpc) - .then((Map body) => body['isolateId']! as String); - - // Launch the app and wait for it to print "topLevelFunction" so we know - // it's up and running. - await Future.wait(>[ - dap.client.stdoutOutput.firstWhere((String output) => output.startsWith('topLevelFunction')), - dap.client.start( - launch: () => dap.client.launch( - cwd: project.dir.path, - toolArgs: ['-d', 'flutter-tester'], + group('attach', () { + late SimpleFlutterRunner testProcess; + late BasicProject project; + late String breakpointFilePath; + late int breakpointLine; + setUp(() async { + project = BasicProject(); + await project.setUpIn(tempDir); + testProcess = await SimpleFlutterRunner.start(tempDir); + + breakpointFilePath = globals.fs.path.join(project.dir.path, 'lib', 'main.dart'); + breakpointLine = project.buildMethodBreakpointLine; + }); + + tearDown(() async { + testProcess.process.kill(); + await testProcess.process.exitCode; + }); + + testWithoutContext('can attach to an already-running Flutter app and reload', () async { + final Uri vmServiceUri = await testProcess.vmServiceUri; + + // Launch the app and wait for it to print "topLevelFunction". + await Future.wait(>[ + dap.client.stdoutOutput.firstWhere((String output) => output.startsWith('topLevelFunction')), + dap.client.start( + launch: () => dap.client.attach( + cwd: project.dir.path, + toolArgs: ['-d', 'flutter-tester'], + vmServiceUri: vmServiceUri.toString(), + ), ), - ), - ], eagerError: true); - - // Capture the next relevant state-change event (which should occur as a - // result of the call below). - final Future> stateChangeEventFuture = - dap.client.serviceExtensionStateChanged(debugPaintRpc); - - // Enable debug paint to trigger the state change. - await dap.client.custom( - 'callService', - { - 'method': debugPaintRpc, - 'params': { - 'enabled': true, - 'isolateId': await isolateIdForDebugPaint, - }, - }, - ); - - // Ensure the event occurred, and its value was as expected. - final Map stateChangeEvent = await stateChangeEventFuture; - expect(stateChangeEvent['value'], 'true'); // extension state change values are always strings - - await dap.client.terminate(); + ], eagerError: true); + + // Capture the "Reloaded" output and events immediately after. + final Future> outputEventsFuture = dap.client.stdoutOutput + .skipWhile((String output) => !output.startsWith('Reloaded')) + .take(4) + .toList(); + + // Perform the reload, and expect we get the Reloaded output followed + // by printed output, to ensure the app is running again. + await dap.client.hotReload(); + expectLines( + (await outputEventsFuture).join(), + [ + startsWith('Reloaded'), + 'topLevelFunction', + ], + allowExtras: true, + ); + + await dap.client.terminate(); + }); + + testWithoutContext('can attach to an already-running Flutter app and hit breakpoints', () async { + final Uri vmServiceUri = await testProcess.vmServiceUri; + + // Launch the app and wait for it to print "topLevelFunction". + await Future.wait(>[ + dap.client.stdoutOutput.firstWhere((String output) => output.startsWith('topLevelFunction')), + dap.client.start( + launch: () => dap.client.attach( + cwd: project.dir.path, + toolArgs: ['-d', 'flutter-tester'], + vmServiceUri: vmServiceUri.toString(), + ), + ), + ], eagerError: true); + + // Set a breakpoint and expect to hit it. + final Future stoppedFuture = dap.client.stoppedEvents.firstWhere((StoppedEventBody e) => e.reason == 'breakpoint'); + await Future.wait(>[ + stoppedFuture, + dap.client.setBreakpoint(breakpointFilePath, breakpointLine), + ], eagerError: true); + final int threadId = (await stoppedFuture).threadId!; + + // Remove the breakpoint and resume. + await dap.client.clearBreakpoints(breakpointFilePath); + await dap.client.continue_(threadId); + + await dap.client.terminate(); + }); }); } 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 index 4a3a08acad1a..76c42d301ddb 100644 --- a/packages/flutter_tools/test/integration.shard/debug_adapter/test_client.dart +++ b/packages/flutter_tools/test/integration.shard/debug_adapter/test_client.dart @@ -133,7 +133,7 @@ class DapTestClient { return responses[1] as Response; // Return the initialize response. } - /// Send a launchRequest to the server, asking it to start a Dart program. + /// Send a launchRequest to the server, asking it to start a Flutter app. Future launch({ String? program, List? args, @@ -141,7 +141,6 @@ class DapTestClient { String? cwd, bool? noDebug, List? additionalProjectPaths, - String? console, bool? debugSdkLibraries, bool? debugExternalPackageLibraries, bool? evaluateGettersInDebugViews, @@ -165,11 +164,43 @@ class DapTestClient { sendLogsToClient: captureVmServiceTraffic, ), // We can't automatically pick the command when using a custom type - // (DartLaunchRequestArguments). + // (FlutterLaunchRequestArguments). overrideCommand: 'launch', ); } + /// Send an attachRequest to the server, asking it to attach to an already-running Flutter app. + Future attach({ + List? toolArgs, + String? vmServiceUri, + String? cwd, + List? 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 @@ -357,4 +388,33 @@ extension DapTestClientExtension on DapTestClient { ); } + /// Sets a breakpoint at [line] in [file]. + Future setBreakpoint(String filePath, int line) async { + await sendRequest( + SetBreakpointsArguments( + source: Source(path: filePath), + breakpoints: [ + SourceBreakpoint(line: line), + ], + ), + ); + } + + /// Sends a continue request for the given thread. + /// + /// Returns a Future that completes when the server returns a corresponding + /// response. + Future continue_(int threadId) => + sendRequest(ContinueArguments(threadId: threadId)); + + /// Clears breakpoints in [file]. + Future clearBreakpoints(String filePath) async { + await sendRequest( + SetBreakpointsArguments( + source: Source(path: filePath), + breakpoints: [], + ), + ); + } + } 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 index 399bb46ef0fc..2d747e768afb 100644 --- a/packages/flutter_tools/test/integration.shard/debug_adapter/test_support.dart +++ b/packages/flutter_tools/test/integration.shard/debug_adapter/test_support.dart @@ -5,7 +5,13 @@ import 'dart:async'; import 'dart:io'; +import 'package:dds/dap.dart'; import 'package:dds/src/dap/logging.dart'; +import 'package:file/file.dart'; +import 'package:flutter_tools/src/base/io.dart'; +import 'package:flutter_tools/src/cache.dart'; +import 'package:flutter_tools/src/convert.dart'; +import 'package:flutter_tools/src/globals.dart' as globals; import 'package:test/test.dart'; import 'test_client.dart'; @@ -47,6 +53,72 @@ void expectLines( } } +/// Manages running a simple Flutter app to be used in tests that need to attach +/// to an existing process. +class SimpleFlutterRunner { + SimpleFlutterRunner(this.process) { + process.stdout.transform(ByteToLineTransformer()).listen(_handleStdout); + process.stderr.transform(utf8.decoder).listen(_handleStderr); + unawaited(process.exitCode.then(_handleExitCode)); + } + + void _handleExitCode(int code) { + if (!_vmServiceUriCompleter.isCompleted) { + _vmServiceUriCompleter.completeError('Flutter process ended without producing a VM Service URI'); + } + } + + void _handleStderr(String err) { + if (!_vmServiceUriCompleter.isCompleted) { + _vmServiceUriCompleter.completeError(err); + } + } + + void _handleStdout(String outputLine) { + try { + final Object? json = jsonDecode(outputLine); + // Flutter --machine output is wrapped in [brackets] so will deserialize + // as a list with one item. + if (json is List && json.length == 1) { + final Object? message = json.single; + // Parse the add.debugPort event which contains our VM Service URI. + if (message is Map && message['event'] == 'app.debugPort') { + final String vmServiceUri = (message['params']! as Map)['wsUri']! as String; + if (!_vmServiceUriCompleter.isCompleted) { + _vmServiceUriCompleter.complete(Uri.parse(vmServiceUri)); + } + } + } + } on FormatException { + // `flutter run` writes a lot of text to stdout so just ignore anything + // that's not valid JSON. + } + } + + final Process process; + final Completer _vmServiceUriCompleter = Completer(); + Future get vmServiceUri => _vmServiceUriCompleter.future; + + static Future start(Directory projectDirectory) async { + final String flutterToolPath = globals.fs.path.join(Cache.flutterRoot!, 'bin', globals.platform.isWindows ? 'flutter.bat' : 'flutter'); + + final List args = [ + 'run', + '--machine', + '-d', + 'flutter-tester', + ]; + + final Process process = await Process.start( + flutterToolPath, + args, + workingDirectory: projectDirectory.path, + ); + + return SimpleFlutterRunner(process); + } +} + /// A helper class containing the DAP server/client for DAP integration tests. class DapTestSession { DapTestSession._(this.server, this.client);