diff --git a/lib/src/backend/closed_exception.dart b/lib/src/backend/closed_exception.dart new file mode 100644 index 0000000000000000000000000000000000000000..0c9dcca2e5bc70eb0643fdc55eb227e139219cee --- /dev/null +++ b/lib/src/backend/closed_exception.dart @@ -0,0 +1,13 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +library unittest.backend.closed_exception; + +/// An exception thrown by various front-end methods when the test framework has +/// been closed and a test must shut down as soon as possible. +class ClosedException implements Exception { + ClosedException(); + + String toString() => "This test has been closed."; +} diff --git a/lib/src/backend/invoker.dart b/lib/src/backend/invoker.dart index 228b90e64b46583dd4eb7f5382b8425b5122d05c..057e704918cc1e64276066992b48eb151c815aa0 100644 --- a/lib/src/backend/invoker.dart +++ b/lib/src/backend/invoker.dart @@ -10,6 +10,7 @@ import 'package:stack_trace/stack_trace.dart'; import '../frontend/expect.dart'; import '../utils.dart'; +import 'closed_exception.dart'; import 'live_test.dart'; import 'live_test_controller.dart'; import 'metadata.dart'; @@ -55,6 +56,14 @@ class Invoker { LiveTest get liveTest => _controller.liveTest; LiveTestController _controller; + /// Whether the test has been closed. + /// + /// Once the test is closed, [expect] and [expectAsync] will throw + /// [ClosedException]s whenever accessed to help the test stop executing as + /// soon as possible. + bool get closed => _closed; + bool _closed = false; + /// The test being run. LocalTest get _test => liveTest.test as LocalTest; @@ -76,7 +85,9 @@ class Invoker { } Invoker._(Suite suite, LocalTest test) { - _controller = new LiveTestController(suite, test, _onRun); + _controller = new LiveTestController(suite, test, _onRun, () { + _closed = true; + }); } /// Tells the invoker that there's a callback running that it should wait for @@ -87,7 +98,10 @@ class Invoker { /// that only successful tests wait for outstanding callbacks; as soon as a /// test experiences an error, any further calls to [addOutstandingCallback] /// or [removeOutstandingCallback] will do nothing. + /// + /// Throws a [ClosedException] if this test has been closed. void addOutstandingCallback() { + if (closed) throw new ClosedException(); _outstandingCallbacks++; } @@ -162,10 +176,6 @@ class Invoker { new Future(_test._body) .then((_) => removeOutstandingCallback()); - // Explicitly handle an error here so that we can return the [Future]. - // If a [Future] returned from an error zone would throw an error - // through the zone boundary, it instead never completes, and we want to - // avoid that. _completer.future.then((_) { if (_test._tearDown == null) return null; return new Future.sync(_test._tearDown); diff --git a/lib/src/backend/live_test.dart b/lib/src/backend/live_test.dart index 4b1697d76446b25824283cbe032b53af7b05c6f6..4cc917a5a4af3efb1dbcd30c8b87256600c23484 100644 --- a/lib/src/backend/live_test.dart +++ b/lib/src/backend/live_test.dart @@ -111,10 +111,16 @@ abstract class LiveTest { /// Once [close] is called, [onComplete] will complete if it hasn't already /// and [onStateChange] and [onError] will close immediately. This means that, /// if the test was running at the time [close] is called, it will never emit - /// a [Status.complete] state-change event. + /// a [Status.complete] state-change event. Once a test is closed, [expect] + /// and [expectAsync] will throw a [ClosedException] to help the test + /// terminate as quickly as possible. /// /// This doesn't automatically happen after the test completes because there /// may be more asynchronous work going on in the background that could /// produce new errors. + /// + /// Returns a [Future] that completes once all resources are released *and* + /// the test has completed. This allows the caller to wait until the test's + /// tear-down logic has run. Future close(); } diff --git a/lib/src/backend/live_test_controller.dart b/lib/src/backend/live_test_controller.dart index f84b1ce8a1a5fe9ab9ff37292bec2bfaabe6ac03..71731b6f5cf8a31feac0517f63a4e92c9254983d 100644 --- a/lib/src/backend/live_test_controller.dart +++ b/lib/src/backend/live_test_controller.dart @@ -110,15 +110,16 @@ class LiveTestController { /// /// [test] is the test being run; [suite] is the suite that contains it. /// - /// [onRun] is a function that will be called from [LiveTest.run]. It should - /// start the test running. The controller takes care of ensuring that + /// [onRun] is a function that's called from [LiveTest.run]. It should start + /// the test running. The controller takes care of ensuring that /// [LiveTest.run] isn't called more than once and that [LiveTest.onComplete] /// is returned. /// - /// If [onClose] is passed, it's called the first [LiveTest.close] is called. - /// It should clean up any resources that have been allocated for the test. It - /// may return a [Future]. - LiveTestController(this._suite, this._test, void onRun(), {onClose()}) + /// [onClose] is a function that's called the first time [LiveTest.close] is + /// called. It should clean up any resources that have been allocated for the + /// test and ensure that the test finishes quickly if it's still running. It + /// will only be called if [onRun] has been called first. + LiveTestController(this._suite, this._test, void onRun(), void onClose()) : _onRun = onRun, _onClose = onClose { _liveTest = new _LiveTest(this); @@ -130,6 +131,8 @@ class LiveTestController { /// [LiveTest.onError]. [stackTrace] is automatically converted into a [Chain] /// if it's not one already. void addError(error, StackTrace stackTrace) { + if (_isClosed) return; + var asyncError = new AsyncError(error, new Chain.forTrace(stackTrace)); _errors.add(asyncError); _onErrorController.add(asyncError); @@ -141,7 +144,9 @@ class LiveTestController { /// [LiveTest.state] and emits the new state via [LiveTest.onStateChanged]. If /// it's not different, this does nothing. void setState(State newState) { + if (_isClosed) return; if (_state == newState) return; + _state = newState; _onStateChangeController.add(newState); } @@ -163,12 +168,17 @@ class LiveTestController { /// A wrapper for [_onClose] that ensures that all controllers are closed. Future _close() { - if (_isClosed) return new Future.value(); + if (_isClosed) return completer.future; + _onStateChangeController.close(); _onErrorController.close(); - if (!completer.isCompleted) completer.complete(); - if (_onClose != null) return new Future.sync(_onClose); - return new Future.value(); + if (_runCalled) { + _onClose(); + } else { + completer.complete(); + } + + return completer.future; } } diff --git a/lib/src/executable.dart b/lib/src/executable.dart index fe403b2a1f26423aed50fcc52059e55c28de2aba..b70a5066a2a6c3824fb12ff26cdb206c2dbe1154 100644 --- a/lib/src/executable.dart +++ b/lib/src/executable.dart @@ -25,6 +25,16 @@ import 'utils.dart'; /// The argument parser used to parse the executable arguments. final _parser = new ArgParser(allowTrailingOptions: true); +/// A merged stream of all signals that tell the test runner to shut down +/// gracefully. +/// +/// Signals will only be captured as long as this has an active subscription. +/// Otherwise, they'll be handled by Dart's default signal handler, which +/// terminates the program immediately. +final _signals = mergeStreams([ + ProcessSignal.SIGTERM.watch(), ProcessSignal.SIGINT.watch() +]); + void main(List<String> args) { _parser.addFlag("help", abbr: "h", negatable: false, help: "Shows this usage information."); @@ -65,6 +75,15 @@ void main(List<String> args) { var platforms = options["platform"].map(TestPlatform.find); var loader = new Loader(platforms, packageRoot: options["package-root"], color: color); + + var signalSubscription; + var closed = false; + signalSubscription = _signals.listen((_) { + signalSubscription.cancel(); + closed = true; + loader.close(); + }); + new Future.sync(() { var paths = options.rest; if (paths.isEmpty) { @@ -82,6 +101,7 @@ void main(List<String> args) { throw new LoadException(path, 'Does not exist.'); })); }).then((suites) { + if (closed) return null; suites = flatten(suites); var pattern; @@ -116,10 +136,30 @@ void main(List<String> args) { } var reporter = new CompactReporter(flatten(suites), color: color); + + // Override the signal handler to close [reporter]. [loader] will still be + // closed in the [whenComplete] below. + signalSubscription.onData((_) { + signalSubscription.cancel(); + closed = true; + + // Wait a bit to print this message, since printing it eagerly looks weird + // if the tests then finish immediately. + var timer = new Timer(new Duration(seconds: 1), () { + print("Waiting for current test to finish."); + print("Press Control-C again to terminate immediately."); + }); + + reporter.close().then((_) => timer.cancel()); + }); + return reporter.run().then((success) { exitCode = success ? 0 : 1; - }).whenComplete(() => reporter.close()); - }).catchError((error, stackTrace) { + }).whenComplete(() { + signalSubscription.cancel(); + return reporter.close(); + }); + }).whenComplete(signalSubscription.cancel).catchError((error, stackTrace) { if (error is LoadException) { stderr.writeln(error.toString(color: color)); diff --git a/lib/src/frontend/expect.dart b/lib/src/frontend/expect.dart index 71a141694f42ba7f0a04415ca4f34651430672c0..d39986b40231df2599403430c43909ff703e40e5 100644 --- a/lib/src/frontend/expect.dart +++ b/lib/src/frontend/expect.dart @@ -6,6 +6,9 @@ library test.frontend.expect; import 'package:matcher/matcher.dart'; +import '../backend/closed_exception.dart'; +import '../backend/invoker.dart'; + /// An exception thrown when a test assertion fails. class TestFailure { final String message; @@ -36,6 +39,8 @@ typedef String ErrorFormatter( /// [verbose] should be specified as `true`. void expect(actual, matcher, {String reason, bool verbose: false, ErrorFormatter formatter}) { + if (Invoker.current.closed) throw new ClosedException(); + matcher = wrapMatcher(matcher); var matchState = {}; try { diff --git a/lib/src/runner/browser/compiler_pool.dart b/lib/src/runner/browser/compiler_pool.dart index e5ba0565fe9e48b7124973a2fe34974ebbef4b6c..a424151af93fda56f221f1d07a48e93a325f7f07 100644 --- a/lib/src/runner/browser/compiler_pool.dart +++ b/lib/src/runner/browser/compiler_pool.dart @@ -35,6 +35,12 @@ class CompilerPool { /// emitted once they become visible. final _compilers = new Queue<_Compiler>(); + /// Whether [close] has been called. + bool get _closed => _closeCompleter != null; + + /// The completer for the [Future] returned by [close]. + Completer _closeCompleter; + /// Creates a compiler pool that runs up to [parallel] instances of `dart2js` /// at once. /// @@ -55,6 +61,8 @@ class CompilerPool { /// *and* all its output has been printed to the command line. Future compile(String dartPath, String jsPath, {String packageRoot}) { return _pool.withResource(() { + if (_closed) return null; + return withTempDir((dir) { var wrapperPath = p.join(dir, "runInBrowser.dart"); new File(wrapperPath).writeAsStringSync(''' @@ -103,12 +111,18 @@ void main(_) { compiler.process.stdout.listen(stdout.add).asFuture(), compiler.process.stderr.listen(stderr.add).asFuture(), compiler.process.exitCode.then((exitCode) { - if (exitCode == 0) return; + if (exitCode == 0 || _closed) return; throw new LoadException(compiler.path, "dart2js failed."); }) - ]).then(compiler.onDoneCompleter.complete) - .catchError(compiler.onDoneCompleter.completeError) - .then((_) { + ]).then((_) { + if (_closed) return; + compiler.onDoneCompleter.complete(); + }).catchError((error, stackTrace) { + if (_closed) return; + compiler.onDoneCompleter.completeError(error, stackTrace); + }).then((_) { + if (_closed) return; + _compilers.removeFirst(); if (_compilers.isEmpty) return; @@ -119,6 +133,24 @@ void main(_) { Timer.run(() => _showProcess(next)); }); } + + /// Closes the compiler pool. + /// + /// This kills all currently-running compilers and ensures that no more will + /// be started. It returns a [Future] that completes once all the compilers + /// have been killed and all resources released. + Future close() { + if (_closed) return _closeCompleter.future; + _closeCompleter = new Completer(); + + return Future.wait(_compilers.map((compiler) { + compiler.process.kill(); + return compiler.process.exitCode.then(compiler.onDoneCompleter.complete); + })).then((_) { + _compilers.clear(); + _closeCompleter.complete(); + }).catchError(_closeCompleter.completeError); + } } /// A running instance of `dart2js`. diff --git a/lib/src/runner/browser/iframe_listener.dart b/lib/src/runner/browser/iframe_listener.dart index 6cef9f62b2022d1a69f25605e3b95a8bb37ca1d7..cdc9edad9321aad065e5434f6b7aee5d99377deb 100644 --- a/lib/src/runner/browser/iframe_listener.dart +++ b/lib/src/runner/browser/iframe_listener.dart @@ -143,6 +143,11 @@ class IframeListener { void _runTest(Test test, MultiChannel channel) { var liveTest = test.load(_suite); + channel.stream.listen((message) { + assert(message['command'] == 'close'); + liveTest.close(); + }); + liveTest.onStateChange.listen((state) { channel.sink.add({ "type": "state-change", diff --git a/lib/src/runner/browser/iframe_test.dart b/lib/src/runner/browser/iframe_test.dart index 6b663ab1999163750a977b951fee532e8f9666b0..6b799419c5ba8458639c86dae4c79d83180fee85 100644 --- a/lib/src/runner/browser/iframe_test.dart +++ b/lib/src/runner/browser/iframe_test.dart @@ -25,10 +25,11 @@ class IframeTest implements Test { LiveTest load(Suite suite) { var controller; + var testChannel; controller = new LiveTestController(suite, this, () { controller.setState(const State(Status.running, Result.success)); - var testChannel = _channel.virtualChannel(); + testChannel = _channel.virtualChannel(); _channel.sink.add({ 'command': 'run', 'channel': testChannel.id @@ -50,6 +51,12 @@ class IframeTest implements Test { controller.completer.complete(); } }); + }, () { + // Ignore all future messages from the test and complete it immediately. + // We don't need to tell it to run its tear-down because there's nothing a + // browser test needs to clean up on the file system anyway. + testChannel.sink.close(); + if (!controller.completer.isCompleted) controller.completer.complete(); }); return controller.liveTest; } diff --git a/lib/src/runner/browser/server.dart b/lib/src/runner/browser/server.dart index 9730e3c2a276ca36808cccd00ca5caabc535146f..f39466ed31cf6d138a34c50677557076b023cd7a 100644 --- a/lib/src/runner/browser/server.dart +++ b/lib/src/runner/browser/server.dart @@ -64,6 +64,12 @@ class BrowserServer { /// This is `null` until a suite is loaded. Chrome _browser; + /// Whether [close] has been called. + bool get _closed => _closeCompleter != null; + + /// The completer for the [Future] returned by [close]. + Completer _closeCompleter; + /// A future that will complete to the [BrowserManager] for [_browser]. /// /// The first time this is called, it will start both the browser and the @@ -92,7 +98,7 @@ class BrowserServer { Completer<BrowserManager> _browserManagerCompleter; BrowserServer._(this._packageRoot, bool color) - : _compiledDir = Directory.systemTemp.createTempSync('test_').path, + : _compiledDir = createTempDir(), _compilers = new CompilerPool(color: color); /// Starts the underlying server. @@ -114,8 +120,12 @@ class BrowserServer { /// This will start a browser to load the suite if one isn't already running. Future<Suite> loadSuite(String path) { return _compileSuite(path).then((dir) { + if (_closed) return null; + // TODO(nweiz): Don't start the browser until all the suites are compiled. return _browserManager.then((browserManager) { + if (_closed) return null; + // Add a trailing slash because at least on Chrome, the iframe's // window.location.href will do so automatically, and if that differs // from the original URL communication will fail. @@ -135,6 +145,8 @@ class BrowserServer { return _compilers.compile(dartPath, jsPath, packageRoot: packageRootFor(dartPath, _packageRoot)) .then((_) { + if (_closed) return null; + // TODO(nweiz): support user-authored HTML files. new File(p.join(dir, "index.html")).writeAsStringSync(''' <!DOCTYPE html> @@ -154,10 +166,18 @@ class BrowserServer { /// Returns a [Future] that completes once the server is closed and its /// resources have been fully released. Future close() { - new Directory(_compiledDir).deleteSync(recursive: true); - return _server.close().then((_) { + if (_closeCompleter != null) return _closeCompleter.future; + _closeCompleter = new Completer(); + + return Future.wait([ + _server.close(), + _compilers.close() + ]).then((_) { if (_browserManagerCompleter == null) return null; return _browserManager.then((_) => _browser.close()); - }); + }).then((_) { + new Directory(_compiledDir).deleteSync(recursive: true); + _closeCompleter.complete(); + }).catchError(_closeCompleter.completeError); } } diff --git a/lib/src/runner/engine.dart b/lib/src/runner/engine.dart index 9d3c1bba30b60ea9d7af75f46898073211edb1f3..355686b0e01a5b66f3ac61edd748e9bc7c10c821 100644 --- a/lib/src/runner/engine.dart +++ b/lib/src/runner/engine.dart @@ -23,6 +23,9 @@ class Engine { /// Whether [run] has been called yet. var _runCalled = false; + /// Whether [close] has been called. + var _closed = false; + /// An unmodifiable list of tests to run. /// /// These are [LiveTest]s, representing the in-progress state of each test. @@ -54,6 +57,7 @@ class Engine { _runCalled = true; return Future.forEach(liveTests, (liveTest) { + if (_closed) return new Future.value(); _onTestStartedController.add(liveTest); // First, schedule a microtask to ensure that [onTestStarted] fires before @@ -67,6 +71,12 @@ class Engine { /// Signals that the caller is done paying attention to test results and the /// engine should release any resources it has allocated. - Future close() => - Future.wait(liveTests.map((liveTest) => liveTest.close())); + /// + /// Any actively-running tests are also closed. VM tests are allowed to finish + /// running so that any modifications they've made to the filesystem can be + /// cleaned up. + Future close() { + _closed = true; + return Future.wait(liveTests.map((liveTest) => liveTest.close())); + } } diff --git a/lib/src/runner/reporter/compact.dart b/lib/src/runner/reporter/compact.dart index 42b1eb755a3e5a1232b0b9aa2ba07c4872c25fdc..b24136aff21960904527057631e5fa2ee3dd55ff 100644 --- a/lib/src/runner/reporter/compact.dart +++ b/lib/src/runner/reporter/compact.dart @@ -51,6 +51,9 @@ class CompactReporter { /// The set of tests that have completed and been marked as failing or error. final _failed = new Set<LiveTest>(); + /// Whether [close] has been called. + bool _closed = false; + /// The size of [_passed] last time a progress notification was printed. int _lastProgressPassed; @@ -60,6 +63,9 @@ class CompactReporter { /// The message printed for the last progress notification. String _lastProgressMessage; + // Whether a newline has been printed since the last progress line. + var _printedNewline = true; + /// Creates a [ConsoleReporter] that will run all tests in [suites]. /// /// If [color] is `true`, this will use terminal colors; if it's `false`, it @@ -72,10 +78,10 @@ class CompactReporter { _green = color ? '\u001b[32m' : '', _red = color ? '\u001b[31m' : '', _noColor = color ? '\u001b[0m' : '' { - // Whether a newline has been printed since the last progress line. - var printedNewline = false; _engine.onTestStarted.listen((liveTest) { _progressLine(_description(liveTest)); + _printedNewline = false; + liveTest.onStateChange.listen((state) { if (state.status != Status.complete) return; if (state.result == Result.success) { @@ -85,15 +91,15 @@ class CompactReporter { _failed.add(liveTest); } _progressLine(_description(liveTest)); - printedNewline = false; + _printedNewline = false; }); liveTest.onError.listen((error) { if (liveTest.state.status != Status.complete) return; _progressLine(_description(liveTest)); - if (!printedNewline) print(''); - printedNewline = true; + if (!_printedNewline) print(''); + _printedNewline = true; print(indent(error.error.toString())); print(indent(terseChain(error.stackTrace).toString())); @@ -101,8 +107,8 @@ class CompactReporter { liveTest.onPrint.listen((line) { _progressLine(_description(liveTest)); - if (!printedNewline) print(''); - printedNewline = true; + if (!_printedNewline) print(''); + _printedNewline = true; print(line); }); @@ -126,6 +132,8 @@ class CompactReporter { _stopwatch.start(); return _engine.run().then((success) { + if (_closed) return false; + if (success) { _progressLine("All tests passed!"); print(''); @@ -140,7 +148,12 @@ class CompactReporter { /// Signals that the caller is done with any test output and the reporter /// should release any resources it has allocated. - Future close() => _engine.close(); + Future close() { + if (!_printedNewline) print(""); + _printedNewline = true; + _closed = true; + return _engine.close(); + } /// Prints a line representing the current state of the tests. /// diff --git a/lib/src/runner/vm/isolate_listener.dart b/lib/src/runner/vm/isolate_listener.dart index c0e7c09c7fcf2f6fbb797479816a0134cf7b86d1..46c05f2592c70c8e09abdd8a61609a80ec76da1e 100644 --- a/lib/src/runner/vm/isolate_listener.dart +++ b/lib/src/runner/vm/isolate_listener.dart @@ -97,6 +97,15 @@ class IsolateListener { void _runTest(Test test, SendPort sendPort) { var liveTest = test.load(_suite); + var receivePort = new ReceivePort(); + sendPort.send({"type": "started", "reply": receivePort.sendPort}); + + receivePort.listen((message) { + assert(message['command'] == 'close'); + receivePort.close(); + liveTest.close(); + }); + liveTest.onStateChange.listen((state) { sendPort.send({ "type": "state-change", diff --git a/lib/src/runner/vm/isolate_test.dart b/lib/src/runner/vm/isolate_test.dart index ef73869f4a6a9d8e63d525d06a9f38814faa5dcb..61a74cc30efae9e8491efda489a67a439ef50e35 100644 --- a/lib/src/runner/vm/isolate_test.dart +++ b/lib/src/runner/vm/isolate_test.dart @@ -4,6 +4,7 @@ library test.runner.vm.isolate_test; +import 'dart:async'; import 'dart:isolate'; import '../../backend/live_test.dart'; @@ -26,11 +27,18 @@ class IsolateTest implements Test { /// Loads a single runnable instance of this test. LiveTest load(Suite suite) { - var receivePort; var controller; + + // We get a new send port for communicating with the live test, since + // [_sendPort] is only for communicating with the non-live test. This will + // be non-null once the test starts running. + var sendPortCompleter; + + var receivePort; controller = new LiveTestController(suite, this, () { controller.setState(const State(Status.running, Result.success)); + sendPortCompleter = new Completer(); receivePort = new ReceivePort(); _sendPort.send({ 'command': 'run', @@ -38,7 +46,9 @@ class IsolateTest implements Test { }); receivePort.listen((message) { - if (message['type'] == 'error') { + if (message['type'] == 'started') { + sendPortCompleter.complete(message['reply']); + } else if (message['type'] == 'error') { var asyncError = RemoteException.deserialize(message['error']); controller.addError(asyncError.error, asyncError.stackTrace); } else if (message['type'] == 'state-change') { @@ -53,8 +63,21 @@ class IsolateTest implements Test { controller.completer.complete(); } }); - }, onClose: () { - if (receivePort != null) receivePort.close(); + }, () { + // If the test has finished running, just disconnect the receive port. The + // Dart process won't terminate if there are any live receive ports open. + if (controller.completer.isCompleted) { + receivePort.close(); + return; + } + + // If the test is still running, send it a message telling it to shut down + // ASAP. This causes the [Invoker] to eagerly throw exceptions whenever + // the test touches it. + sendPortCompleter.future.then((sendPort) { + sendPort.send({'command': 'close'}); + return controller.completer.future; + }).then((_) => receivePort.close()); }); return controller.liveTest; } diff --git a/lib/src/util/dart.dart b/lib/src/util/dart.dart index 378c0350b97f329ce0ff7b23a8086b1b17350233..c62bacbfd2f188dee6d2196fe71d6082bcccf1ae 100644 --- a/lib/src/util/dart.dart +++ b/lib/src/util/dart.dart @@ -28,7 +28,7 @@ import 'remote_exception.dart'; /// [String] or a [Uri]. Future<Isolate> runInIsolate(String code, message, {packageRoot}) { // TODO(nweiz): load code from a local server rather than from a file. - var dir = Directory.systemTemp.createTempSync().path; + var dir = createTempDir(); var dartPath = p.join(dir, 'runInIsolate.dart'); new File(dartPath).writeAsStringSync(code); var port = new ReceivePort(); diff --git a/lib/src/util/io.dart b/lib/src/util/io.dart index 39c336ac84ddff329fcecdc6fee2f9932b7110f5..fa12ae6d1eb205677ad246c964a0a8fd36930873 100644 --- a/lib/src/util/io.dart +++ b/lib/src/util/io.dart @@ -26,6 +26,15 @@ final OperatingSystem currentOS = (() { throw new UnsupportedError('Unsupported operating system "$name".'); })(); +/// The root directory below which to nest temporary directories created by the +/// test runner. +/// +/// This is configurable so that the test code can validate that the runner +/// cleans up after itself fully. +final _tempDir = Platform.environment.containsKey("_UNITTEST_TEMP_DIR") + ? Platform.environment["_UNITTEST_TEMP_DIR"] + : Directory.systemTemp.path; + /// The path to the `lib` directory of the `test` package. String libDir({String packageRoot}) { var pathToIo = libraryPath(#test.util.io, packageRoot: packageRoot); @@ -52,6 +61,10 @@ bool get canUseSpecialChars => Platform.operatingSystem != 'windows' && Platform.environment["_UNITTEST_USE_COLOR"] != "false"; +/// Creates a temporary directory and returns its path. +String createTempDir() => + new Directory(_tempDir).createTempSync('dart_test_').path; + /// Creates a temporary directory and passes its path to [fn]. /// /// Once the [Future] returned by [fn] completes, the temporary directory and @@ -62,11 +75,9 @@ bool get canUseSpecialChars => /// [fn] completes to. Future withTempDir(Future fn(String path)) { return new Future.sync(() { - // TODO(nweiz): Empirically test whether sync or async functions perform - // better here when starting a bunch of isolates. - var tempDir = Directory.systemTemp.createTempSync('test_'); - return new Future.sync(() => fn(tempDir.path)) - .whenComplete(() => tempDir.deleteSync(recursive: true)); + var tempDir = createTempDir(); + return new Future.sync(() => fn(tempDir)) + .whenComplete(() => new Directory(tempDir).deleteSync(recursive: true)); }); } diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 97997d7752521a1e30d13ec6997ad4e04f4a19d2..7d1094edd970080d7e04b8690353efb78633cad2 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -16,6 +16,9 @@ import 'backend/operating_system.dart'; /// The return type should only ever by [Future] or void. typedef AsyncFunction(); +/// A typedef for a zero-argument callback function. +typedef void Callback(); + /// A regular expression to match the exception prefix that some exceptions' /// [Object.toString] values contain. final _exceptionPrefix = new RegExp(r'^([A-Z][a-zA-Z]*)?(Exception|Error): '); @@ -141,3 +144,38 @@ String truncate(String text, int maxLength) { } return '...$result'; } + +/// Merges [streams] into a single stream that emits events from all sources. +Stream mergeStreams(Iterable<Stream> streamIter) { + var streams = streamIter.toList(); + + var subscriptions = new Set(); + var controller; + controller = new StreamController(sync: true, onListen: () { + for (var stream in streams) { + var subscription; + subscription = stream.listen( + controller.add, + onError: controller.addError, + onDone: () { + subscriptions.remove(subscription); + if (subscriptions.isEmpty) controller.close(); + }); + subscriptions.add(subscription); + } + }, onPause: () { + for (var subscription in subscriptions) { + subscription.pause(); + } + }, onResume: () { + for (var subscription in subscriptions) { + subscription.resume(); + } + }, onCancel: () { + for (var subscription in subscriptions) { + subscription.cancel(); + } + }); + + return controller.stream; +} diff --git a/pubspec.yaml b/pubspec.yaml index 577cd58d9e151fa6f76e27b998dc08ab1a640328..b26c9d3f3bb891e703a394e0fe7e3082b2d40d0f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: test -version: 0.12.0-beta.4 +version: 0.12.0-dev author: Dart Team <misc@dartlang.org> description: A library for writing dart unit tests. homepage: https://github.com/dart-lang/test diff --git a/test/io.dart b/test/io.dart index 9dccdaef5772d4ed5f3c1430ca33f7ea67b2d504..68b6b52e5b39c5ff78fb101d594c9073b9d3b635 100644 --- a/test/io.dart +++ b/test/io.dart @@ -4,6 +4,7 @@ library test.test.io; +import 'dart:async'; import 'dart:io'; import 'package:path/path.dart' as p; @@ -37,3 +38,28 @@ ProcessResult runDart(List<String> args, {String workingDirectory, return Process.runSync(Platform.executable, allArgs, workingDirectory: workingDirectory, environment: environment); } + +/// Starts the test executable with the package root set properly. +Future<Process> startUnittest(List<String> args, {String workingDirectory, + Map<String, String> environment}) { + var allArgs = [ + p.absolute(p.join(packageDir, 'bin/test.dart')), + "--package-root=${p.join(packageDir, 'packages')}" + ]..addAll(args); + + if (environment == null) environment = {}; + environment.putIfAbsent("_UNITTEST_USE_COLOR", () => "false"); + + return startDart(allArgs, workingDirectory: workingDirectory, + environment: environment); +} + +/// Starts Dart. +Future<Process> startDart(List<String> args, {String workingDirectory, + Map<String, String> environment}) { + var allArgs = Platform.executableArguments.toList()..addAll(args); + + // TODO(nweiz): Use ScheduledProcess once it's compatible. + return Process.start(Platform.executable, allArgs, + workingDirectory: workingDirectory, environment: environment); +} diff --git a/test/runner/browser/compact_reporter_test.dart b/test/runner/browser/compact_reporter_test.dart index 24045229d8b888f1d3146c6c7be51d7e9fb4e02f..94b748d2aa7019fc23d58c7735db3648c859dd9b 100644 --- a/test/runner/browser/compact_reporter_test.dart +++ b/test/runner/browser/compact_reporter_test.dart @@ -7,6 +7,7 @@ import 'dart:io'; import 'package:path/path.dart' as p; +import 'package:test/src/util/io.dart'; import 'package:test/test.dart'; import '../../io.dart'; @@ -15,7 +16,7 @@ String _sandbox; void main() { setUp(() { - _sandbox = Directory.systemTemp.createTempSync('test_').path; + _sandbox = createTempDir(); }); tearDown(() { diff --git a/test/runner/browser/compiler_pool_test.dart b/test/runner/browser/compiler_pool_test.dart index ed28f052e133d35d36bcf3f56d21ab6fd15ddafe..6b05a3a15d22e01639d0acd39c171533fdf90b8b 100644 --- a/test/runner/browser/compiler_pool_test.dart +++ b/test/runner/browser/compiler_pool_test.dart @@ -8,6 +8,7 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:test/src/util/exit_codes.dart' as exit_codes; +import 'package:test/src/util/io.dart'; import 'package:test/test.dart'; import '../../io.dart'; @@ -16,7 +17,7 @@ String _sandbox; void main() { setUp(() { - _sandbox = Directory.systemTemp.createTempSync('test_').path; + _sandbox = createTempDir(); }); tearDown(() { diff --git a/test/runner/browser/loader_test.dart b/test/runner/browser/loader_test.dart index 4d0228e771094992063a5c5d7ad02a17c18d2ae8..e1a7d6657eb97474a45f444f1476a19c3d3dc38d 100644 --- a/test/runner/browser/loader_test.dart +++ b/test/runner/browser/loader_test.dart @@ -10,6 +10,7 @@ import 'package:path/path.dart' as p; import 'package:test/src/backend/state.dart'; import 'package:test/src/backend/test_platform.dart'; import 'package:test/src/runner/loader.dart'; +import 'package:test/src/util/io.dart'; import 'package:test/test.dart'; import '../../io.dart'; @@ -34,7 +35,7 @@ void main() { setUp(() { _loader = new Loader([TestPlatform.chrome], packageRoot: p.join(packageDir, 'packages')); - _sandbox = Directory.systemTemp.createTempSync('test_').path; + _sandbox = createTempDir(); /// TODO(nweiz): Use scheduled_test for this once it's compatible with this /// version of test. new File(p.join(_sandbox, 'a_test.dart')).writeAsStringSync(_tests); diff --git a/test/runner/browser/runner_test.dart b/test/runner/browser/runner_test.dart index abe5ac4da5e06f56e21b6a64fdb5e8e26f0a81f6..aea05886d3756e828653b590adb81018a3dcd6fa 100644 --- a/test/runner/browser/runner_test.dart +++ b/test/runner/browser/runner_test.dart @@ -8,6 +8,7 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:test/src/util/exit_codes.dart' as exit_codes; +import 'package:test/src/util/io.dart'; import 'package:test/test.dart'; import '../../io.dart'; @@ -26,7 +27,7 @@ void main() { void main() { setUp(() { - _sandbox = Directory.systemTemp.createTempSync('test_').path; + _sandbox = createTempDir(); }); tearDown(() { diff --git a/test/runner/loader_test.dart b/test/runner/loader_test.dart index 9be28d77ff7ebf23b264a90dd39dece8e810ba73..f56bc94b4a9a1871c743f4c6814ed7780baac477 100644 --- a/test/runner/loader_test.dart +++ b/test/runner/loader_test.dart @@ -10,6 +10,7 @@ import 'package:path/path.dart' as p; import 'package:test/src/backend/state.dart'; import 'package:test/src/backend/test_platform.dart'; import 'package:test/src/runner/loader.dart'; +import 'package:test/src/util/io.dart'; import 'package:test/test.dart'; import '../io.dart'; @@ -34,7 +35,7 @@ void main() { setUp(() { _loader = new Loader([TestPlatform.vm], packageRoot: p.join(packageDir, 'packages')); - _sandbox = Directory.systemTemp.createTempSync('test_').path; + _sandbox = createTempDir(); }); tearDown(() { diff --git a/test/runner/parse_metadata_test.dart b/test/runner/parse_metadata_test.dart index e65b62a2ead0ca479c2b0703aabae233158a46eb..72c2b4cfe76d530b18d3f1da4482876fb91949f7 100644 --- a/test/runner/parse_metadata_test.dart +++ b/test/runner/parse_metadata_test.dart @@ -11,13 +11,14 @@ import 'package:test/test.dart'; import 'package:test/src/backend/platform_selector.dart'; import 'package:test/src/backend/test_platform.dart'; import 'package:test/src/runner/parse_metadata.dart'; +import 'package:test/src/util/io.dart'; String _sandbox; String _path; void main() { setUp(() { - _sandbox = Directory.systemTemp.createTempSync('test_').path; + _sandbox = createTempDir(); _path = p.join(_sandbox, "test.dart"); }); diff --git a/test/runner/runner_test.dart b/test/runner/runner_test.dart index af67c03590988164b0ae24d3a417721437cb0eab..c1c003967d822199c5fa38a075fcddec572a2c22 100644 --- a/test/runner/runner_test.dart +++ b/test/runner/runner_test.dart @@ -8,6 +8,7 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:test/src/util/exit_codes.dart' as exit_codes; +import 'package:test/src/util/io.dart'; import 'package:test/test.dart'; import '../io.dart'; @@ -51,7 +52,7 @@ Usage: pub run test:test [files or directories...] void main() { setUp(() { - _sandbox = Directory.systemTemp.createTempSync('test_').path; + _sandbox = createTempDir(); }); tearDown(() { diff --git a/test/runner/signal_test.dart b/test/runner/signal_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..d0e7520a49be2dc6a1aa7d373786249419360eb5 --- /dev/null +++ b/test/runner/signal_test.dart @@ -0,0 +1,277 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +@TestOn("vm") + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; +import 'package:test/src/util/io.dart'; + +import '../io.dart'; + +String _sandbox; + +String get _tempDir => p.join(_sandbox, "tmp"); + +final _lines = UTF8.decoder.fuse(const LineSplitter()); + +// This test is inherently prone to race conditions. If it fails, it will likely +// do so flakily, but if it succeeds, it will succeed consistently. The tests +// represent a best effort to kill the test runner at certain times during its +// execution. +void main() { + setUp(() { + _sandbox = createTempDir(); + }); + + tearDown(() { + new Directory(_sandbox).deleteSync(recursive: true); + }); + + group("during loading,", () { + test("cleans up if killed while loading a VM test", () { + new File(p.join(_sandbox, "test.dart")).writeAsStringSync(""" +void main() { + print("in test.dart"); + // Spin for a long time so the test is probably killed while still loading. + for (var i = 0; i < 100000000; i++) {} +} +"""); + + return _startUnittest(["test.dart"]).then((process) { + return _lines.bind(process.stdout).first.then((line) { + expect(line, equals("in test.dart")); + process.kill(); + return process.exitCode; + }).then((_) { + expect(new Directory(_tempDir).listSync(), isEmpty); + }); + }); + }); + + test("cleans up if killed while loading a browser test", () { + new File(p.join(_sandbox, "test.dart")) + .writeAsStringSync("void main() {}"); + + return _startUnittest(["-p", "chrome", "test.dart"]).then((process) { + return _lines.bind(process.stdout).first.then((line) { + expect(line, equals("Compiling test.dart...")); + process.kill(); + return process.exitCode; + }).then((_) { + expect(new Directory(_tempDir).listSync(), isEmpty); + }); + }); + }); + + test("exits immediately if ^C is sent twice", () { + new File(p.join(_sandbox, "test.dart")).writeAsStringSync(""" +void main() { + print("in test.dart"); + while (true) {} +} +"""); + + return _startUnittest(["test.dart"]).then((process) { + return _lines.bind(process.stdout).first.then((line) { + expect(line, equals("in test.dart")); + process.kill(); + + // TODO(nweiz): Sending two signals in close succession can cause the + // second one to be ignored, so we wait a bit before the second + // one. Remove this hack when issue 23047 is fixed. + return new Future.delayed(new Duration(seconds: 1)); + }).then((_) { + process.kill(); + return process.exitCode; + }).then((_) { + expect(new Directory(_tempDir).listSync(), isEmpty); + }); + }); + }); + }); + + group("during test running", () { + test("waits for a VM test to finish running", () { + new File(p.join(_sandbox, "test.dart")).writeAsStringSync(""" +import 'dart:async'; +import 'dart:io'; + +import 'package:test/test.dart'; + +void main() { + tearDown(() => new File("output").writeAsStringSync("ran teardown")); + + test("test", () { + print("running test"); + return new Future.delayed(new Duration(seconds: 1)); + }); +} +"""); + + return _startUnittest(["test.dart"]).then((process) { + return _lines.bind(process.stdout).skip(2).first.then((line) { + expect(line, equals("running test")); + process.kill(); + return process.exitCode; + }).then((_) { + expect(new File(p.join(_sandbox, "output")).readAsStringSync(), + equals("ran teardown")); + expect(new Directory(_tempDir).listSync(), isEmpty); + }); + }); + }); + + test("kills a browser test immediately", () { + new File(p.join(_sandbox, "test.dart")).writeAsStringSync(""" +import 'dart:async'; + +import 'package:test/test.dart'; + +void main() { + test("test", () { + print("running test"); + + // Allow an event loop to pass so the preceding print can be handled. + return new Future(() { + // Loop forever so that if the test isn't stopped while running, it never + // stops. + while (true) {} + }); + }); +} +"""); + + return _startUnittest(["-p", "chrome", "test.dart"]).then((process) { + return _lines.bind(process.stdout).skip(3).first.then((line) { + expect(line, equals("running test")); + process.kill(); + return process.exitCode; + }).then((_) { + expect(new Directory(_tempDir).listSync(), isEmpty); + }); + }); + }); + + test("kills a VM test immediately if ^C is sent twice", () { + new File(p.join(_sandbox, "test.dart")).writeAsStringSync(""" +import 'package:test/test.dart'; + +void main() { + test("test", () { + print("running test"); + while (true) {} + }); +} +"""); + + return _startUnittest(["test.dart"]).then((process) { + return _lines.bind(process.stdout).skip(2).first.then((line) { + expect(line, equals("running test")); + process.kill(); + + // TODO(nweiz): Sending two signals in close succession can cause the + // second one to be ignored, so we wait a bit before the second + // one. Remove this hack when issue 23047 is fixed. + return new Future.delayed(new Duration(seconds: 1)); + }).then((_) { + process.kill(); + return process.exitCode; + }); + }); + }); + + test("causes expect() to always throw an error immediately", () { + new File(p.join(_sandbox, "test.dart")).writeAsStringSync(""" +import 'dart:async'; +import 'dart:io'; + +import 'package:test/test.dart'; + +void main() { + var expectThrewError = false; + + tearDown(() { + new File("output").writeAsStringSync(expectThrewError.toString()); + }); + + test("test", () { + print("running test"); + + return new Future.delayed(new Duration(seconds: 1)).then((_) { + try { + expect(true, isTrue); + } catch (_) { + expectThrewError = true; + } + }); + }); +} +"""); + + return _startUnittest(["test.dart"]).then((process) { + return _lines.bind(process.stdout).skip(2).first.then((line) { + expect(line, equals("running test")); + process.kill(); + return process.exitCode; + }).then((_) { + expect(new File(p.join(_sandbox, "output")).readAsStringSync(), + equals("true")); + expect(new Directory(_tempDir).listSync(), isEmpty); + }); + }); + }); + + test("causes expectAsync() to always throw an error immediately", () { + new File(p.join(_sandbox, "test.dart")).writeAsStringSync(""" +import 'dart:async'; +import 'dart:io'; + +import 'package:test/test.dart'; + +void main() { + var expectAsyncThrewError = false; + + tearDown(() { + new File("output").writeAsStringSync(expectAsyncThrewError.toString()); + }); + + test("test", () { + print("running test"); + + return new Future.delayed(new Duration(seconds: 1)).then((_) { + try { + expectAsync(() {}); + } catch (_) { + expectAsyncThrewError = true; + } + }); + }); +} +"""); + + return _startUnittest(["test.dart"]).then((process) { + return _lines.bind(process.stdout).skip(2).first.then((line) { + expect(line, equals("running test")); + process.kill(); + return process.exitCode; + }).then((_) { + expect(new File(p.join(_sandbox, "output")).readAsStringSync(), + equals("true")); + expect(new Directory(_tempDir).listSync(), isEmpty); + }); + }); + }); + }); +} + +Future<Process> _startUnittest(List<String> args) { + new Directory(_tempDir).create(); + return startUnittest(args, workingDirectory: _sandbox, + environment: {"_UNITTEST_TEMP_DIR": _tempDir}); +} diff --git a/test/runner/test_on_test.dart b/test/runner/test_on_test.dart index 91b69d33a1bd579b660f3674dd2d56f015e4ea6c..632f94c27afa6a3e276e8586ae48bb8b09005425 100644 --- a/test/runner/test_on_test.dart +++ b/test/runner/test_on_test.dart @@ -18,7 +18,7 @@ final _otherOS = Platform.isWindows ? "mac-os" : "windows"; void main() { setUp(() { - _sandbox = Directory.systemTemp.createTempSync('test_').path; + _sandbox = createTempDir(); }); tearDown(() {