flutter_test_adapter.dart 7.63 KB
Newer Older
1 2 3 4 5
// 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';
6
import 'dart:math' as math;
7

8
import 'package:dds/dap.dart' hide PidTracker;
9
import 'package:meta/meta.dart';
10 11 12 13 14 15 16 17 18 19 20 21
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>
22
    with PidTracker, TestAdapter {
23
  FlutterTestDebugAdapter(
24
    ByteStreamServerChannel channel, {
25 26
    required this.fileSystem,
    required this.platform,
27 28 29 30 31 32 33 34 35 36 37
    bool ipv6 = false,
    bool enableDds = true,
    bool enableAuthCodes = true,
    Logger? logger,
  }) : super(
    channel,
    ipv6: ipv6,
    enableDds: enableDds,
    enableAuthCodes: enableAuthCodes,
    logger: logger,
  );
38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102

  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',
    ];
103 104 105 106 107 108 109 110

    // 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));
    }

111 112 113 114 115 116 117
    final List<String> processArgs = <String>[
      ...toolArgs,
      ...?args.toolArgs,
      if (program != null) program,
      ...?args.args,
    ];

118 119 120 121 122 123 124 125 126 127 128
    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}');
129
    final Process process = await Process.start(
130
      executable,
131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223
      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);
  }
}