363 lines
13 KiB
Dart
363 lines
13 KiB
Dart
// 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 'package:vm_service/vm_service.dart' as vm_service;
|
|
|
|
import '../base/file_system.dart';
|
|
import '../base/io.dart';
|
|
import '../base/logger.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,
|
|
required this.uriConverter,
|
|
}) : assert(!debuggingOptions.startPaused || enableObservatory),
|
|
_gotProcessObservatoryUri = enableObservatory
|
|
? Completer<Uri?>() : (Completer<Uri?>()..complete());
|
|
|
|
/// 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 UriConverter? uriConverter;
|
|
|
|
final Completer<Uri?> _gotProcessObservatoryUri;
|
|
final Completer<int> _exitCode = Completer<int>();
|
|
|
|
Process? _process;
|
|
HttpServer? _server;
|
|
|
|
/// 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>[
|
|
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',
|
|
if (debuggingOptions.enableDartProfiling)
|
|
'--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,
|
|
uriConverter: uriConverter,
|
|
);
|
|
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) async {
|
|
logger.printTrace('test $id: Successfully connected to service protocol: $forwardingUri');
|
|
if (debuggingOptions.serveObservatory) {
|
|
try {
|
|
await vmservice.callMethodWrapper('_serveObservatory');
|
|
} on vm_service.RPCError {
|
|
logger.printWarning('Unable to enable Observatory');
|
|
}
|
|
}
|
|
}));
|
|
|
|
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 {
|
|
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, {UriConverter? uriConverter}) {
|
|
return DartDevelopmentService.startDartDevelopmentService(
|
|
uri,
|
|
serviceUri: _ddsServiceUri,
|
|
enableAuthCodes: !debuggingOptions.disableServiceAuthCodes,
|
|
ipv6: host!.type == InternetAddressType.IPv6,
|
|
uriConverter: uriConverter,
|
|
);
|
|
}
|
|
|
|
/// 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]!);
|
|
await reportObservatoryUri(uri);
|
|
} on Exception catch (error) {
|
|
logger.printError('Could not parse shell observatory port message: $error');
|
|
}
|
|
} else {
|
|
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;
|
|
}
|