Commit 971ca4b8 authored by Ian Hickson's avatar Ian Hickson Committed by GitHub

Check exit code for test subprocess (#7269)

parent 502592e5
This directory is used by ///flutter/travis/test.sh to verify that This directory is used by //flutter/dev/bots/test.sh to verify that
`flutter test` actually correctly fails when a test fails. `flutter test` actually correctly fails when a test fails.
// Copyright 2016 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:io' as system;
import 'package:flutter_test/flutter_test.dart';
// this is a test to make sure our tests consider engine crashes to be failures
// see //flutter/dev/bots/test.sh
void main() {
test('test smoke test -- this test should fail', () async {
system.Process.killPid(system.pid, system.ProcessSignal.SIGSEGV);
});
}
\ No newline at end of file
// Copyright 2016 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:io' as system;
// this is a test to make sure our tests consider engine crashes to be failures
// see //flutter/dev/bots/test.sh
void main() {
system.Process.killPid(system.pid, system.ProcessSignal.SIGSEGV);
}
\ No newline at end of file
...@@ -5,7 +5,7 @@ ...@@ -5,7 +5,7 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
// this is a test to make sure our tests actually catch failures // this is a test to make sure our tests actually catch failures
// see ///flutter/travis/test.sh // see //flutter/dev/bots/test.sh
void main() { void main() {
test('test smoke test -- this test SHOULD FAIL', () async { test('test smoke test -- this test SHOULD FAIL', () async {
......
// Copyright 2016 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.
// this is a test to make sure our tests consider syntax errors to be failures
// see //flutter/dev/bots/test.sh
void main() {
fail(); // inspired by https://github.com/flutter/flutter/issues/2698
}
...@@ -5,7 +5,7 @@ ...@@ -5,7 +5,7 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
// this is a test to make sure our tests actually catch failures // this is a test to make sure our tests actually catch failures
// see ///flutter/travis/test.sh // see //flutter/dev/bots/test.sh
void main() { void main() {
test('test smoke test -- this test should pass', () async { test('test smoke test -- this test should pass', () async {
......
// Copyright 2016 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.
// this is a test to make sure our tests consider syntax errors to be failures
// see //flutter/dev/bots/test.sh
The challenge: demand satisfaction
If they apologize, no need for further action.
...@@ -22,6 +22,10 @@ flutter analyze --flutter-repo ...@@ -22,6 +22,10 @@ flutter analyze --flutter-repo
# verify that the tests actually return failure on failure and success on success # verify that the tests actually return failure on failure and success on success
(cd dev/automated_tests; ! flutter test test_smoke_test/fail_test.dart > /dev/null) (cd dev/automated_tests; ! flutter test test_smoke_test/fail_test.dart > /dev/null)
(cd dev/automated_tests; flutter test test_smoke_test/pass_test.dart > /dev/null) (cd dev/automated_tests; flutter test test_smoke_test/pass_test.dart > /dev/null)
(cd dev/automated_tests; ! flutter test test_smoke_test/crash1_test.dart > /dev/null)
(cd dev/automated_tests; ! flutter test test_smoke_test/crash2_test.dart > /dev/null)
(cd dev/automated_tests; ! flutter test test_smoke_test/syntax_error_test.broken_dart > /dev/null)
(cd dev/automated_tests; ! flutter test test_smoke_test/missing_import_test.broken_dart > /dev/null)
(cd packages/flutter_driver; ! flutter drive --use-existing-app -t test_driver/failure.dart >/dev/null 2>&1) (cd packages/flutter_driver; ! flutter drive --use-existing-app -t test_driver/failure.dart >/dev/null 2>&1)
COVERAGE_FLAG= COVERAGE_FLAG=
......
...@@ -6,7 +6,7 @@ import 'dart:async'; ...@@ -6,7 +6,7 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
import 'package:test/src/executable.dart' as executable; // ignore: implementation_imports import 'package:test/src/executable.dart' as test; // ignore: implementation_imports
import '../base/common.dart'; import '../base/common.dart';
import '../base/logger.dart'; import '../base/logger.dart';
...@@ -77,9 +77,9 @@ class TestCommand extends FlutterCommand { ...@@ -77,9 +77,9 @@ class TestCommand extends FlutterCommand {
Directory.current = testDirectory; Directory.current = testDirectory;
} }
printTrace('running test package with arguments: $testArgs'); printTrace('running test package with arguments: $testArgs');
await executable.main(testArgs); await test.main(testArgs);
// test.main() sets dart:io's exitCode global.
printTrace('test package returned with exit code $exitCode'); printTrace('test package returned with exit code $exitCode');
return exitCode; return exitCode;
} finally { } finally {
Directory.current = currentDirectory; Directory.current = currentDirectory;
...@@ -164,10 +164,10 @@ class TestCommand extends FlutterCommand { ...@@ -164,10 +164,10 @@ class TestCommand extends FlutterCommand {
if (argResults['coverage']) if (argResults['coverage'])
testArgs.insert(0, '--concurrency=1'); testArgs.insert(0, '--concurrency=1');
loader.installHook(); final String shellPath = tools.getHostToolPath(HostTool.SkyShell) ?? Platform.environment['SKY_SHELL'];
loader.shellPath = tools.getHostToolPath(HostTool.SkyShell); if (!FileSystemEntity.isFileSync(shellPath))
if (!FileSystemEntity.isFileSync(loader.shellPath)) throwToolExit('Cannot find Flutter shell at $shellPath');
throwToolExit('Cannot find Flutter shell at ${loader.shellPath}'); loader.installHook(shellPath: shellPath);
Cache.releaseLockEarly(); Cache.releaseLockEarly();
......
...@@ -20,14 +20,14 @@ class CoverageCollector { ...@@ -20,14 +20,14 @@ class CoverageCollector {
void collectCoverage({ void collectCoverage({
String host, String host,
int port, int port,
Process processToKill Process processToKill,
}) { }) {
if (enabled) { if (enabled) {
assert(_jobs != null); assert(_jobs != null);
_jobs.add(_startJob( _jobs.add(_startJob(
host: host, host: host,
port: port, port: port,
processToKill: processToKill processToKill: processToKill,
)); ));
} else { } else {
processToKill.kill(); processToKill.kill();
...@@ -37,7 +37,7 @@ class CoverageCollector { ...@@ -37,7 +37,7 @@ class CoverageCollector {
Future<Null> _startJob({ Future<Null> _startJob({
String host, String host,
int port, int port,
Process processToKill Process processToKill,
}) async { }) async {
int pid = processToKill.pid; int pid = processToKill.pid;
printTrace('collecting coverage data from pid $pid on port $port'); printTrace('collecting coverage data from pid $pid on port $port');
......
...@@ -7,7 +7,6 @@ import 'dart:convert'; ...@@ -7,7 +7,6 @@ import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:async/async.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
import 'package:stream_channel/stream_channel.dart'; import 'package:stream_channel/stream_channel.dart';
...@@ -20,113 +19,198 @@ import '../dart/package_map.dart'; ...@@ -20,113 +19,198 @@ import '../dart/package_map.dart';
import '../globals.dart'; import '../globals.dart';
import 'coverage_collector.dart'; import 'coverage_collector.dart';
final String _kSkyShell = Platform.environment['SKY_SHELL']; const Duration _kTestStartupTimeout = const Duration(seconds: 5);
final InternetAddress _kHost = InternetAddress.LOOPBACK_IP_V4; final InternetAddress _kHost = InternetAddress.LOOPBACK_IP_V4;
const String _kRunnerPath = '/runner';
const String _kShutdownPath = '/shutdown';
String shellPath; void installHook({ String shellPath }) {
hack.registerPlatformPlugin(<TestPlatform>[TestPlatform.vm], () => new FlutterPlatform(shellPath: shellPath));
List<String> fontDirectories = <String>[cache.getCacheArtifacts().path];
void installHook() {
hack.registerPlatformPlugin(<TestPlatform>[TestPlatform.vm], () => new FlutterPlatform());
} }
class _ServerInfo { enum _InitialResult { crashed, timedOut, connected }
final String url; enum _TestResult { crashed, harnessBailed, completed }
final String shutdownUrl; typedef Future<Null> _Finalizer();
final Future<WebSocket> socket;
final HttpServer server;
_ServerInfo(this.server, this.url, this.shutdownUrl, this.socket); class FlutterPlatform extends PlatformPlugin {
} FlutterPlatform({ this.shellPath }) {
assert(shellPath != null);
}
Future<_ServerInfo> _startServer() async { final String shellPath;
HttpServer server = await HttpServer.bind(_kHost, 0);
Completer<WebSocket> socket = new Completer<WebSocket>();
server.listen((HttpRequest request) {
if (request.uri.path == _kRunnerPath)
socket.complete(WebSocketTransformer.upgrade(request));
else if (!socket.isCompleted && request.uri.path == _kShutdownPath)
socket.completeError('Failed to start test');
});
return new _ServerInfo(server, 'ws://${_kHost.address}:${server.port}$_kRunnerPath',
'ws://${_kHost.address}:${server.port}$_kShutdownPath', socket.future);
}
Future<Process> _startProcess(String mainPath, { String packages, int observatoryPort }) { // Each time loadChannel() is called, we spin up a local WebSocket server,
assert(shellPath != null || _kSkyShell != null); // Please provide the path to the shell in the SKY_SHELL environment variable. // then spin up the engine in a subprocess. We pass the engine a Dart file
String executable = shellPath ?? _kSkyShell; // that connects to our WebSocket server, then we proxy JSON messages from
List<String> arguments = <String>[]; // the test harness to the engine and back again. If at any time the engine
if (observatoryPort != null) { // crashes, we inject an error into that stream. When the process closes,
arguments.add('--observatory-port=$observatoryPort'); // we clean everything up.
} else {
arguments.add('--disable-observatory'); @override
StreamChannel<dynamic> loadChannel(String testPath, TestPlatform platform) {
final StreamChannelController<dynamic> controller = new StreamChannelController<dynamic>(allowForeignErrors: false);
_startTest(testPath, controller.local);
return controller.foreign;
} }
arguments.addAll(<String>[
'--enable-dart-profiling',
'--non-interactive',
'--enable-checked-mode',
'--packages=$packages',
mainPath
]);
printTrace('$executable ${arguments.join(' ')}');
Map<String, String> environment = <String, String>{
'FLUTTER_TEST': 'true',
'FONTCONFIG_FILE': _fontConfigFile.path,
};
return processManager.start(executable, arguments, environment: environment);
}
void _attachStandardStreams(Process process) { Future<Null> _startTest(String testPath, StreamChannel<dynamic> controller) async {
for (Stream<List<int>> stream in printTrace('starting test: $testPath');
<Stream<List<int>>>[process.stderr, process.stdout]) {
stream.transform(UTF8.decoder) final List<_Finalizer> finalizers = <_Finalizer>[];
.transform(const LineSplitter()) bool subprocessActive = false;
.listen((String line) { bool controllerSinkClosed = false;
if (line != null) try {
print('Shell: $line'); controller.sink.done.then((_) { controllerSinkClosed = true; });
// Prepare our WebSocket server to talk to the engine subproces.
HttpServer server = await HttpServer.bind(_kHost, 0);
finalizers.add(() async { await server.close(force: true); });
Completer<WebSocket> webSocket = new Completer<WebSocket>();
server.listen((HttpRequest request) {
webSocket.complete(WebSocketTransformer.upgrade(request));
}); });
}
}
File _cachedFontConfig; // Prepare a temporary directory to store the Dart file that will talk to us.
Directory temporaryDirectory = Directory.systemTemp.createTempSync('dart_test_listener');
finalizers.add(() async { temporaryDirectory.deleteSync(recursive: true); });
/// Returns a Fontconfig config file that limits font fallback to directories // Prepare the Dart file that will talk to us and start the test.
/// specified in [fontDirectories]. File listenerFile = new File('${temporaryDirectory.path}/listener.dart');
File get _fontConfigFile { listenerFile.createSync();
if (_cachedFontConfig != null) return _cachedFontConfig; listenerFile.writeAsStringSync(_generateTestMain(
testUrl: path.toUri(path.absolute(testPath)).toString(),
encodedWebsocketUrl: Uri.encodeComponent("ws://${_kHost.address}:${server.port}"),
));
Directory fontsDir = Directory.systemTemp.createTempSync('flutter_fonts'); // If we are collecting coverage data, then set that up now.
int observatoryPort;
if (CoverageCollector.instance.enabled) {
// TODO(ianh): the random number on the next line is a landmine that will eventually
// cause a hard-to-find bug...
observatoryPort = CoverageCollector.instance.observatoryPort ?? new math.Random().nextInt(30000) + 2000;
await CoverageCollector.instance.finishPendingJobs();
}
StringBuffer sb = new StringBuffer(); // Start the engine subprocess.
sb.writeln('<fontconfig>'); Process process = await _startProcess(
for (String fontDir in fontDirectories) { shellPath,
sb.writeln(' <dir>$fontDir</dir>'); listenerFile.path,
} packages: PackageMap.globalPackagesPath,
sb.writeln(' <cachedir>/var/cache/fontconfig</cachedir>'); observatoryPort: observatoryPort,
sb.writeln('</fontconfig>'); );
subprocessActive = true;
finalizers.add(() async {
if (subprocessActive)
process.kill();
int exitCode = await process.exitCode;
subprocessActive = false;
if (!controllerSinkClosed && exitCode != 0) {
String message = _getErrorMessage(_getExitCodeMessage(exitCode, 'after tests finished'), testPath, shellPath);
controller.sink.addError(new Exception(message));
}
});
_cachedFontConfig = new File('${fontsDir.path}/fonts.conf'); // Pipe stdout and stderr from the subprocess to our printStatus console.
_cachedFontConfig.createSync(); _pipeStandardStreamsToConsole(process);
_cachedFontConfig.writeAsStringSync(sb.toString());
return _cachedFontConfig;
}
class FlutterPlatform extends PlatformPlugin { // At this point, three things can happen next:
@override // The engine could crash, in which case process.exitCode will complete.
StreamChannel<dynamic> loadChannel(String mainPath, TestPlatform platform) { // The engine could connect to us, in which case webSocket.future will complete.
return StreamChannelCompleter.fromFuture(_startTest(mainPath)); // The local test harness could get bored of us.
_InitialResult initialResult = await Future.any(<Future<_InitialResult>>[
process.exitCode.then<_InitialResult>((int exitCode) { return _InitialResult.crashed; }),
new Future<_InitialResult>.delayed(_kTestStartupTimeout, () { return _InitialResult.timedOut; }),
webSocket.future.then<_InitialResult>((WebSocket webSocket) { return _InitialResult.connected; }),
]);
switch (initialResult) {
case _InitialResult.crashed:
int exitCode = await process.exitCode;
String message = _getErrorMessage(_getExitCodeMessage(exitCode, 'before connecting to test harness'), testPath, shellPath);
controller.sink.addError(new Exception(message));
controller.sink.close();
await controller.sink.done;
break;
case _InitialResult.timedOut:
String message = _getErrorMessage('Test never connected to test harness.', testPath, shellPath);
controller.sink.addError(new Exception(message));
controller.sink.close();
await controller.sink.done;
break;
case _InitialResult.connected:
WebSocket testSocket = await webSocket.future;
Completer<Null> harnessDone = new Completer<Null>();
StreamSubscription<dynamic> harnessToTest = controller.stream.listen(
(dynamic event) { testSocket.add(JSON.encode(event)); },
onDone: () { harnessDone.complete(); },
);
Completer<Null> testDone = new Completer<Null>();
StreamSubscription<dynamic> testToHarness = testSocket.listen(
(dynamic event) {
assert(event is String); // we shouldn't ever get binary messages
controller.sink.add(JSON.decode(event));
},
onDone: () { testDone.complete(); },
);
_TestResult testResult = await Future.any(<Future<_TestResult>>[
process.exitCode.then<_TestResult>((int exitCode) { return _TestResult.crashed; }),
testDone.future.then<_TestResult>((Null _) { return _TestResult.completed; }),
harnessDone.future.then<_TestResult>((Null _) { return _TestResult.harnessBailed; }),
]);
harnessToTest.cancel();
testToHarness.cancel();
assert(!controllerSinkClosed);
switch (testResult) {
case _TestResult.crashed:
int exitCode = await process.exitCode;
subprocessActive = false;
String message = _getErrorMessage(_getExitCodeMessage(exitCode, 'before test harness closed its WebSocket'), testPath, shellPath);
controller.sink.addError(new Exception(message));
controller.sink.close();
await controller.sink.done;
break;
case _TestResult.completed:
break;
case _TestResult.harnessBailed:
break;
}
break;
}
CoverageCollector.instance.collectCoverage(
host: _kHost.address,
port: observatoryPort,
processToKill: process, // This kills the subprocess whether coverage is enabled or not.
);
subprocessActive = false;
} catch (e, stack) {
if (!controllerSinkClosed) {
controller.sink.addError(e, stack);
} else {
printError('unhandled error during test:\n$e\n$stack');
}
} finally {
for (_Finalizer finalizer in finalizers)
await finalizer();
if (!controllerSinkClosed) {
controller.sink.close();
await controller.sink.done;
}
}
assert(!subprocessActive);
assert(controllerSinkClosed);
printTrace('ending test: $testPath');
} }
Future<StreamChannel<dynamic>> _startTest(String mainPath) async { String _generateTestMain({
_ServerInfo info = await _startServer(); String testUrl,
Directory tempDir = Directory.systemTemp.createTempSync( String encodedWebsocketUrl,
'dart_test_listener'); }) {
File listenerFile = new File('${tempDir.path}/listener.dart'); return '''
listenerFile.createSync();
listenerFile.writeAsStringSync('''
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
...@@ -134,10 +218,10 @@ import 'package:stream_channel/stream_channel.dart'; ...@@ -134,10 +218,10 @@ import 'package:stream_channel/stream_channel.dart';
import 'package:test/src/runner/plugin/remote_platform_helpers.dart'; import 'package:test/src/runner/plugin/remote_platform_helpers.dart';
import 'package:test/src/runner/vm/catch_isolate_errors.dart'; import 'package:test/src/runner/vm/catch_isolate_errors.dart';
import '${path.toUri(path.absolute(mainPath))}' as test; import '$testUrl' as test;
void main() { void main() {
String server = Uri.decodeComponent('${Uri.encodeComponent(info.url)}'); String server = Uri.decodeComponent('$encodedWebsocketUrl');
StreamChannel channel = serializeSuite(() { StreamChannel channel = serializeSuite(() {
catchIsolateErrors(); catchIsolateErrors();
return test.main; return test.main;
...@@ -147,65 +231,82 @@ void main() { ...@@ -147,65 +231,82 @@ void main() {
socket.addStream(channel.stream.map(JSON.encode)); socket.addStream(channel.stream.map(JSON.encode));
}); });
} }
'''); ''';
}
File _cachedFontConfig;
/// Returns a Fontconfig config file that limits font fallback to the
/// artifact cache directory.
File get _fontConfigFile {
if (_cachedFontConfig != null)
return _cachedFontConfig;
int observatoryPort; StringBuffer sb = new StringBuffer();
if (CoverageCollector.instance.enabled) { sb.writeln('<fontconfig>');
observatoryPort = CoverageCollector.instance.observatoryPort ?? new math.Random().nextInt(30000) + 2000; sb.writeln(' <dir>${cache.getCacheArtifacts().path}</dir>');
await CoverageCollector.instance.finishPendingJobs(); sb.writeln(' <cachedir>/var/cache/fontconfig</cachedir>');
sb.writeln('</fontconfig>');
Directory fontsDir = Directory.systemTemp.createTempSync('flutter_fonts');
_cachedFontConfig = new File('${fontsDir.path}/fonts.conf');
_cachedFontConfig.createSync();
_cachedFontConfig.writeAsStringSync(sb.toString());
return _cachedFontConfig;
}
Future<Process> _startProcess(String executable, String testPath, { String packages, int observatoryPort }) {
assert(executable != null); // Please provide the path to the shell in the SKY_SHELL environment variable.
List<String> arguments = <String>[];
if (observatoryPort != null) {
arguments.add('--observatory-port=$observatoryPort');
} else {
arguments.add('--disable-observatory');
} }
arguments.addAll(<String>[
'--enable-dart-profiling',
'--non-interactive',
'--enable-checked-mode',
'--packages=$packages',
testPath,
]);
printTrace('$executable ${arguments.join(' ')}');
Map<String, String> environment = <String, String>{
'FLUTTER_TEST': 'true',
'FONTCONFIG_FILE': _fontConfigFile.path,
};
return processManager.start(executable, arguments, environment: environment);
}
Process process = await _startProcess( void _pipeStandardStreamsToConsole(Process process) {
listenerFile.path, for (Stream<List<int>> stream in
packages: PackageMap.globalPackagesPath, <Stream<List<int>>>[process.stderr, process.stdout]) {
observatoryPort: observatoryPort stream.transform(UTF8.decoder)
); .transform(const LineSplitter())
.listen((String line) {
_attachStandardStreams(process); if (line != null)
printStatus('Shell: $line');
void finalize() { });
if (process != null) {
Process processToKill = process;
process = null;
CoverageCollector.instance.collectCoverage(
host: _kHost.address,
port: observatoryPort,
processToKill: processToKill
);
}
if (tempDir != null) {
Directory dirToDelete = tempDir;
tempDir = null;
dirToDelete.deleteSync(recursive: true);
}
} }
}
process.exitCode.then((_) { String _getErrorMessage(String what, String testPath, String shellPath) {
WebSocket.connect(info.shutdownUrl); return '$what\nTest: $testPath\nShell: $shellPath\n\n';
}); }
try { String _getExitCodeMessage(int exitCode, String when) {
WebSocket socket = await info.socket; switch (exitCode) {
StreamChannel<dynamic> channel = new StreamChannel<dynamic>(socket.map(JSON.decode), socket); case 0:
return channel.transformStream( return 'Shell subprocess ended cleanly $when. Did main() call exit()?';
new StreamTransformer<dynamic, dynamic>.fromHandlers( case -0x0f: // ProcessSignal.SIGTERM
handleDone: (EventSink<dynamic> sink) { return 'Shell subprocess crashed with SIGTERM ($exitCode) $when.';
finalize(); case -0x0b: // ProcessSignal.SIGSEGV
sink.close(); return 'Shell subprocess crashed with segmentation fault $when.';
} case -0x06: // ProcessSignal.SIGABRT
) return 'Shell subprocess crashed with SIGABRT ($exitCode) $when.';
).transformSink(new StreamSinkTransformer<dynamic, String>.fromHandlers( default:
handleData: (dynamic data, StreamSink<String> sink) { return 'Shell subprocess crashed with unexpected exit code $exitCode $when.';
sink.add(JSON.encode(data));
},
handleDone: (EventSink<String> sink) {
finalize();
sink.close();
}
));
} catch(e) {
finalize();
rethrow;
} }
} }
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment