// 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. // @dart = 2.8 import 'dart:async'; import 'dart:io' as io; // flutter_ignore: dart_io_import; import 'package:dds/dds.dart'; import 'package:meta/meta.dart'; import 'package:process/process.dart'; import 'package:stream_channel/stream_channel.dart'; import '../base/file_system.dart'; import '../base/io.dart'; import '../base/logger.dart'; import '../base/os.dart'; import '../base/platform.dart'; import '../convert.dart'; import '../device.dart'; import '../globals.dart' as globals; import '../project.dart'; import '../vmservice.dart'; import 'font_config_manager.dart'; import 'test_device.dart'; /// Implementation of [TestDevice] with the Flutter Tester over a [Process]. class FlutterTesterTestDevice extends TestDevice { FlutterTesterTestDevice({ @required this.id, @required this.platform, @required this.fileSystem, @required this.processManager, @required this.logger, @required this.shellPath, @required this.debuggingOptions, @required this.enableObservatory, @required this.machine, @required this.host, @required this.testAssetDirectory, @required this.flutterProject, @required this.icudtlPath, @required this.compileExpression, @required this.fontConfigManager, }) : assert(shellPath != null), // Please provide the path to the shell in the SKY_SHELL environment variable. assert(!debuggingOptions.startPaused || enableObservatory), _gotProcessObservatoryUri = enableObservatory ? Completer<Uri>() : (Completer<Uri>()..complete(null)), _operatingSystemUtils = OperatingSystemUtils( fileSystem: fileSystem, logger: logger, platform: platform, processManager: processManager, ); /// Used for logging to identify the test that is currently being executed. final int id; final Platform platform; final FileSystem fileSystem; final ProcessManager processManager; final Logger logger; final String shellPath; final DebuggingOptions debuggingOptions; final bool enableObservatory; final bool machine; final InternetAddress host; final String testAssetDirectory; final FlutterProject flutterProject; final String icudtlPath; final CompileExpression compileExpression; final FontConfigManager fontConfigManager; final Completer<Uri> _gotProcessObservatoryUri; final Completer<int> _exitCode = Completer<int>(); Process _process; HttpServer _server; final OperatingSystemUtils _operatingSystemUtils; /// Starts the device. /// /// [entrypointPath] is the path to the entrypoint file which must be compiled /// as a dill. @override Future<StreamChannel<String>> start(String entrypointPath) async { assert(!_exitCode.isCompleted); assert(_process == null); assert(_server == null); // Prepare our WebSocket server to talk to the engine subprocess. // Let the server choose an unused port. _server = await bind(host, /*port*/ 0); logger.printTrace('test $id: test harness socket server is running at port:${_server.port}'); final List<String> command = <String>[ // Until an arm64 flutter tester binary is available, force to run in Rosetta // to avoid "unexpectedly got a signal in sigtramp" crash. // https://github.com/flutter/flutter/issues/88106 if (_operatingSystemUtils.hostPlatform == HostPlatform.darwin_arm) ...<String>[ '/usr/bin/arch', '-x86_64', ], shellPath, if (enableObservatory) ...<String>[ // Some systems drive the _FlutterPlatform class in an unusual way, where // only one test file is processed at a time, and the operating // environment hands out specific ports ahead of time in a cooperative // manner, where we're only allowed to open ports that were given to us in // advance like this. For those esoteric systems, we have this feature // whereby you can create _FlutterPlatform with a pair of ports. // // I mention this only so that you won't be tempted, as I was, to apply // the obvious simplification to this code and remove this entire feature. '--observatory-port=${debuggingOptions.enableDds ? 0 : debuggingOptions.hostVmServicePort }', if (debuggingOptions.startPaused) '--start-paused', if (debuggingOptions.disableServiceAuthCodes) '--disable-service-auth-codes', ] else '--disable-observatory', if (host.type == InternetAddressType.IPv6) '--ipv6', if (icudtlPath != null) '--icu-data-file-path=$icudtlPath', '--enable-checked-mode', '--verify-entry-points', '--enable-software-rendering', '--skia-deterministic-rendering', '--enable-dart-profiling', '--non-interactive', '--use-test-fonts', '--disable-asset-fonts', '--packages=${debuggingOptions.buildInfo.packagesPath}', if (testAssetDirectory != null) '--flutter-assets-dir=$testAssetDirectory', if (debuggingOptions.nullAssertions) '--dart-flags=--null_assertions', ...debuggingOptions.dartEntrypointArgs, entrypointPath, ]; // If the FLUTTER_TEST environment variable has been set, then pass it on // for package:flutter_test to handle the value. // // If FLUTTER_TEST has not been set, assume from this context that this // call was invoked by the command 'flutter test'. final String flutterTest = platform.environment.containsKey('FLUTTER_TEST') ? platform.environment['FLUTTER_TEST'] : 'true'; final Map<String, String> environment = <String, String>{ 'FLUTTER_TEST': flutterTest, 'FONTCONFIG_FILE': fontConfigManager.fontConfigFile.path, 'SERVER_PORT': _server.port.toString(), 'APP_NAME': flutterProject?.manifest?.appName ?? '', if (testAssetDirectory != null) 'UNIT_TEST_ASSETS': testAssetDirectory, }; logger.printTrace('test $id: Starting flutter_tester process with command=$command, environment=$environment'); _process = await processManager.start(command, environment: environment); // Unawaited to update state. unawaited(_process.exitCode.then((int exitCode) { logger.printTrace('test $id: flutter_tester process at pid ${_process.pid} exited with code=$exitCode'); _exitCode.complete(exitCode); })); logger.printTrace('test $id: Started flutter_tester process at pid ${_process.pid}'); // Pipe stdout and stderr from the subprocess to our printStatus console. // We also keep track of what observatory port the engine used, if any. _pipeStandardStreamsToConsole( process: _process, reportObservatoryUri: (Uri detectedUri) async { assert(!_gotProcessObservatoryUri.isCompleted); assert(debuggingOptions.hostVmServicePort == null || debuggingOptions.hostVmServicePort == detectedUri.port); Uri forwardingUri; if (debuggingOptions.enableDds) { logger.printTrace('test $id: Starting Dart Development Service'); final DartDevelopmentService dds = await startDds(detectedUri); forwardingUri = dds.uri; logger.printTrace('test $id: Dart Development Service started at ${dds.uri}, forwarding to VM service at ${dds.remoteVmServiceUri}.'); } else { forwardingUri = detectedUri; } logger.printTrace('Connecting to service protocol: $forwardingUri'); final Future<FlutterVmService> localVmService = connectToVmService( forwardingUri, compileExpression: compileExpression, logger: logger, ); unawaited(localVmService.then((FlutterVmService vmservice) { logger.printTrace('test $id: Successfully connected to service protocol: $forwardingUri'); })); if (debuggingOptions.startPaused && !machine) { logger.printStatus('The test process has been started.'); logger.printStatus('You can now connect to it using observatory. To connect, load the following Web site in your browser:'); logger.printStatus(' $forwardingUri'); logger.printStatus('You should first set appropriate breakpoints, then resume the test in the debugger.'); } _gotProcessObservatoryUri.complete(forwardingUri); }, ); return remoteChannel; } @override Future<Uri> get observatoryUri { assert(_gotProcessObservatoryUri != null); return _gotProcessObservatoryUri.future; } @override Future<void> kill() async { logger.printTrace('test $id: Terminating flutter_tester process'); _process?.kill(io.ProcessSignal.sigkill); logger.printTrace('test $id: Shutting down test harness socket server'); await _server?.close(force: true); await finished; } @override Future<void> get finished async { final int exitCode = await _exitCode.future; // On Windows, the [exitCode] and the terminating signal have no correlation. if (platform.isWindows) { return; } // ProcessSignal.SIGKILL. Negative because signals are returned as negative // exit codes. if (exitCode == -9) { // We expect SIGKILL (9) because we could have tried to [kill] it. return; } throw TestDeviceException(_getExitCodeMessage(exitCode), StackTrace.current); } Uri get _ddsServiceUri { return Uri( scheme: 'http', host: (host.type == InternetAddressType.IPv6 ? InternetAddress.loopbackIPv6 : InternetAddress.loopbackIPv4 ).host, port: debuggingOptions.hostVmServicePort ?? 0, ); } @visibleForTesting @protected Future<DartDevelopmentService> startDds(Uri uri) { return DartDevelopmentService.startDartDevelopmentService( uri, serviceUri: _ddsServiceUri, enableAuthCodes: !debuggingOptions.disableServiceAuthCodes, ipv6: host.type == InternetAddressType.IPv6, ); } /// Binds an [HttpServer] serving from `host` on `port`. /// /// Only intended to be overridden in tests. @protected @visibleForTesting Future<HttpServer> bind(InternetAddress host, int port) => HttpServer.bind(host, port); @protected @visibleForTesting Future<StreamChannel<String>> get remoteChannel async { assert(_server != null); try { final HttpRequest firstRequest = await _server.first; final WebSocket webSocket = await WebSocketTransformer.upgrade(firstRequest); return _webSocketToStreamChannel(webSocket); } on Exception catch (error, stackTrace) { throw TestDeviceException('Unable to connect to flutter_tester process: $error', stackTrace); } } @override String toString() { final String status = _process != null ? 'pid: ${_process.pid}, ${_exitCode.isCompleted ? 'exited' : 'running'}' : 'not started'; return 'Flutter Tester ($status) for test $id'; } void _pipeStandardStreamsToConsole({ @required Process process, @required Future<void> Function(Uri uri) reportObservatoryUri, }) { for (final Stream<List<int>> stream in <Stream<List<int>>>[ process.stderr, process.stdout, ]) { stream .transform<String>(utf8.decoder) .transform<String>(const LineSplitter()) .listen( (String line) async { logger.printTrace('test $id: Shell: $line'); final Match match = globals.kVMServiceMessageRegExp.firstMatch(line); if (match != null) { try { final Uri uri = Uri.parse(match[1]); if (reportObservatoryUri != null) { await reportObservatoryUri(uri); } } on Exception catch (error) { logger.printError('Could not parse shell observatory port message: $error'); } } else if (line != null) { logger.printStatus('Shell: $line'); } }, onError: (dynamic error) { logger.printError('shell console stream for process pid ${process.pid} experienced an unexpected error: $error'); }, cancelOnError: true, ); } } } String _getExitCodeMessage(int exitCode) { switch (exitCode) { case 1: return 'Shell subprocess cleanly reported an error. Check the logs above for an error message.'; case 0: return 'Shell subprocess ended cleanly. Did main() call exit()?'; case -0x0f: // ProcessSignal.SIGTERM return 'Shell subprocess crashed with SIGTERM ($exitCode).'; case -0x0b: // ProcessSignal.SIGSEGV return 'Shell subprocess crashed with segmentation fault.'; case -0x06: // ProcessSignal.SIGABRT return 'Shell subprocess crashed with SIGABRT ($exitCode).'; case -0x02: // ProcessSignal.SIGINT return 'Shell subprocess terminated by ^C (SIGINT, $exitCode).'; default: return 'Shell subprocess crashed with unexpected exit code $exitCode.'; } } StreamChannel<String> _webSocketToStreamChannel(WebSocket webSocket) { final StreamChannelController<String> controller = StreamChannelController<String>(); controller.local.stream .map<dynamic>((String message) => message as dynamic) .pipe(webSocket); webSocket // We're only communicating with string encoded JSON. .map<String>((dynamic message) => message as String) .pipe(controller.local.sink); return controller.foreign; }