mirror of
https://github.com/flutter/flutter.git
synced 2025-06-03 00:51:18 +00:00
1004 lines
39 KiB
Dart
1004 lines
39 KiB
Dart
// Copyright 2015 The Chromium 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 'package:meta/meta.dart';
|
|
import 'package:stream_channel/stream_channel.dart';
|
|
|
|
import 'package:test_api/src/backend/runtime.dart'; // ignore: implementation_imports
|
|
import 'package:test_api/src/backend/suite_platform.dart'; // ignore: implementation_imports
|
|
import 'package:test_core/src/runner/platform.dart'; // ignore: implementation_imports
|
|
import 'package:test_core/src/runner/hack_register_platform.dart' as hack; // ignore: implementation_imports
|
|
import 'package:test_core/src/runner/runner_suite.dart'; // ignore: implementation_imports
|
|
import 'package:test_core/src/runner/suite.dart'; // ignore: implementation_imports
|
|
import 'package:test_core/src/runner/plugin/platform_helpers.dart'; // ignore: implementation_imports
|
|
import 'package:test_core/src/runner/environment.dart'; // ignore: implementation_imports
|
|
|
|
import '../base/common.dart';
|
|
import '../base/file_system.dart';
|
|
import '../base/io.dart';
|
|
import '../base/platform.dart';
|
|
import '../base/process_manager.dart';
|
|
import '../compile.dart';
|
|
import '../convert.dart';
|
|
import '../dart/package_map.dart';
|
|
import '../globals.dart';
|
|
import '../project.dart';
|
|
import '../vmservice.dart';
|
|
import 'test_compiler.dart';
|
|
import 'watcher.dart';
|
|
|
|
/// The timeout we give the test process to connect to the test harness
|
|
/// once the process has entered its main method.
|
|
///
|
|
/// We time out test execution because we expect some tests to hang and we want
|
|
/// to know which test hung, rather than have the entire test harness just do
|
|
/// nothing for a few hours until the user (or CI environment) gets bored.
|
|
const Duration _kTestStartupTimeout = Duration(minutes: 5);
|
|
|
|
/// The timeout we give the test process to start executing Dart code. When the
|
|
/// CPU is under severe load, this can take a while, but it's not indicative of
|
|
/// any problem with Flutter, so we give it a large timeout.
|
|
///
|
|
/// See comment under [_kTestStartupTimeout] regarding timeouts.
|
|
const Duration _kTestProcessTimeout = Duration(minutes: 5);
|
|
|
|
/// Message logged by the test process to signal that its main method has begun
|
|
/// execution.
|
|
///
|
|
/// The test harness responds by starting the [_kTestStartupTimeout] countdown.
|
|
/// The CPU may be throttled, which can cause a long delay in between when the
|
|
/// process is spawned and when dart code execution begins; we don't want to
|
|
/// hold that against the test.
|
|
const String _kStartTimeoutTimerMessage = 'sky_shell test process has entered main method';
|
|
|
|
/// The name of the test configuration file that will be discovered by the
|
|
/// test harness if it exists in the project directory hierarchy.
|
|
const String _kTestConfigFileName = 'flutter_test_config.dart';
|
|
|
|
/// The name of the file that signals the root of the project and that will
|
|
/// cause the test harness to stop scanning for configuration files.
|
|
const String _kProjectRootSentinel = 'pubspec.yaml';
|
|
|
|
/// The address at which our WebSocket server resides and at which the sky_shell
|
|
/// processes will host the Observatory server.
|
|
final Map<InternetAddressType, InternetAddress> _kHosts = <InternetAddressType, InternetAddress>{
|
|
InternetAddressType.IPv4: InternetAddress.loopbackIPv4,
|
|
InternetAddressType.IPv6: InternetAddress.loopbackIPv6,
|
|
};
|
|
|
|
typedef PlatformPluginRegistration = void Function(FlutterPlatform platform);
|
|
|
|
/// Configure the `test` package to work with Flutter.
|
|
///
|
|
/// On systems where each [FlutterPlatform] is only used to run one test suite
|
|
/// (that is, one Dart file with a `*_test.dart` file name and a single `void
|
|
/// main()`), you can set an observatory port explicitly.
|
|
FlutterPlatform installHook({
|
|
@required String shellPath,
|
|
TestWatcher watcher,
|
|
bool enableObservatory = false,
|
|
bool machine = false,
|
|
bool startPaused = false,
|
|
bool disableServiceAuthCodes = false,
|
|
int port = 0,
|
|
String precompiledDillPath,
|
|
Map<String, String> precompiledDillFiles,
|
|
bool trackWidgetCreation = false,
|
|
bool updateGoldens = false,
|
|
bool buildTestAssets = false,
|
|
int observatoryPort,
|
|
InternetAddressType serverType = InternetAddressType.IPv4,
|
|
Uri projectRootDirectory,
|
|
FlutterProject flutterProject,
|
|
String icudtlPath,
|
|
PlatformPluginRegistration platformPluginRegistration
|
|
}) {
|
|
assert(enableObservatory || (!startPaused && observatoryPort == null));
|
|
|
|
// registerPlatformPlugin can be injected for testing since it's not very mock-friendly.
|
|
platformPluginRegistration ??= (FlutterPlatform platform) {
|
|
hack.registerPlatformPlugin(
|
|
<Runtime>[Runtime.vm],
|
|
() {
|
|
return platform;
|
|
}
|
|
);
|
|
};
|
|
final FlutterPlatform platform = FlutterPlatform(
|
|
shellPath: shellPath,
|
|
watcher: watcher,
|
|
machine: machine,
|
|
enableObservatory: enableObservatory,
|
|
startPaused: startPaused,
|
|
disableServiceAuthCodes: disableServiceAuthCodes,
|
|
explicitObservatoryPort: observatoryPort,
|
|
host: _kHosts[serverType],
|
|
port: port,
|
|
precompiledDillPath: precompiledDillPath,
|
|
precompiledDillFiles: precompiledDillFiles,
|
|
trackWidgetCreation: trackWidgetCreation,
|
|
updateGoldens: updateGoldens,
|
|
buildTestAssets: buildTestAssets,
|
|
projectRootDirectory: projectRootDirectory,
|
|
flutterProject: flutterProject,
|
|
icudtlPath: icudtlPath,
|
|
);
|
|
platformPluginRegistration(platform);
|
|
return platform;
|
|
}
|
|
|
|
/// Generates the bootstrap entry point script that will be used to launch an
|
|
/// individual test file.
|
|
///
|
|
/// The [testUrl] argument specifies the path to the test file that is being
|
|
/// launched.
|
|
///
|
|
/// The [host] argument specifies the address at which the test harness is
|
|
/// running.
|
|
///
|
|
/// If [testConfigFile] is specified, it must follow the conventions of test
|
|
/// configuration files as outlined in the [flutter_test] library. By default,
|
|
/// the test file will be launched directly.
|
|
///
|
|
/// The [updateGoldens] argument will set the [autoUpdateGoldens] global
|
|
/// variable in the [flutter_test] package before invoking the test.
|
|
String generateTestBootstrap({
|
|
@required Uri testUrl,
|
|
@required InternetAddress host,
|
|
File testConfigFile,
|
|
bool updateGoldens = false,
|
|
}) {
|
|
assert(testUrl != null);
|
|
assert(host != null);
|
|
assert(updateGoldens != null);
|
|
|
|
final String websocketUrl = host.type == InternetAddressType.IPv4
|
|
? 'ws://${host.address}'
|
|
: 'ws://[${host.address}]';
|
|
final String encodedWebsocketUrl = Uri.encodeComponent(websocketUrl);
|
|
|
|
final StringBuffer buffer = StringBuffer();
|
|
buffer.write('''
|
|
import 'dart:async';
|
|
import 'dart:convert'; // ignore: dart_convert_import
|
|
import 'dart:io'; // ignore: dart_io_import
|
|
import 'dart:isolate';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:test_api/src/remote_listener.dart';
|
|
import 'package:stream_channel/stream_channel.dart';
|
|
import 'package:stack_trace/stack_trace.dart';
|
|
|
|
import '$testUrl' as test;
|
|
''');
|
|
if (testConfigFile != null) {
|
|
buffer.write('''
|
|
import '${Uri.file(testConfigFile.path)}' as test_config;
|
|
''');
|
|
}
|
|
buffer.write('''
|
|
|
|
/// Returns a serialized test suite.
|
|
StreamChannel<dynamic> serializeSuite(Function getMain(),
|
|
{bool hidePrints = true, Future<dynamic> beforeLoad()}) {
|
|
return RemoteListener.start(getMain,
|
|
hidePrints: hidePrints, beforeLoad: beforeLoad);
|
|
}
|
|
|
|
/// Capture any top-level errors (mostly lazy syntax errors, since other are
|
|
/// caught below) and report them to the parent isolate.
|
|
void catchIsolateErrors() {
|
|
final ReceivePort errorPort = ReceivePort();
|
|
// Treat errors non-fatal because otherwise they'll be double-printed.
|
|
Isolate.current.setErrorsFatal(false);
|
|
Isolate.current.addErrorListener(errorPort.sendPort);
|
|
errorPort.listen((dynamic message) {
|
|
// Masquerade as an IsolateSpawnException because that's what this would
|
|
// be if the error had been detected statically.
|
|
final IsolateSpawnException error = IsolateSpawnException(message[0]);
|
|
final Trace stackTrace =
|
|
message[1] == null ? Trace(const <Frame>[]) : Trace.parse(message[1]);
|
|
Zone.current.handleUncaughtError(error, stackTrace);
|
|
});
|
|
}
|
|
|
|
|
|
void main() {
|
|
print('$_kStartTimeoutTimerMessage');
|
|
String serverPort = Platform.environment['SERVER_PORT'];
|
|
String server = Uri.decodeComponent('$encodedWebsocketUrl:\$serverPort');
|
|
StreamChannel<dynamic> channel = serializeSuite(() {
|
|
catchIsolateErrors();
|
|
goldenFileComparator = new LocalFileComparator(Uri.parse('$testUrl'));
|
|
autoUpdateGoldenFiles = $updateGoldens;
|
|
''');
|
|
if (testConfigFile != null) {
|
|
buffer.write('''
|
|
return () => test_config.main(test.main);
|
|
''');
|
|
} else {
|
|
buffer.write('''
|
|
return test.main;
|
|
''');
|
|
}
|
|
buffer.write('''
|
|
});
|
|
WebSocket.connect(server).then((WebSocket socket) {
|
|
socket.map((dynamic x) {
|
|
assert(x is String);
|
|
return json.decode(x);
|
|
}).pipe(channel.sink);
|
|
socket.addStream(channel.stream.map(json.encode));
|
|
});
|
|
}
|
|
''');
|
|
return buffer.toString();
|
|
}
|
|
|
|
enum InitialResult { crashed, timedOut, connected }
|
|
|
|
enum TestResult { crashed, harnessBailed, testBailed }
|
|
|
|
typedef Finalizer = Future<void> Function();
|
|
|
|
/// The flutter test platform used to integrate with package:test.
|
|
class FlutterPlatform extends PlatformPlugin {
|
|
FlutterPlatform({
|
|
@required this.shellPath,
|
|
this.watcher,
|
|
this.enableObservatory,
|
|
this.machine,
|
|
this.startPaused,
|
|
this.disableServiceAuthCodes,
|
|
this.explicitObservatoryPort,
|
|
this.host,
|
|
this.port,
|
|
this.precompiledDillPath,
|
|
this.precompiledDillFiles,
|
|
this.trackWidgetCreation,
|
|
this.updateGoldens,
|
|
this.buildTestAssets,
|
|
this.projectRootDirectory,
|
|
this.flutterProject,
|
|
this.icudtlPath,
|
|
}) : assert(shellPath != null);
|
|
|
|
final String shellPath;
|
|
final TestWatcher watcher;
|
|
final bool enableObservatory;
|
|
final bool machine;
|
|
final bool startPaused;
|
|
final bool disableServiceAuthCodes;
|
|
final int explicitObservatoryPort;
|
|
final InternetAddress host;
|
|
final int port;
|
|
final String precompiledDillPath;
|
|
final Map<String, String> precompiledDillFiles;
|
|
final bool trackWidgetCreation;
|
|
final bool updateGoldens;
|
|
final bool buildTestAssets;
|
|
final Uri projectRootDirectory;
|
|
final FlutterProject flutterProject;
|
|
final String icudtlPath;
|
|
|
|
Directory fontsDirectory;
|
|
|
|
/// The test compiler produces dill files for each test main.
|
|
///
|
|
/// To speed up compilation, each compile is intialized from an existing
|
|
/// dill file from previous runs, if possible.
|
|
TestCompiler compiler;
|
|
|
|
// Each time loadChannel() is called, we spin up a local WebSocket server,
|
|
// then spin up the engine in a subprocess. We pass the engine a Dart file
|
|
// that connects to our WebSocket server, then we proxy JSON messages from
|
|
// the test harness to the engine and back again. If at any time the engine
|
|
// crashes, we inject an error into that stream. When the process closes,
|
|
// we clean everything up.
|
|
|
|
int _testCount = 0;
|
|
|
|
@override
|
|
Future<RunnerSuite> load(
|
|
String path,
|
|
SuitePlatform platform,
|
|
SuiteConfiguration suiteConfig,
|
|
Object message,
|
|
) async {
|
|
// loadChannel may throw an exception. That's fine; it will cause the
|
|
// LoadSuite to emit an error, which will be presented to the user.
|
|
// Except for the Declarer error, which is a specific test incompatibility
|
|
// error we need to catch.
|
|
try {
|
|
final StreamChannel<dynamic> channel = loadChannel(path, platform);
|
|
final RunnerSuiteController controller = deserializeSuite(path, platform,
|
|
suiteConfig, const PluginEnvironment(), channel, message);
|
|
return await controller.suite;
|
|
} catch (err) {
|
|
/// Rethrow a less confusing error if it is a test incompatibility.
|
|
if (err.toString().contains('type \'Declarer\' is not a subtype of type \'Declarer\'')) {
|
|
throw UnsupportedError('Package incompatibility between flutter and test packages:\n'
|
|
' * flutter is incompatible with test <1.4.0.\n'
|
|
' * flutter is incompatible with mockito <4.0.0\n'
|
|
'To fix this error, update test to at least \'^1.4.0\' and mockito to at least \'^4.0.0\'\n'
|
|
);
|
|
}
|
|
// Guess it was a different error.
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
@override
|
|
StreamChannel<dynamic> loadChannel(String path, SuitePlatform platform) {
|
|
if (_testCount > 0) {
|
|
// Fail if there will be a port conflict.
|
|
if (explicitObservatoryPort != null) {
|
|
throwToolExit('installHook() was called with an observatory port or debugger mode enabled, but then more than one test suite was run.');
|
|
}
|
|
// Fail if we're passing in a precompiled entry-point.
|
|
if (precompiledDillPath != null) {
|
|
throwToolExit('installHook() was called with a precompiled test entry-point, but then more than one test suite was run.');
|
|
}
|
|
}
|
|
final int ourTestCount = _testCount;
|
|
_testCount += 1;
|
|
final StreamController<dynamic> localController = StreamController<dynamic>();
|
|
final StreamController<dynamic> remoteController = StreamController<dynamic>();
|
|
final Completer<_AsyncError> testCompleteCompleter = Completer<_AsyncError>();
|
|
final _FlutterPlatformStreamSinkWrapper<dynamic> remoteSink = _FlutterPlatformStreamSinkWrapper<dynamic>(
|
|
remoteController.sink,
|
|
testCompleteCompleter.future,
|
|
);
|
|
final StreamChannel<dynamic> localChannel = StreamChannel<dynamic>.withGuarantees(
|
|
remoteController.stream,
|
|
localController.sink,
|
|
);
|
|
final StreamChannel<dynamic> remoteChannel = StreamChannel<dynamic>.withGuarantees(
|
|
localController.stream,
|
|
remoteSink,
|
|
);
|
|
testCompleteCompleter.complete(_startTest(path, localChannel, ourTestCount));
|
|
return remoteChannel;
|
|
}
|
|
|
|
Future<String> _compileExpressionService(
|
|
String isolateId,
|
|
String expression,
|
|
List<String> definitions,
|
|
List<String> typeDefinitions,
|
|
String libraryUri,
|
|
String klass,
|
|
bool isStatic,
|
|
) async {
|
|
if (compiler == null || compiler.compiler == null) {
|
|
throw 'Compiler is not set up properly to compile $expression';
|
|
}
|
|
final CompilerOutput compilerOutput =
|
|
await compiler.compiler.compileExpression(expression, definitions,
|
|
typeDefinitions, libraryUri, klass, isStatic);
|
|
if (compilerOutput != null && compilerOutput.outputFilename != null) {
|
|
return base64.encode(fs.file(compilerOutput.outputFilename).readAsBytesSync());
|
|
}
|
|
throw 'Failed to compile $expression';
|
|
}
|
|
|
|
/// Binds an [HttpServer] serving from `host` on `port`.
|
|
///
|
|
/// Only intended to be overridden in tests for [FlutterPlatform].
|
|
@protected
|
|
@visibleForTesting
|
|
Future<HttpServer> bind(InternetAddress host, int port) => HttpServer.bind(host, port);
|
|
|
|
Future<_AsyncError> _startTest(
|
|
String testPath,
|
|
StreamChannel<dynamic> controller,
|
|
int ourTestCount,
|
|
) async {
|
|
printTrace('test $ourTestCount: starting test $testPath');
|
|
|
|
_AsyncError outOfBandError; // error that we couldn't send to the harness that we need to send via our future
|
|
|
|
final List<Finalizer> finalizers = <Finalizer>[]; // Will be run in reverse order.
|
|
bool subprocessActive = false;
|
|
bool controllerSinkClosed = false;
|
|
try {
|
|
// Callback can't throw since it's just setting a variable.
|
|
unawaited(controller.sink.done.whenComplete(() {
|
|
controllerSinkClosed = true;
|
|
}));
|
|
|
|
// Prepare our WebSocket server to talk to the engine subproces.
|
|
final HttpServer server = await bind(host, port);
|
|
finalizers.add(() async {
|
|
printTrace('test $ourTestCount: shutting down test harness socket server');
|
|
await server.close(force: true);
|
|
});
|
|
final Completer<WebSocket> webSocket = Completer<WebSocket>();
|
|
server.listen((HttpRequest request) {
|
|
if (!webSocket.isCompleted)
|
|
webSocket.complete(WebSocketTransformer.upgrade(request));
|
|
},
|
|
onError: (dynamic error, dynamic stack) {
|
|
// If you reach here, it's unlikely we're going to be able to really handle this well.
|
|
printTrace('test $ourTestCount: test harness socket server experienced an unexpected error: $error');
|
|
if (!controllerSinkClosed) {
|
|
controller.sink.addError(error, stack);
|
|
controller.sink.close();
|
|
} else {
|
|
printError('unexpected error from test harness socket server: $error');
|
|
}
|
|
},
|
|
cancelOnError: true,
|
|
);
|
|
|
|
printTrace('test $ourTestCount: starting shell process');
|
|
|
|
// If a kernel file is given, then use that to launch the test.
|
|
// If mapping is provided, look kernel file from mapping.
|
|
// If all fails, create a "listener" dart that invokes actual test.
|
|
String mainDart;
|
|
if (precompiledDillPath != null) {
|
|
mainDart = precompiledDillPath;
|
|
} else if (precompiledDillFiles != null) {
|
|
mainDart = precompiledDillFiles[testPath];
|
|
}
|
|
mainDart ??= _createListenerDart(finalizers, ourTestCount, testPath, server);
|
|
|
|
if (precompiledDillPath == null && precompiledDillFiles == null) {
|
|
// Lazily instantiate compiler so it is built only if it is actually used.
|
|
compiler ??= TestCompiler(trackWidgetCreation, flutterProject);
|
|
mainDart = await compiler.compile(mainDart);
|
|
|
|
if (mainDart == null) {
|
|
controller.sink.addError(_getErrorMessage('Compilation failed', testPath, shellPath));
|
|
return null;
|
|
}
|
|
}
|
|
|
|
final Process process = await _startProcess(
|
|
shellPath,
|
|
mainDart,
|
|
packages: PackageMap.globalPackagesPath,
|
|
enableObservatory: enableObservatory,
|
|
startPaused: startPaused,
|
|
disableServiceAuthCodes: disableServiceAuthCodes,
|
|
observatoryPort: explicitObservatoryPort,
|
|
serverPort: server.port,
|
|
);
|
|
subprocessActive = true;
|
|
finalizers.add(() async {
|
|
if (subprocessActive) {
|
|
printTrace('test $ourTestCount: ensuring end-of-process for shell');
|
|
process.kill();
|
|
final int exitCode = await process.exitCode;
|
|
subprocessActive = false;
|
|
if (!controllerSinkClosed && exitCode != -15) {
|
|
// ProcessSignal.SIGTERM
|
|
// We expect SIGTERM (15) because we tried to terminate it.
|
|
// It's negative because signals are returned as negative exit codes.
|
|
final String message = _getErrorMessage(
|
|
_getExitCodeMessage(exitCode, 'after tests finished'),
|
|
testPath,
|
|
shellPath);
|
|
controller.sink.addError(message);
|
|
}
|
|
}
|
|
});
|
|
|
|
final Completer<void> timeout = Completer<void>();
|
|
final Completer<void> gotProcessObservatoryUri = Completer<void>();
|
|
if (!enableObservatory) {
|
|
gotProcessObservatoryUri.complete();
|
|
}
|
|
|
|
// Pipe stdout and stderr from the subprocess to our printStatus console.
|
|
// We also keep track of what observatory port the engine used, if any.
|
|
Uri processObservatoryUri;
|
|
_pipeStandardStreamsToConsole(
|
|
process,
|
|
reportObservatoryUri: (Uri detectedUri) {
|
|
assert(processObservatoryUri == null);
|
|
assert(explicitObservatoryPort == null ||
|
|
explicitObservatoryPort == detectedUri.port);
|
|
if (startPaused && !machine) {
|
|
printStatus('The test process has been started.');
|
|
printStatus('You can now connect to it using observatory. To connect, load the following Web site in your browser:');
|
|
printStatus(' $detectedUri');
|
|
printStatus('You should first set appropriate breakpoints, then resume the test in the debugger.');
|
|
} else {
|
|
printTrace('test $ourTestCount: using observatory uri $detectedUri from pid ${process.pid}');
|
|
}
|
|
processObservatoryUri = detectedUri;
|
|
{
|
|
printTrace('Connecting to service protocol: $processObservatoryUri');
|
|
final Future<VMService> localVmService = VMService.connect(processObservatoryUri,
|
|
compileExpression: _compileExpressionService);
|
|
localVmService.then((VMService vmservice) {
|
|
printTrace('Successfully connected to service protocol: $processObservatoryUri');
|
|
});
|
|
}
|
|
gotProcessObservatoryUri.complete();
|
|
watcher?.handleStartedProcess(
|
|
ProcessEvent(ourTestCount, process, processObservatoryUri));
|
|
},
|
|
startTimeoutTimer: () {
|
|
Future<InitialResult>.delayed(_kTestStartupTimeout)
|
|
.then<void>((_) => timeout.complete());
|
|
},
|
|
);
|
|
|
|
// At this point, three things can happen next:
|
|
// The engine could crash, in which case process.exitCode will complete.
|
|
// The engine could connect to us, in which case webSocket.future will complete.
|
|
// The local test harness could get bored of us.
|
|
printTrace('test $ourTestCount: awaiting initial result for pid ${process.pid}');
|
|
final InitialResult initialResult = await Future.any<InitialResult>(<Future<InitialResult>>[
|
|
process.exitCode.then<InitialResult>((int exitCode) => InitialResult.crashed),
|
|
timeout.future.then<InitialResult>((void value) => InitialResult.timedOut),
|
|
Future<InitialResult>.delayed(_kTestProcessTimeout, () => InitialResult.timedOut),
|
|
gotProcessObservatoryUri.future.then<InitialResult>((void value) {
|
|
return webSocket.future.then<InitialResult>(
|
|
(WebSocket webSocket) => InitialResult.connected,
|
|
);
|
|
}),
|
|
]);
|
|
|
|
switch (initialResult) {
|
|
case InitialResult.crashed:
|
|
printTrace('test $ourTestCount: process with pid ${process.pid} crashed before connecting to test harness');
|
|
final int exitCode = await process.exitCode;
|
|
subprocessActive = false;
|
|
final String message = _getErrorMessage(
|
|
_getExitCodeMessage(
|
|
exitCode, 'before connecting to test harness'),
|
|
testPath,
|
|
shellPath);
|
|
controller.sink.addError(message);
|
|
// Awaited for with 'sink.done' below.
|
|
unawaited(controller.sink.close());
|
|
printTrace('test $ourTestCount: waiting for controller sink to close');
|
|
await controller.sink.done;
|
|
await watcher?.handleTestCrashed(ProcessEvent(ourTestCount, process));
|
|
break;
|
|
case InitialResult.timedOut:
|
|
// Could happen either if the process takes a long time starting
|
|
// (_kTestProcessTimeout), or if once Dart code starts running, it takes a
|
|
// long time to open the WebSocket connection (_kTestStartupTimeout).
|
|
printTrace('test $ourTestCount: timed out waiting for process with pid ${process.pid} to connect to test harness');
|
|
final String message = _getErrorMessage('Test never connected to test harness.', testPath, shellPath);
|
|
controller.sink.addError(message);
|
|
// Awaited for with 'sink.done' below.
|
|
unawaited(controller.sink.close());
|
|
printTrace('test $ourTestCount: waiting for controller sink to close');
|
|
await controller.sink.done;
|
|
await watcher
|
|
?.handleTestTimedOut(ProcessEvent(ourTestCount, process));
|
|
break;
|
|
case InitialResult.connected:
|
|
printTrace('test $ourTestCount: process with pid ${process.pid} connected to test harness');
|
|
final WebSocket testSocket = await webSocket.future;
|
|
|
|
final Completer<void> harnessDone = Completer<void>();
|
|
final StreamSubscription<dynamic> harnessToTest =
|
|
controller.stream.listen(
|
|
(dynamic event) {
|
|
testSocket.add(json.encode(event));
|
|
},
|
|
onDone: harnessDone.complete,
|
|
onError: (dynamic error, dynamic stack) {
|
|
// If you reach here, it's unlikely we're going to be able to really handle this well.
|
|
printError('test harness controller stream experienced an unexpected error\ntest: $testPath\nerror: $error');
|
|
if (!controllerSinkClosed) {
|
|
controller.sink.addError(error, stack);
|
|
controller.sink.close();
|
|
} else {
|
|
printError('unexpected error from test harness controller stream: $error');
|
|
}
|
|
},
|
|
cancelOnError: true,
|
|
);
|
|
|
|
final Completer<void> testDone = Completer<void>();
|
|
final StreamSubscription<dynamic> testToHarness = testSocket.listen(
|
|
(dynamic encodedEvent) {
|
|
assert(encodedEvent
|
|
is String); // we shouldn't ever get binary messages
|
|
controller.sink.add(json.decode(encodedEvent));
|
|
},
|
|
onDone: testDone.complete,
|
|
onError: (dynamic error, dynamic stack) {
|
|
// If you reach here, it's unlikely we're going to be able to really handle this well.
|
|
printError('test socket stream experienced an unexpected error\ntest: $testPath\nerror: $error');
|
|
if (!controllerSinkClosed) {
|
|
controller.sink.addError(error, stack);
|
|
controller.sink.close();
|
|
} else {
|
|
printError('unexpected error from test socket stream: $error');
|
|
}
|
|
},
|
|
cancelOnError: true,
|
|
);
|
|
|
|
printTrace('test $ourTestCount: awaiting test result for pid ${process.pid}');
|
|
final TestResult testResult = await Future.any<TestResult>(<Future<TestResult>>[
|
|
process.exitCode.then<TestResult>((int exitCode) {
|
|
return TestResult.crashed;
|
|
}),
|
|
harnessDone.future.then<TestResult>((void value) {
|
|
return TestResult.harnessBailed;
|
|
}),
|
|
testDone.future.then<TestResult>((void value) {
|
|
return TestResult.testBailed;
|
|
}),
|
|
]);
|
|
|
|
await Future.wait<void>(<Future<void>>[
|
|
harnessToTest.cancel(),
|
|
testToHarness.cancel(),
|
|
]);
|
|
|
|
switch (testResult) {
|
|
case TestResult.crashed:
|
|
printTrace('test $ourTestCount: process with pid ${process.pid} crashed');
|
|
final int exitCode = await process.exitCode;
|
|
subprocessActive = false;
|
|
final String message = _getErrorMessage(
|
|
_getExitCodeMessage(
|
|
exitCode, 'before test harness closed its WebSocket'),
|
|
testPath,
|
|
shellPath);
|
|
controller.sink.addError(message);
|
|
// Awaited for with 'sink.done' below.
|
|
unawaited(controller.sink.close());
|
|
printTrace('test $ourTestCount: waiting for controller sink to close');
|
|
await controller.sink.done;
|
|
break;
|
|
case TestResult.harnessBailed:
|
|
case TestResult.testBailed:
|
|
if (testResult == TestResult.harnessBailed) {
|
|
printTrace('test $ourTestCount: process with pid ${process.pid} no longer needed by test harness');
|
|
} else {
|
|
assert(testResult == TestResult.testBailed);
|
|
printTrace('test $ourTestCount: process with pid ${process.pid} no longer needs test harness');
|
|
}
|
|
await watcher?.handleFinishedTest(
|
|
ProcessEvent(ourTestCount, process, processObservatoryUri));
|
|
break;
|
|
}
|
|
break;
|
|
}
|
|
} catch (error, stack) {
|
|
printTrace('test $ourTestCount: error caught during test; ${controllerSinkClosed ? "reporting to console" : "sending to test framework"}');
|
|
if (!controllerSinkClosed) {
|
|
controller.sink.addError(error, stack);
|
|
} else {
|
|
printError('unhandled error during test:\n$testPath\n$error\n$stack');
|
|
outOfBandError ??= _AsyncError(error, stack);
|
|
}
|
|
} finally {
|
|
printTrace('test $ourTestCount: cleaning up...');
|
|
// Finalizers are treated like a stack; run them in reverse order.
|
|
for (Finalizer finalizer in finalizers.reversed) {
|
|
try {
|
|
await finalizer();
|
|
} catch (error, stack) {
|
|
printTrace('test $ourTestCount: error while cleaning up; ${controllerSinkClosed ? "reporting to console" : "sending to test framework"}');
|
|
if (!controllerSinkClosed) {
|
|
controller.sink.addError(error, stack);
|
|
} else {
|
|
printError('unhandled error during finalization of test:\n$testPath\n$error\n$stack');
|
|
outOfBandError ??= _AsyncError(error, stack);
|
|
}
|
|
}
|
|
}
|
|
if (!controllerSinkClosed) {
|
|
// Waiting below with await.
|
|
unawaited(controller.sink.close());
|
|
printTrace('test $ourTestCount: waiting for controller sink to close');
|
|
await controller.sink.done;
|
|
}
|
|
}
|
|
assert(!subprocessActive);
|
|
assert(controllerSinkClosed);
|
|
if (outOfBandError != null) {
|
|
printTrace('test $ourTestCount: finished with out-of-band failure');
|
|
} else {
|
|
printTrace('test $ourTestCount: finished');
|
|
}
|
|
return outOfBandError;
|
|
}
|
|
|
|
String _createListenerDart(
|
|
List<Finalizer> finalizers,
|
|
int ourTestCount,
|
|
String testPath,
|
|
HttpServer server,
|
|
) {
|
|
// Prepare a temporary directory to store the Dart file that will talk to us.
|
|
final Directory tempDir = fs.systemTempDirectory.createTempSync('flutter_test_listener.');
|
|
finalizers.add(() async {
|
|
printTrace('test $ourTestCount: deleting temporary directory');
|
|
tempDir.deleteSync(recursive: true);
|
|
});
|
|
|
|
// Prepare the Dart file that will talk to us and start the test.
|
|
final File listenerFile = fs.file('${tempDir.path}/listener.dart');
|
|
listenerFile.createSync();
|
|
listenerFile.writeAsStringSync(_generateTestMain(
|
|
testUrl: fs.path.toUri(fs.path.absolute(testPath)),
|
|
));
|
|
return listenerFile.path;
|
|
}
|
|
|
|
String _generateTestMain({
|
|
Uri testUrl,
|
|
}) {
|
|
assert(testUrl.scheme == 'file');
|
|
File testConfigFile;
|
|
Directory directory = fs.file(testUrl).parent;
|
|
while (directory.path != directory.parent.path) {
|
|
final File configFile = directory.childFile(_kTestConfigFileName);
|
|
if (configFile.existsSync()) {
|
|
printTrace('Discovered $_kTestConfigFileName in ${directory.path}');
|
|
testConfigFile = configFile;
|
|
break;
|
|
}
|
|
if (directory.childFile(_kProjectRootSentinel).existsSync()) {
|
|
printTrace('Stopping scan for $_kTestConfigFileName; '
|
|
'found project root at ${directory.path}');
|
|
break;
|
|
}
|
|
directory = directory.parent;
|
|
}
|
|
return generateTestBootstrap(
|
|
testUrl: testUrl,
|
|
testConfigFile: testConfigFile,
|
|
host: host,
|
|
updateGoldens: updateGoldens,
|
|
);
|
|
}
|
|
|
|
File _cachedFontConfig;
|
|
|
|
@override
|
|
Future<dynamic> close() async {
|
|
if (compiler != null) {
|
|
await compiler.dispose();
|
|
compiler = null;
|
|
}
|
|
if (fontsDirectory != null) {
|
|
printTrace('Deleting ${fontsDirectory.path}...');
|
|
fontsDirectory.deleteSync(recursive: true);
|
|
fontsDirectory = null;
|
|
}
|
|
}
|
|
|
|
/// Returns a Fontconfig config file that limits font fallback to the
|
|
/// artifact cache directory.
|
|
File get _fontConfigFile {
|
|
if (_cachedFontConfig != null) {
|
|
return _cachedFontConfig;
|
|
}
|
|
|
|
final StringBuffer sb = StringBuffer();
|
|
sb.writeln('<fontconfig>');
|
|
sb.writeln(' <dir>${cache.getCacheArtifacts().path}</dir>');
|
|
sb.writeln(' <cachedir>/var/cache/fontconfig</cachedir>');
|
|
sb.writeln('</fontconfig>');
|
|
|
|
if (fontsDirectory == null) {
|
|
fontsDirectory = fs.systemTempDirectory.createTempSync('flutter_test_fonts.');
|
|
printTrace('Using this directory for fonts configuration: ${fontsDirectory.path}');
|
|
}
|
|
|
|
_cachedFontConfig = fs.file('${fontsDirectory.path}/fonts.conf');
|
|
_cachedFontConfig.createSync();
|
|
_cachedFontConfig.writeAsStringSync(sb.toString());
|
|
return _cachedFontConfig;
|
|
}
|
|
|
|
Future<Process> _startProcess(
|
|
String executable,
|
|
String testPath, {
|
|
String packages,
|
|
bool enableObservatory = false,
|
|
bool startPaused = false,
|
|
bool disableServiceAuthCodes = false,
|
|
int observatoryPort,
|
|
int serverPort,
|
|
}) {
|
|
assert(executable != null); // Please provide the path to the shell in the SKY_SHELL environment variable.
|
|
assert(!startPaused || enableObservatory);
|
|
final List<String> command = <String>[executable];
|
|
if (enableObservatory) {
|
|
// 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.
|
|
if (observatoryPort != null)
|
|
command.add('--observatory-port=$observatoryPort');
|
|
if (startPaused) {
|
|
command.add('--start-paused');
|
|
}
|
|
if (disableServiceAuthCodes) {
|
|
command.add('--disable-service-auth-codes');
|
|
}
|
|
} else {
|
|
command.add('--disable-observatory');
|
|
}
|
|
if (host.type == InternetAddressType.IPv6) {
|
|
command.add('--ipv6');
|
|
}
|
|
|
|
if (icudtlPath != null) {
|
|
command.add('--icu-data-file-path=$icudtlPath');
|
|
}
|
|
|
|
command.addAll(<String>[
|
|
'--enable-checked-mode',
|
|
'--verify-entry-points',
|
|
'--enable-software-rendering',
|
|
'--skia-deterministic-rendering',
|
|
'--enable-dart-profiling',
|
|
'--non-interactive',
|
|
'--use-test-fonts',
|
|
'--packages=$packages',
|
|
testPath,
|
|
]);
|
|
printTrace(command.join(' '));
|
|
// 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': _fontConfigFile.path,
|
|
'SERVER_PORT': serverPort.toString(),
|
|
'APP_NAME': flutterProject?.manifest?.appName ?? '',
|
|
};
|
|
if (buildTestAssets) {
|
|
environment['UNIT_TEST_ASSETS'] = fs.path.join(
|
|
flutterProject?.directory?.path ?? '', 'build', 'unit_test_assets');
|
|
}
|
|
return processManager.start(command, environment: environment);
|
|
}
|
|
|
|
void _pipeStandardStreamsToConsole(
|
|
Process process, {
|
|
void startTimeoutTimer(),
|
|
void reportObservatoryUri(Uri uri),
|
|
}) {
|
|
const String observatoryString = 'Observatory listening on ';
|
|
for (Stream<List<int>> stream in <Stream<List<int>>>[
|
|
process.stderr,
|
|
process.stdout,
|
|
]) {
|
|
stream
|
|
.transform<String>(utf8.decoder)
|
|
.transform<String>(const LineSplitter())
|
|
.listen(
|
|
(String line) {
|
|
if (line == _kStartTimeoutTimerMessage) {
|
|
if (startTimeoutTimer != null) {
|
|
startTimeoutTimer();
|
|
}
|
|
} else if (line.startsWith('error: Unable to read Dart source \'package:test/')) {
|
|
printTrace('Shell: $line');
|
|
printError('\n\nFailed to load test harness. Are you missing a dependency on flutter_test?\n');
|
|
} else if (line.startsWith(observatoryString)) {
|
|
printTrace('Shell: $line');
|
|
try {
|
|
final Uri uri = Uri.parse(line.substring(observatoryString.length));
|
|
if (reportObservatoryUri != null) {
|
|
reportObservatoryUri(uri);
|
|
}
|
|
} catch (error) {
|
|
printError('Could not parse shell observatory port message: $error');
|
|
}
|
|
} else if (line != null) {
|
|
printStatus('Shell: $line');
|
|
}
|
|
},
|
|
onError: (dynamic error) {
|
|
printError('shell console stream for process pid ${process.pid} experienced an unexpected error: $error');
|
|
},
|
|
cancelOnError: true,
|
|
);
|
|
}
|
|
}
|
|
|
|
String _getErrorMessage(String what, String testPath, String shellPath) {
|
|
return '$what\nTest: $testPath\nShell: $shellPath\n\n';
|
|
}
|
|
|
|
String _getExitCodeMessage(int exitCode, String when) {
|
|
switch (exitCode) {
|
|
case 1:
|
|
return 'Shell subprocess cleanly reported an error $when. Check the logs above for an error message.';
|
|
case 0:
|
|
return 'Shell subprocess ended cleanly $when. Did main() call exit()?';
|
|
case -0x0f: // ProcessSignal.SIGTERM
|
|
return 'Shell subprocess crashed with SIGTERM ($exitCode) $when.';
|
|
case -0x0b: // ProcessSignal.SIGSEGV
|
|
return 'Shell subprocess crashed with segmentation fault $when.';
|
|
case -0x06: // ProcessSignal.SIGABRT
|
|
return 'Shell subprocess crashed with SIGABRT ($exitCode) $when.';
|
|
case -0x02: // ProcessSignal.SIGINT
|
|
return 'Shell subprocess terminated by ^C (SIGINT, $exitCode) $when.';
|
|
default:
|
|
return 'Shell subprocess crashed with unexpected exit code $exitCode $when.';
|
|
}
|
|
}
|
|
}
|
|
|
|
// The [_shellProcessClosed] future can't have errors thrown on it because it
|
|
// crosses zones (it's fed in a zone created by the test package, but listened
|
|
// to by a parent zone, the same zone that calls [close] below).
|
|
//
|
|
// This is because Dart won't let errors that were fed into a Future in one zone
|
|
// propagate to listeners in another zone. (Specifically, the zone in which the
|
|
// future was completed with the error, and the zone in which the listener was
|
|
// registered, are what matters.)
|
|
//
|
|
// Because of this, the [_shellProcessClosed] future takes an [_AsyncError]
|
|
// object as a result. If it's null, it's as if it had completed correctly; if
|
|
// it's non-null, it contains the error and stack trace of the actual error, as
|
|
// if it had completed with that error.
|
|
class _FlutterPlatformStreamSinkWrapper<S> implements StreamSink<S> {
|
|
_FlutterPlatformStreamSinkWrapper(this._parent, this._shellProcessClosed);
|
|
|
|
final StreamSink<S> _parent;
|
|
final Future<_AsyncError> _shellProcessClosed;
|
|
|
|
@override
|
|
Future<void> get done => _done.future;
|
|
final Completer<void> _done = Completer<void>();
|
|
|
|
@override
|
|
Future<dynamic> close() {
|
|
Future.wait<dynamic>(<Future<dynamic>>[
|
|
_parent.close(),
|
|
_shellProcessClosed,
|
|
]).then<void>(
|
|
(List<dynamic> futureResults) {
|
|
assert(futureResults.length == 2);
|
|
assert(futureResults.first == null);
|
|
if (futureResults.last is _AsyncError) {
|
|
_done.completeError(futureResults.last.error, futureResults.last.stack);
|
|
} else {
|
|
assert(futureResults.last == null);
|
|
_done.complete();
|
|
}
|
|
},
|
|
onError: _done.completeError,
|
|
);
|
|
return done;
|
|
}
|
|
|
|
@override
|
|
void add(S event) => _parent.add(event);
|
|
@override
|
|
void addError(dynamic errorEvent, [ StackTrace stackTrace ]) => _parent.addError(errorEvent, stackTrace);
|
|
@override
|
|
Future<dynamic> addStream(Stream<S> stream) => _parent.addStream(stream);
|
|
}
|
|
|
|
@immutable
|
|
class _AsyncError {
|
|
const _AsyncError(this.error, this.stack);
|
|
final dynamic error;
|
|
final StackTrace stack;
|
|
}
|