// 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:math' as math; import 'package:dds/dap.dart' hide PidTracker; import 'package:meta/meta.dart'; 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 tests. class FlutterTestDebugAdapter extends DartDebugAdapter<FlutterLaunchRequestArguments, FlutterAttachRequestArguments> with PidTracker, TestAdapter { FlutterTestDebugAdapter( super.channel, { required this.fileSystem, required this.platform, super.ipv6, super.enableDds, super.enableAuthCodes, super.logger, }); FileSystem fileSystem; Platform platform; Process? _process; @override final FlutterLaunchRequestArguments Function(Map<String, Object?> obj) parseLaunchArgs = FlutterLaunchRequestArguments.fromJson; @override final FlutterAttachRequestArguments Function(Map<String, Object?> obj) parseAttachArgs = FlutterAttachRequestArguments.fromJson; /// Whether the VM Service closing should be used as a signal to terminate the debug session. /// /// Since we do not support attaching for tests, 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<void> attachImpl() async { sendOutput('console', '\nAttach is not currently supported'); handleSessionTerminate(); } @override Future<void> 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. pub 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<void> disconnectImpl() async { terminatePids(ProcessSignal.sigkill); } /// Called by [launchRequest] to request that we actually start the tests to be run/debugged. /// /// For debugging, this should start paused, connect to the VM Service, set /// breakpoints, and resume. @override Future<void> launchImpl() async { final FlutterLaunchRequestArguments args = this.args as FlutterLaunchRequestArguments; final bool debug = !(args.noDebug ?? false); final String? program = args.program; final List<String> toolArgs = <String>[ 'test', '--machine', if (debug) '--start-paused', ]; // 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) { toolArgs.removeRange(0, math.min(removeArgs, toolArgs.length)); } final List<String> processArgs = <String>[ ...toolArgs, ...?args.toolArgs, if (program != null) program, ...?args.args, ]; await launchAsProcess(executable, processArgs); // Delay responding until the debugger is connected. if (debug) { await debuggerInitialized; } } @visibleForOverriding Future<void> launchAsProcess(String executable, List<String> processArgs) async { logger?.call('Spawning $executable with $processArgs in ${args.cwd}'); final Process process = await Process.start( executable, 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)); } /// Called by [terminateRequest] to request that we gracefully shut down the app being run (or in the case of an attach, disconnect). @override Future<void> terminateImpl() async { terminatePids(ProcessSignal.sigterm); await _process?.exitCode; } /// 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 test --machine`. bool _handleJsonEvent(String event, Map<String, Object?>? params) { params ??= <String, Object?>{}; switch (event) { case 'test.startedProcess': _handleTestStartedProcess(params); return true; } return false; } void _handleStderr(List<int> data) { logger?.call('stderr: $data'); sendOutput('stderr', utf8.decode(data)); } /// Handles stdout from the `flutter test --machine` process, decoding the JSON and calling the appropriate handlers. void _handleStdout(String data) { // Output to stdout from `flutter test --machine` is either: // 1. JSON output from flutter_tools (eg. "test.startedProcess") which is // wrapped in [] brackets and has an event/params. // 2. JSON output from package:test (not wrapped in brackets). // 3. Non-JSON output (user messages, or flutter_tools printing things like // call stacks/error information). logger?.call('stdout: $data'); 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('stdout', data); return; } // Check for valid flutter_tools JSON output (1) first. final Map<String, Object?>? flutterPayload = jsonData is List && jsonData.length == 1 && jsonData.first is Map<String, Object?> ? jsonData.first as Map<String, Object?> : null; final Object? event = flutterPayload?['event']; final Object? params = flutterPayload?['params']; if (event is String && params is Map<String, Object?>?) { _handleJsonEvent(event, params); } else if (jsonData != null) { // Handle package:test output (2). sendTestEvents(jsonData); } else { // Other output should just be passed straight through. sendOutput('stdout', data); } } /// Handles the test.processStarted event from Flutter that provides the VM Service URL. void _handleTestStartedProcess(Map<String, Object?> params) { final String? vmServiceUriString = params['observatoryUri'] as String?; // For no-debug mode, this event is still sent, but has a null URI. if (vmServiceUriString == null) { return; } final Uri vmServiceUri = Uri.parse(vmServiceUriString); connectDebugger(vmServiceUri, resumeIfStarting: true); } }