blob: 1a243ba5efae384ef57bc6ac7482aa8c749c0c06 [file] [log] [blame]
// 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: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()),
_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;
}