| // 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'; |
| 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/convert.dart'; |
| import 'package:flutter_tools/src/globals.dart' as globals; |
| |
| 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_server.dart'; |
| import 'test_support.dart'; |
| |
| void main() { |
| late Directory tempDir; |
| late DapTestSession dap; |
| final String relativeMainPath = 'lib${fileSystem.path.separator}main.dart'; |
| |
| setUpAll(() { |
| Cache.flutterRoot = getFlutterRoot(); |
| }); |
| |
| setUp(() async { |
| tempDir = createResolvedTempDirectorySync('flutter_adapter_test.'); |
| dap = await DapTestSession.setUp(); |
| }); |
| |
| tearDown(() async { |
| await dap.tearDown(); |
| tryToDelete(tempDir); |
| }); |
| |
| 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<OutputEventBody> outputEvents = await dap.client.collectAllOutput( |
| launch: () => dap.client |
| .launch( |
| cwd: project.dir.path, |
| toolArgs: <String>['-d', 'flutter-tester'], |
| ), |
| ); |
| |
| final String output = _uniqueOutputLines(outputEvents); |
| |
| expectLines(output, <Object>[ |
| 'Launching $relativeMainPath on Flutter test device in debug mode...', |
| startsWith('Connecting to VM Service at'), |
| 'topLevelFunction', |
| 'Application finished.', |
| '', |
| 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<OutputEventBody> outputEvents = await dap.client.collectAllOutput( |
| launch: () => dap.client |
| .launch( |
| cwd: project.dir.path, |
| noDebug: true, |
| toolArgs: <String>['-d', 'flutter-tester'], |
| ), |
| ); |
| |
| final String output = _uniqueOutputLines(outputEvents); |
| |
| expectLines(output, <Object>[ |
| 'Launching $relativeMainPath on Flutter test device in debug mode...', |
| 'topLevelFunction', |
| 'Application finished.', |
| '', |
| startsWith('Exited'), |
| ]); |
| |
| // If we're running with an out-of-process debug adapter, ensure that its |
| // own process shuts down after we terminated. |
| final DapTestServer server = dap.server; |
| if (server is OutOfProcessDapTestServer) { |
| await server.exitCode; |
| } |
| }); |
| |
| testWithoutContext('outputs useful message on invalid DAP protocol messages', () async { |
| final OutOfProcessDapTestServer server = dap.server as OutOfProcessDapTestServer; |
| final CompileErrorProject project = CompileErrorProject(); |
| await project.setUpIn(tempDir); |
| |
| final StringBuffer stderrOutput = StringBuffer(); |
| dap.server.onStderrOutput = stderrOutput.write; |
| |
| // Write invalid headers and await the error. |
| dap.server.sink.add(utf8.encode('foo\r\nbar\r\n\r\n')); |
| await server.exitCode; |
| |
| // Verify the user-friendly message was included in the output. |
| final String error = stderrOutput.toString(); |
| expect(error, contains('Input could not be parsed as a Debug Adapter Protocol message')); |
| expect(error, contains('The "flutter debug-adapter" command is intended for use by tooling')); |
| // This test only runs with out-of-process DAP as it's testing _actual_ |
| // stderr output and that the debug-adapter process terminates, which is |
| // not possible when running the DAP Server in-process. |
| }, skip: useInProcessDap); // [intended] See above. |
| |
| testWithoutContext('correctly outputs launch errors and terminates', () async { |
| final CompileErrorProject project = CompileErrorProject(); |
| await project.setUpIn(tempDir); |
| |
| final List<OutputEventBody> outputEvents = await dap.client.collectAllOutput( |
| launch: () => dap.client |
| .launch( |
| cwd: project.dir.path, |
| toolArgs: <String>['-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)')); |
| }); |
| |
| /// Helper that tests exception output in either debug or noDebug mode. |
| Future<void> testExceptionOutput({required bool noDebug}) async { |
| final BasicProjectThatThrows project = BasicProjectThatThrows(); |
| await project.setUpIn(tempDir); |
| |
| final List<OutputEventBody> outputEvents = |
| await dap.client.collectAllOutput(launch: () { |
| // Terminate the app after we see the exception because otherwise |
| // it will keep running and `collectAllOutput` won't end. |
| dap.client.output |
| .firstWhere((String output) => output.contains(endOfErrorOutputMarker)) |
| .then((_) => dap.client.terminate()); |
| return dap.client.launch( |
| noDebug: noDebug, |
| cwd: project.dir.path, |
| toolArgs: <String>['-d', 'flutter-tester'], |
| ); |
| }); |
| |
| final String output = _uniqueOutputLines(outputEvents); |
| final List<String> outputLines = output.split('\n'); |
| expect( outputLines, containsAllInOrder(<String>[ |
| '══╡ EXCEPTION CAUGHT BY WIDGETS LIBRARY ╞═══════════════════════════════════════════════════════════', |
| 'The following _Exception was thrown building App(dirty):', |
| 'Exception: c', |
| 'The relevant error-causing widget was:', |
| ])); |
| expect(output, contains('App:${Uri.file(project.dir.path)}/lib/main.dart:24:12')); |
| } |
| |
| testWithoutContext('correctly outputs exceptions in debug mode', () => testExceptionOutput(noDebug: false)); |
| |
| testWithoutContext('correctly outputs exceptions in noDebug mode', () => testExceptionOutput(noDebug: true)); |
| |
| 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(<Future<void>>[ |
| dap.client.stdoutOutput.firstWhere((String output) => output.startsWith('topLevelFunction')), |
| dap.client.start( |
| launch: () => dap.client.launch( |
| cwd: project.dir.path, |
| noDebug: true, |
| toolArgs: <String>['-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<List<String>> 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(), |
| <Object>[ |
| 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(<Future<void>>[ |
| dap.client.stdoutOutput.firstWhere((String output) => output.startsWith('topLevelFunction')), |
| dap.client.start( |
| launch: () => dap.client.launch( |
| cwd: project.dir.path, |
| noDebug: true, |
| toolArgs: <String>['-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<List<String>> 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(), |
| <Object>[ |
| 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(<Future<void>>[ |
| // 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: <String>['-d', 'flutter-tester'], |
| ), |
| ), |
| ], eagerError: true); |
| |
| // Hot restart, ensuring it completes and capturing the ID of the new thread |
| // to pause. |
| await Future.wait(<Future<void>>[ |
| // 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<String> isolateIdForDebugPaint = dap.client |
| .serviceExtensionAdded(debugPaintRpc) |
| .then((Map<String, Object?> 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(<Future<void>>[ |
| dap.client.stdoutOutput.firstWhere((String output) => output.startsWith('topLevelFunction')), |
| dap.client.start( |
| launch: () => dap.client.launch( |
| cwd: project.dir.path, |
| toolArgs: <String>['-d', 'flutter-tester'], |
| ), |
| ), |
| ], eagerError: true); |
| |
| // Capture the next relevant state-change event (which should occur as a |
| // result of the call below). |
| final Future<Map<String, Object?>> stateChangeEventFuture = |
| dap.client.serviceExtensionStateChanged(debugPaintRpc); |
| |
| // Enable debug paint to trigger the state change. |
| await dap.client.custom( |
| 'callService', |
| <String, Object?>{ |
| 'method': debugPaintRpc, |
| 'params': <String, Object?>{ |
| 'enabled': true, |
| 'isolateId': await isolateIdForDebugPaint, |
| }, |
| }, |
| ); |
| |
| // Ensure the event occurred, and its value was as expected. |
| final Map<String, Object?> stateChangeEvent = await stateChangeEventFuture; |
| expect(stateChangeEvent['value'], 'true'); // extension state change values are always strings |
| |
| await dap.client.terminate(); |
| }); |
| |
| testWithoutContext('provides appStarted events to the client', () async { |
| final BasicProject project = BasicProject(); |
| await project.setUpIn(tempDir); |
| |
| // Launch the app and wait for it to send a 'flutter.appStarted' event. |
| await Future.wait(<Future<void>>[ |
| dap.client.event('flutter.appStarted'), |
| dap.client.start( |
| launch: () => dap.client.launch( |
| cwd: project.dir.path, |
| toolArgs: <String>['-d', 'flutter-tester'], |
| ), |
| ), |
| ], eagerError: true); |
| |
| await dap.client.terminate(); |
| }); |
| }); |
| |
| 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(<Future<void>>[ |
| dap.client.stdoutOutput.firstWhere((String output) => output.startsWith('topLevelFunction')), |
| dap.client.start( |
| launch: () => dap.client.attach( |
| cwd: project.dir.path, |
| toolArgs: <String>['-d', 'flutter-tester'], |
| vmServiceUri: vmServiceUri.toString(), |
| ), |
| ), |
| ], eagerError: true); |
| |
| // Capture the "Reloaded" output and events immediately after. |
| final Future<List<String>> 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(), |
| <Object>[ |
| 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(<Future<void>>[ |
| dap.client.stdoutOutput.firstWhere((String output) => output.startsWith('topLevelFunction')), |
| dap.client.start( |
| launch: () => dap.client.attach( |
| cwd: project.dir.path, |
| toolArgs: <String>['-d', 'flutter-tester'], |
| vmServiceUri: vmServiceUri.toString(), |
| ), |
| ), |
| ], eagerError: true); |
| |
| // Set a breakpoint and expect to hit it. |
| final Future<StoppedEventBody> stoppedFuture = dap.client.stoppedEvents.firstWhere((StoppedEventBody e) => e.reason == 'breakpoint'); |
| await Future.wait(<Future<void>>[ |
| stoppedFuture, |
| dap.client.setBreakpoint(breakpointFilePath, breakpointLine), |
| ], eagerError: true); |
| }); |
| |
| testWithoutContext('resumes and removes breakpoints on detach', () async { |
| final Uri vmServiceUri = await testProcess.vmServiceUri; |
| |
| // Launch the app and wait for it to print "topLevelFunction". |
| await Future.wait(<Future<void>>[ |
| dap.client.stdoutOutput.firstWhere((String output) => output.startsWith('topLevelFunction')), |
| dap.client.start( |
| launch: () => dap.client.attach( |
| cwd: project.dir.path, |
| toolArgs: <String>['-d', 'flutter-tester'], |
| vmServiceUri: vmServiceUri.toString(), |
| ), |
| ), |
| ], eagerError: true); |
| |
| // Set a breakpoint and expect to hit it. |
| final Future<StoppedEventBody> stoppedFuture = dap.client.stoppedEvents.firstWhere((StoppedEventBody e) => e.reason == 'breakpoint'); |
| await Future.wait(<Future<void>>[ |
| stoppedFuture, |
| dap.client.setBreakpoint(breakpointFilePath, breakpointLine), |
| ], eagerError: true); |
| |
| // Detach. |
| await dap.client.terminate(); |
| |
| // Ensure we get additional output (confirming the process resumed). |
| await testProcess.output.first; |
| }); |
| }); |
| } |
| |
| /// Extracts the output from a set of [OutputEventBody], removing any |
| /// adjacent duplicates and combining into a single string. |
| String _uniqueOutputLines(List<OutputEventBody> 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(); |
| } |