// 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: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_data/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'), ], allowExtras: true, ); }); testWithoutContext('logs to client when sendLogsToClient=true', () 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'], sendLogsToClient: true, ), ), ], eagerError: true); // Capture events while terminating. final Future<List<Event>> logEventsFuture = dap.client.events('dart.log').toList(); await dap.client.terminate(); // Ensure logs contain both the app.stop request and the result. final List<Event> logEvents = await logEventsFuture; final List<String> logMessages = logEvents.map((Event l) => (l.body! as Map<String, Object?>)['message']! as String).toList(); expect( logMessages, containsAll(<Matcher>[ startsWith('==> [Flutter] [{"id":1,"method":"app.stop"'), startsWith('<== [Flutter] [{"id":1,"result":true}]'), ]), ); }); 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'), ], allowExtras: true, ); // 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)')); }); group('structured errors', () { /// Helper that runs [project] and collects the output. /// /// Line and column numbers are replaced with "1" to avoid fragile tests. Future<String> getExceptionOutput( Project project, { required bool noDebug, required bool ansiColors, }) async { 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'], allowAnsiColorOutput: ansiColors, ); }); String output = _uniqueOutputLines(outputEvents); // Replace out any line/columns to make tests less fragile. output = output.replaceAll(RegExp(r'\.dart:\d+:\d+'), '.dart:1:1'); return output; } testWithoutContext('correctly outputs exceptions in debug mode', () async { final BasicProjectThatThrows project = BasicProjectThatThrows(); final String output = await getExceptionOutput(project, noDebug: false, ansiColors: false); expect( output, contains(''' ════════ Exception caught by widgets library ═══════════════════════════════════ The following _Exception was thrown building App(dirty): Exception: c The relevant error-causing widget was: App App:${Uri.file(project.dir.path)}/lib/main.dart:1:1'''), ); }); testWithoutContext('correctly outputs colored exceptions when supported', () async { final BasicProjectThatThrows project = BasicProjectThatThrows(); final String output = await getExceptionOutput(project, noDebug: false, ansiColors: true); // Frames in the stack trace that are the users own code will be unformatted, but // frames from the framework are faint (starting with `\x1B[2m`). expect( output, contains(''' ════════ Exception caught by widgets library ═══════════════════════════════════ The following _Exception was thrown building App(dirty): Exception: c The relevant error-causing widget was: App App:${Uri.file(project.dir.path)}/lib/main.dart:1:1 When the exception was thrown, this was the stack: #0 c (package:test/main.dart:1:1) ^ source: package:test/main.dart #1 App.build (package:test/main.dart:1:1) ^ source: package:test/main.dart \x1B[2m#2 StatelessElement.build (package:flutter/src/widgets/framework.dart:1:1)\x1B[0m ^ source: package:flutter/src/widgets/framework.dart \x1B[2m#3 ComponentElement.performRebuild (package:flutter/src/widgets/framework.dart:1:1)\x1B[0m ^ source: package:flutter/src/widgets/framework.dart'''), ); }); testWithoutContext('correctly outputs exceptions in noDebug mode', () async { final BasicProjectThatThrows project = BasicProjectThatThrows(); final String output = await getExceptionOutput(project, noDebug: true, ansiColors: false); // When running in noDebug mode, we don't get the Flutter.Error event so // we get the basic Flutter-formatted version of the error. expect( output, contains(''' ══╡ EXCEPTION CAUGHT BY WIDGETS LIBRARY ╞═══════════════════════════════════════════════════════════ The following _Exception was thrown building App(dirty): Exception: c The relevant error-causing widget was: App'''), ); expect( output, contains('App:${Uri.file(project.dir.path)}/lib/main.dart:1: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(<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', ], allowExtras: true, ); // Repeat the test for hot reload with custom syntax. final Future<List<String>> customOutputEventsFuture = dap.client.stdoutOutput // But skip any topLevelFunctions that come before the reload. .skipWhile((String output) => output.startsWith('topLevelFunction')) .take(2) .toList(); await dap.client.customSyntaxHotReload(); expectLines( (await customOutputEventsFuture).join(), <Object>[ startsWith('Reloaded'), 'topLevelFunction', ], allowExtras: true, ); await dap.client.terminate(); }); testWithoutContext('sends progress notifications during 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.initialize(supportsProgressReporting: true), dap.client.launch( cwd: project.dir.path, noDebug: true, toolArgs: <String>['-d', 'flutter-tester'], ), ], eagerError: true); // Capture progress events during a reload. final Future<List<Event>> progressEventsFuture = dap.client.progressEvents().toList(); await dap.client.hotReload(); await dap.client.terminate(); // Verify the progress events. final List<Event> progressEvents = await progressEventsFuture; expect(progressEvents, hasLength(2)); final List<String> eventKinds = progressEvents.map((Event event) => event.event).toList(); expect(eventKinds, <String>['progressStart', 'progressEnd']); final List<Map<String, Object?>> eventBodies = progressEvents.map((Event event) => event.body).cast<Map<String, Object?>>().toList(); final ProgressStartEventBody start = ProgressStartEventBody.fromMap(eventBodies[0]); final ProgressEndEventBody end = ProgressEndEventBody.fromMap(eventBodies[1]); expect(start.progressId, isNotNull); expect(start.title, 'Flutter'); expect(start.message, 'Hot reloading…'); expect(end.progressId, start.progressId); expect(end.message, isNull); }); 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', ], allowExtras: true, ); await dap.client.terminate(); }); testWithoutContext('sends progress notifications during 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.initialize(supportsProgressReporting: true), dap.client.launch( cwd: project.dir.path, noDebug: true, toolArgs: <String>['-d', 'flutter-tester'], ), ], eagerError: true); // Capture progress events during a restart. final Future<List<Event>> progressEventsFuture = dap.client.progressEvents().toList(); await dap.client.hotRestart(); await dap.client.terminate(); // Verify the progress events. final List<Event> progressEvents = await progressEventsFuture; expect(progressEvents, hasLength(2)); final List<String> eventKinds = progressEvents.map((Event event) => event.event).toList(); expect(eventKinds, <String>['progressStart', 'progressEnd']); final List<Map<String, Object?>> eventBodies = progressEvents.map((Event event) => event.body).cast<Map<String, Object?>>().toList(); final ProgressStartEventBody start = ProgressStartEventBody.fromMap(eventBodies[0]); final ProgressEndEventBody end = ProgressEndEventBody.fromMap(eventBodies[1]); expect(start.progressId, isNotNull); expect(start.title, 'Flutter'); expect(start.message, 'Hot restarting…'); expect(end.progressId, start.progressId); expect(end.message, isNull); }); 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.appStart' event. final Future<Event> appStartFuture = dap.client.event('flutter.appStart'); await Future.wait(<Future<void>>[ appStartFuture, dap.client.start( launch: () => dap.client.launch( cwd: project.dir.path, toolArgs: <String>['-d', 'flutter-tester'], ), ), ], eagerError: true); await dap.client.terminate(); final Event appStart = await appStartFuture; final Map<String, Object?> params = appStart.body! as Map<String, Object?>; expect(params['deviceId'], 'flutter-tester'); expect(params['mode'], 'debug'); }); 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 and expected resume and correct output. await Future.wait(<Future<void>>[ // We should print "Detached" instead of "Exited". dap.client.outputEvents.firstWhere((OutputEventBody event) => event.output.contains('\nDetached')), // We should still get terminatedEvent (this signals the DAP server terminating). dap.client.event('terminated'), // We should get output showing the app resumed. testProcess.output.firstWhere((String output) => output.contains('topLevelFunction')), // Trigger the detach. dap.client.terminate(), ]); }); }); } /// Extracts the output from a set of [OutputEventBody], removing any /// adjacent duplicates and combining into a single string. /// /// If the output event contains a [Source], the name will be shown on the /// following line indented and prefixed with `^ source:`. String _uniqueOutputLines(List<OutputEventBody> outputEvents) { String? lastItem; return outputEvents .map((OutputEventBody e) { final String output = e.output; final Source? source = e.source; return source != null ? '$output ^ source: ${source.name}\n' : 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(); }