diff --git a/lib/src/backend/declarer.dart b/lib/src/backend/declarer.dart index 8adeacb4329f27772b211996c90bb9cd1aba7673..881a5dca393ef3efed968800468d5a546cb17419 100644 --- a/lib/src/backend/declarer.dart +++ b/lib/src/backend/declarer.dart @@ -101,7 +101,8 @@ class Declarer { Timeout timeout, skip, Map<String, dynamic> onPlatform, - tags}) { + tags, + int retry}) { _checkNotBuilt("test"); var metadata = _metadata.merge(new Metadata.parse( @@ -109,7 +110,8 @@ class Declarer { timeout: timeout, skip: skip, onPlatform: onPlatform, - tags: tags)); + tags: tags, + retry: retry)); _entries.add(new LocalTest(_prefix(name), metadata, () async { var parents = <Declarer>[]; diff --git a/lib/src/backend/invoker.dart b/lib/src/backend/invoker.dart index f7fcb4892e9b299ec6a149bb0af32fc273e4df08..cb6ff4582d73893b774da341ce3cbc54469fa032 100644 --- a/lib/src/backend/invoker.dart +++ b/lib/src/backend/invoker.dart @@ -107,6 +107,9 @@ class Invoker { /// on one anothers' toes. final _counterKey = new Object(); + /// The number of times this [liveTest] has been run. + int _runCount = 0; + /// The current invoker, or `null` if none is defined. /// /// An invoker is only set within the zone scope of a running test. @@ -275,6 +278,8 @@ class Invoker { /// Notifies the invoker of an asynchronous error. void _handleError(error, [StackTrace stackTrace]) { + // Ignore errors propagated from previous test runs + if (_runCount != Zone.current[#runCount]) return; if (stackTrace == null) stackTrace = new Chain.current(); // Store these here because they'll change when we set the state below. @@ -324,6 +329,7 @@ class Invoker { var outstandingCallbacksForBody = new OutstandingCallbackCounter(); + _runCount++; Chain.capture(() { runZonedWithValues(() async { _invokerZone = Zone.current; @@ -348,6 +354,14 @@ class Invoker { await _outstandingCallbacks.noOutstandingCallbacks; if (_timeoutTimer != null) _timeoutTimer.cancel(); + if (liveTest.state.result != Result.success && + _runCount < liveTest.test.metadata.retry + 1) { + _controller + .message(new Message.print("Retry: ${liveTest.test.name}")); + _onRun(); + return; + } + _controller.setState(new State(Status.complete, liveTest.state.result)); _controller.completer.complete(); @@ -357,7 +371,8 @@ class Invoker { // Use the invoker as a key so that multiple invokers can have different // outstanding callback counters at once. _counterKey: outstandingCallbacksForBody, - _closableKey: true + _closableKey: true, + #runCount: _runCount }, zoneSpecification: new ZoneSpecification( print: (self, parent, zone, line) => diff --git a/lib/src/backend/metadata.dart b/lib/src/backend/metadata.dart index 8fa6bc426b083b76f87070e2c703231b7a58a41b..92a8ac81114b149df9a53ae7bce4bd34b0824571 100644 --- a/lib/src/backend/metadata.dart +++ b/lib/src/backend/metadata.dart @@ -49,6 +49,10 @@ class Metadata { /// The user-defined tags attached to the test or suite. final Set<String> tags; + /// The number of times to re-run a test before being marked as a failure. + int get retry => _retry ?? 0; + final int _retry; + /// Platform-specific metadata. /// /// Each key identifies a platform, and its value identifies the specific @@ -140,6 +144,7 @@ class Metadata { bool skip, bool verboseTrace, bool chainStackTraces, + int retry, String skipReason, Iterable<String> tags, Map<PlatformSelector, Metadata> onPlatform, @@ -151,6 +156,7 @@ class Metadata { skip: skip, verboseTrace: verboseTrace, chainStackTraces: chainStackTraces, + retry: retry, skipReason: skipReason, tags: tags, onPlatform: onPlatform, @@ -185,6 +191,7 @@ class Metadata { this.skipReason, bool verboseTrace, bool chainStackTraces, + int retry, Iterable<String> tags, Map<PlatformSelector, Metadata> onPlatform, Map<BooleanSelector, Metadata> forTag}) @@ -193,10 +200,12 @@ class Metadata { _skip = skip, _verboseTrace = verboseTrace, _chainStackTraces = chainStackTraces, + _retry = retry, tags = new UnmodifiableSetView(tags == null ? new Set() : tags.toSet()), onPlatform = onPlatform == null ? const {} : new UnmodifiableMapView(onPlatform), forTag = forTag == null ? const {} : new UnmodifiableMapView(forTag) { + if (retry != null) RangeError.checkNotNegative(retry, "retry"); _validateTags(); } @@ -210,6 +219,7 @@ class Metadata { skip, bool verboseTrace, bool chainStackTraces, + int retry, Map<String, dynamic> onPlatform, tags}) : testOn = testOn == null @@ -219,6 +229,7 @@ class Metadata { _skip = skip == null ? null : skip != false, _verboseTrace = verboseTrace, _chainStackTraces = chainStackTraces, + _retry = retry, skipReason = skip is String ? skip : null, onPlatform = _parseOnPlatform(onPlatform), tags = _parseTags(tags), @@ -228,6 +239,8 @@ class Metadata { '"skip" must be a String or a bool, was "$skip".'); } + if (retry != null) RangeError.checkNotNegative(retry, "retry"); + _validateTags(); } @@ -241,6 +254,7 @@ class Metadata { skipReason = serialized['skipReason'], _verboseTrace = serialized['verboseTrace'], _chainStackTraces = serialized['chainStackTraces'], + _retry = serialized['retry'], tags = new Set.from(serialized['tags']), onPlatform = new Map.fromIterable(serialized['onPlatform'], key: (pair) => new PlatformSelector.parse(pair.first), @@ -284,6 +298,7 @@ class Metadata { skipReason: other.skipReason ?? skipReason, verboseTrace: other._verboseTrace ?? _verboseTrace, chainStackTraces: other._chainStackTraces ?? _chainStackTraces, + retry: other._retry ?? _retry, tags: tags.union(other.tags), onPlatform: mergeMaps(onPlatform, other.onPlatform, value: (metadata1, metadata2) => metadata1.merge(metadata2)), @@ -297,6 +312,7 @@ class Metadata { bool skip, bool verboseTrace, bool chainStackTraces, + int retry, String skipReason, Map<PlatformSelector, Metadata> onPlatform, Set<String> tags, @@ -306,6 +322,7 @@ class Metadata { skip ??= this._skip; verboseTrace ??= this._verboseTrace; chainStackTraces ??= this._chainStackTraces; + retry ??= this._retry; skipReason ??= this.skipReason; onPlatform ??= this.onPlatform; tags ??= this.tags; @@ -319,7 +336,8 @@ class Metadata { skipReason: skipReason, onPlatform: onPlatform, tags: tags, - forTag: forTag); + forTag: forTag, + retry: retry); } /// Returns a copy of [this] with all platform-specific metadata from @@ -351,6 +369,7 @@ class Metadata { 'skipReason': skipReason, 'verboseTrace': _verboseTrace, 'chainStackTraces': _chainStackTraces, + 'retry': _retry, 'tags': tags.toList(), 'onPlatform': serializedOnPlatform, 'forTag': mapMap(forTag, diff --git a/lib/src/runner/live_suite_controller.dart b/lib/src/runner/live_suite_controller.dart index ae5a93347fd43afbbc9106fc872cb4942164d72b..266ddc42eb30d8c0112e8671a86fc62b9495e0dc 100644 --- a/lib/src/runner/live_suite_controller.dart +++ b/lib/src/runner/live_suite_controller.dart @@ -128,6 +128,8 @@ class LiveSuiteController { _failed.add(liveTest); } else if (countSuccess) { _passed.add(liveTest); + // A passing test that was once failing was retried + _failed.remove(liveTest); } }); diff --git a/lib/src/runner/parse_metadata.dart b/lib/src/runner/parse_metadata.dart index 7b86166093618f16848703b3c3214da3945603d3..8ac8014b3d78fef4763239dc89151e0106f4d616 100644 --- a/lib/src/runner/parse_metadata.dart +++ b/lib/src/runner/parse_metadata.dart @@ -60,6 +60,7 @@ class _Parser { var skip; Map<PlatformSelector, Metadata> onPlatform; Set<String> tags; + int retry; for (var annotation in _annotations) { var pair = @@ -82,6 +83,8 @@ class _Parser { } else if (name == 'Tags') { _assertSingle(tags, 'Tags', annotation); tags = _parseTags(annotation, constructorName); + } else if (name == 'Retry') { + retry = _parseRetry(annotation, constructorName); } } @@ -91,7 +94,8 @@ class _Parser { skip: skip == null ? null : true, skipReason: skip is String ? skip : null, onPlatform: onPlatform, - tags: tags); + tags: tags, + retry: retry); } /// Parses a `@TestOn` annotation. @@ -106,6 +110,16 @@ class _Parser { literal, () => new PlatformSelector.parse(literal.stringValue)); } + /// Parses a `@Retry` annotation. + /// + /// [annotation] is the annotation. [constructorName] is the name of the named + /// constructor for the annotation, if any. + int _parseRetry(Annotation annotation, String constructorName) { + _assertConstructorName(constructorName, 'Retry', annotation); + _assertArguments(annotation.arguments, 'Retry', annotation, positional: 1); + return _parseInt(annotation.arguments.arguments.first); + } + /// Parses a `@Timeout` annotation. /// /// [annotation] is the annotation. [constructorName] is the name of the named diff --git a/lib/test.dart b/lib/test.dart index 13d182f8fde530a6951727d48585280c58c408f7..200aab16248bf061be9bda74dd35c605969d6296 100644 --- a/lib/test.dart +++ b/lib/test.dart @@ -101,6 +101,9 @@ Declarer get _declarer { /// [package configuration file][configuring tags]. The parameter can be an /// [Iterable] of tag names, or a [String] representing a single tag. /// +/// If [retry] is passed, the test will be retried the provided number of times +/// before being marked as a failure. +/// /// [configuring tags]: https://github.com/dart-lang/test/blob/master/doc/package_config.md#configuring-tags /// /// [onPlatform] allows tests to be configured on a platform-by-platform @@ -127,13 +130,15 @@ void test(description, body(), Timeout timeout, skip, tags, - Map<String, dynamic> onPlatform}) { + Map<String, dynamic> onPlatform, + int retry}) { _declarer.test(description.toString(), body, testOn: testOn, timeout: timeout, skip: skip, onPlatform: onPlatform, - tags: tags); + tags: tags, + retry: retry); // Force dart2js not to inline this function. We need it to be separate from // `main()` in JS stack traces in order to properly determine the line and diff --git a/test/runner/retry_test.dart b/test/runner/retry_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..e260471faef4ff9a0396d1edfc9bc46c724faea8 --- /dev/null +++ b/test/runner/retry_test.dart @@ -0,0 +1,182 @@ +// Copyright (c) 2017, 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 'package:scheduled_test/descriptor.dart' as d; +import 'package:scheduled_test/scheduled_stream.dart'; +import 'package:scheduled_test/scheduled_test.dart'; + +import '../io.dart'; + +void main() { + useSandbox(); + + test("respects top-level @Retry declarations", () { + d + .file( + "test.dart", + """ + @Retry(3) + + import 'dart:async'; + + import 'package:test/test.dart'; + + var attempt = 0; + void main() { + test("failure", () { + attempt++; + if(attempt <= 3) { + throw new TestFailure("oh no"); + } + }); + } + """) + .create(); + + var test = runTest(["test.dart"]); + test.stdout.expect(consumeThrough(contains("+1: All tests passed!"))); + test.shouldExit(0); + }); + + test("Tests are not retried after they have already been reported successful", + () { + d + .file( + "test.dart", + """ + import 'dart:async'; + + import 'package:test/test.dart'; + + void main() { + var completer1 = new Completer(); + var completer2 = new Completer(); + test("first", () { + completer1.future.then((_) { + completer2.complete(); + throw "oh no"; + }); + }, retry: 2); + + test("second", () async { + completer1.complete(); + await completer2.future; + }); + } + """) + .create(); + + var test = runTest(["test.dart"]); + test.stdout.expect(consumeThrough( + contains("This test failed after it had already completed"))); + test.shouldExit(1); + }); + + group("retries tests", () { + test("and eventually passes for valid tests", () { + d + .file( + "test.dart", + """ + import 'dart:async'; + + import 'package:test/test.dart'; + + var attempt = 0; + void main() { + test("eventually passes", () { + attempt++; + if(attempt <= 2) { + throw new TestFailure("oh no"); + } + }, retry: 2); + } + """) + .create(); + + var test = runTest(["test.dart"]); + test.stdout.expect(consumeThrough(contains("+1: All tests passed!"))); + test.shouldExit(0); + }); + + test("and ignores previous errors", () { + d + .file( + "test.dart", + """ + import 'dart:async'; + + import 'package:test/test.dart'; + + var attempt = 0; + Completer completer = new Completer(); + void main() { + test("failure", () async { + attempt++; + if (attempt == 1) { + completer.future.then((_) => throw 'some error'); + throw new TestFailure("oh no"); + } + completer.complete(null); + await new Future((){}); + }, retry: 1); + } + """) + .create(); + + var test = runTest(["test.dart"]); + test.stdout.expect(consumeThrough(contains("+1: All tests passed!"))); + test.shouldExit(0); + }); + + test("and eventually fails for invalid tests", () { + d + .file( + "test.dart", + """ + import 'dart:async'; + + import 'package:test/test.dart'; + + void main() { + test("failure", () { + throw new TestFailure("oh no"); + }, retry: 2); + } + """) + .create(); + + var test = runTest(["test.dart"]); + test.stdout.expect(consumeThrough(contains("-1: Some tests failed."))); + test.shouldExit(1); + }); + + test("only after a failure", () { + d + .file( + "test.dart", + """ + import 'dart:async'; + + import 'package:test/test.dart'; + + var attempt = 0; + void main() { + test("eventually passes", () { + attempt++; + if (attempt != 2){ + throw new TestFailure("oh no"); + } + }, retry: 5); + } + """) + .create(); + + var test = runTest(["test.dart"]); + test.stdout.expect(consumeThrough(contains("+1: All tests passed!"))); + test.shouldExit(0); + }); + }); +} diff --git a/test/runner/timeout_test.dart b/test/runner/timeout_test.dart index 3d9375a7811a55e29a2f33ead0b8208782c8b9a9..c28ee8772c7b73cdc80bfe0be62840616e59bc6a 100644 --- a/test/runner/timeout_test.dart +++ b/test/runner/timeout_test.dart @@ -59,6 +59,36 @@ void main() { test.shouldExit(1); }); + test("timeout is reset with each retry", () { + d + .file( + "test.dart", + ''' +import 'dart:async'; + +import 'package:test/test.dart'; + +void main() { + var runCount = 0; + test("timeout", () async { + runCount++; + if (runCount <=2) { + await new Future.delayed(new Duration(milliseconds: 1000)); + } + }, retry: 3); +} +''') + .create(); + + var test = runTest(["--timeout=400ms", "test.dart"]); + test.stdout.expect(containsInOrder([ + "Test timed out after 0.4 seconds.", + "Test timed out after 0.4 seconds.", + "+1: All tests passed!" + ])); + test.shouldExit(0); + }); + test("the --timeout flag applies on top of the default 30s timeout", () { d .file(