From 8f3150c703369e9dd4fbff93f6d2abe9b36837b6 Mon Sep 17 00:00:00 2001
From: Natalie Weizenbaum <nweiz@google.com>
Date: Wed, 15 Apr 2015 14:34:39 -0700
Subject: [PATCH] Support configuring timeouts per-test and -group.

Support for using Timeout as an annotation will come next.

See #9

R=kevmoo@google.com

Review URL: https://codereview.chromium.org//1090513002
---
 lib/src/backend/declarer.dart   | 23 ++++++--
 lib/src/backend/invoker.dart    |  6 +--
 lib/src/backend/metadata.dart   | 36 ++++++++++---
 lib/src/frontend/timeout.dart   | 47 +++++++++++++++++
 lib/src/utils.dart              | 19 +++++++
 lib/test.dart                   | 19 +++++--
 test/backend/declarer_test.dart | 33 ++++++++++++
 test/backend/invoker_test.dart  | 94 +++++++++++++++++++++++++--------
 8 files changed, 234 insertions(+), 43 deletions(-)
 create mode 100644 lib/src/frontend/timeout.dart

diff --git a/lib/src/backend/declarer.dart b/lib/src/backend/declarer.dart
index 9983754c..01e81407 100644
--- a/lib/src/backend/declarer.dart
+++ b/lib/src/backend/declarer.dart
@@ -6,6 +6,7 @@ library test.backend.declarer;
 
 import 'dart:collection';
 
+import '../frontend/timeout.dart';
 import 'group.dart';
 import 'invoker.dart';
 import 'metadata.dart';
@@ -32,13 +33,19 @@ class Declarer {
   ///
   /// If [testOn] is passed, it's parsed as a [PlatformSelector], and the test
   /// will only be run on matching platforms.
-  void test(String description, body(), {String testOn}) {
+  ///
+  /// If [timeout] is passed, it's used to modify or replace the default timeout
+  /// of 30 seconds. Timeout modifications take precedence in suite-group-test
+  /// order, so [timeout] will also modify any timeouts set on the group or
+  /// suite.
+  void test(String description, body(), {String testOn, Timeout timeout}) {
     // TODO(nweiz): Once tests have begun running, throw an error if [test] is
     // called.
     var prefix = _group.description;
     if (prefix != null) description = "$prefix $description";
 
-    var metadata = _group.metadata.merge(new Metadata.parse(testOn: testOn));
+    var metadata = _group.metadata.merge(
+        new Metadata.parse(testOn: testOn, timeout: timeout));
     var group = _group;
     _tests.add(new LocalTest(description, metadata, () {
       // TODO(nweiz): It might be useful to throw an error here if a test starts
@@ -56,11 +63,17 @@ class Declarer {
   ///
   /// If [testOn] is passed, it's parsed as a [PlatformSelector], and any tests
   /// in the group will only be run on matching platforms.
-  void group(String description, void body(), {String testOn}) {
+  ///
+  /// If [timeout] is passed, it's used to modify or replace the default timeout
+  /// of 30 seconds. Timeout modifications take precedence in suite-group-test
+  /// order, so [timeout] will also modify any timeouts set on the group or
+  /// suite.
+  void group(String description, void body(), {String testOn,
+      Timeout timeout}) {
     var oldGroup = _group;
 
-    _group = new Group(
-        oldGroup, description, new Metadata.parse(testOn: testOn));
+    var metadata = new Metadata.parse(testOn: testOn, timeout: timeout);
+    _group = new Group(oldGroup, description, metadata);
     try {
       body();
     } finally {
diff --git a/lib/src/backend/invoker.dart b/lib/src/backend/invoker.dart
index 057e7049..147741ff 100644
--- a/lib/src/backend/invoker.dart
+++ b/lib/src/backend/invoker.dart
@@ -158,12 +158,12 @@ class Invoker {
         // TODO(nweiz): Make the timeout configurable.
         // TODO(nweiz): Reset this timer whenever the user's code interacts with
         // the library.
-        var timer = new Timer(new Duration(seconds: 30), () {
+        var timeout = _test.metadata.timeout.apply(new Duration(seconds: 30));
+        var timer = new Timer(timeout, () {
           if (liveTest.isComplete) return;
           handleError(
               new TimeoutException(
-                  "Test timed out after 30 seconds.",
-                  new Duration(seconds: 30)));
+                  "Test timed out after ${niceDuration(timeout)}.", timeout));
         });
 
         addOutstandingCallback();
diff --git a/lib/src/backend/metadata.dart b/lib/src/backend/metadata.dart
index 811a84a2..bf58b3f5 100644
--- a/lib/src/backend/metadata.dart
+++ b/lib/src/backend/metadata.dart
@@ -4,6 +4,7 @@
 
 library test.backend.metadata;
 
+import '../frontend/timeout.dart';
 import 'platform_selector.dart';
 
 /// Metadata for a test or test suite.
@@ -14,32 +15,51 @@ class Metadata {
   /// The selector indicating which platforms the suite supports.
   final PlatformSelector testOn;
 
+  /// The modification to the timeout for the test or suite.
+  final Timeout timeout;
+
   /// Creates new Metadata.
   ///
   /// [testOn] defaults to [PlatformSelector.all].
-  Metadata({PlatformSelector testOn})
-      : testOn = testOn == null ? PlatformSelector.all : testOn;
+  Metadata({PlatformSelector testOn, Timeout timeout})
+      : testOn = testOn == null ? PlatformSelector.all : testOn,
+        timeout = timeout == null ? new Timeout.factor(1) : timeout;
 
-  /// Parses metadata fields from strings.
+  /// Creates a new Metadata, but with fields parsed from strings where
+  /// applicable.
   ///
   /// Throws a [FormatException] if any field is invalid.
-  Metadata.parse({String testOn})
+  Metadata.parse({String testOn, Timeout timeout})
       : this(
-          testOn: testOn == null ? null : new PlatformSelector.parse(testOn));
+          testOn: testOn == null ? null : new PlatformSelector.parse(testOn),
+          timeout: timeout);
 
   /// Dezerializes the result of [Metadata.serialize] into a new [Metadata].
   Metadata.deserialize(serialized)
-      : this.parse(testOn: serialized['testOn']);
+      : this.parse(
+          testOn: serialized['testOn'],
+          timeout: serialized['timeout']['duration'] == null
+              ? new Timeout.factor(serialized['timeout']['scaleFactor'])
+              : new Timeout(new Duration(
+                  microseconds: serialized['timeout']['duration'])));
 
   /// Return a new [Metadata] that merges [this] with [other].
   ///
   /// If the two [Metadata]s have conflicting properties, [other] wins.
   Metadata merge(Metadata other) =>
-      new Metadata(testOn: testOn.intersect(other.testOn));
+      new Metadata(
+          testOn: testOn.intersect(other.testOn),
+          timeout: timeout.merge(other.timeout));
 
   /// Serializes [this] into a JSON-safe object that can be deserialized using
   /// [new Metadata.deserialize].
   serialize() => {
-    'testOn': testOn == PlatformSelector.all ? null : testOn.toString()
+    'testOn': testOn == PlatformSelector.all ? null : testOn.toString(),
+    'timeout': {
+      'duration': timeout.duration == null
+          ? null
+          : timeout.duration.inMicroseconds,
+      'scaleFactor': timeout.scaleFactor
+    }
   };
 }
diff --git a/lib/src/frontend/timeout.dart b/lib/src/frontend/timeout.dart
new file mode 100644
index 00000000..d96407ef
--- /dev/null
+++ b/lib/src/frontend/timeout.dart
@@ -0,0 +1,47 @@
+// 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 test.frontend.timeout;
+
+/// A class representing a modification to the default timeout for a test.
+///
+/// By default, a test will time out after 30 seconds. With [new Timeout], that
+/// can be overridden entirely; with [new Timeout.factor], it can be scaled
+/// relative to the default.
+class Timeout {
+  /// The timeout duration.
+  ///
+  /// If set, this overrides the default duration entirely. It will be
+  /// non-`null` only when [scaleFactor] is `null`.
+  final Duration duration;
+
+  /// The timeout factor.
+  ///
+  /// The default timeout will be multiplied by this to get the new timeout.
+  /// Thus a factor of 2 means that the test will take twice as long to time
+  /// out, and a factor of 0.5 means that it will time out twice as quickly.
+  final num scaleFactor;
+
+  /// Declares an absolute timeout that overrides the default.
+  const Timeout(this.duration)
+      : scaleFactor = null;
+
+  /// Declares a relative timeout that scales the default.
+  const Timeout.factor(this.scaleFactor)
+      : duration = null;
+
+  /// Returns a new [Timeout] that merges [this] with [other].
+  ///
+  /// If [other] declares a [duration], that takes precedence. Otherwise, this
+  /// timeout's [duration] or [factor] are multiplied by [other]'s [factor].
+  Timeout merge(Timeout other) {
+    if (other.duration != null) return new Timeout(other.duration);
+    if (duration != null) return new Timeout(duration * other.scaleFactor);
+    return new Timeout.factor(scaleFactor * other.scaleFactor);
+  }
+
+  /// Returns a new [Duration] from applying [this] to [base].
+  Duration apply(Duration base) =>
+      duration == null ? base * scaleFactor : duration;
+}
diff --git a/lib/src/utils.dart b/lib/src/utils.dart
index 7d697981..59edb665 100644
--- a/lib/src/utils.dart
+++ b/lib/src/utils.dart
@@ -149,6 +149,25 @@ String truncate(String text, int maxLength) {
   return '...$result';
 }
 
+/// Returns a human-friendly representation of [duration].
+String niceDuration(Duration duration) {
+  var minutes = duration.inMinutes;
+  var seconds = duration.inSeconds % 59;
+  var decaseconds = (duration.inMilliseconds % 1000) ~/ 100;
+
+  var buffer = new StringBuffer();
+  if (minutes != 0) buffer.write("$minutes minutes");
+
+  if (minutes == 0 || seconds != 0) {
+    if (minutes != 0) buffer.write(", ");
+    buffer.write(seconds);
+    if (decaseconds != 0) buffer.write(".$decaseconds");
+    buffer.write(" seconds");
+  }
+
+  return buffer.toString();
+}
+
 /// Merges [streams] into a single stream that emits events from all sources.
 Stream mergeStreams(Iterable<Stream> streamIter) {
   var streams = streamIter.toList();
diff --git a/lib/test.dart b/lib/test.dart
index 9dab658a..3839fe7f 100644
--- a/lib/test.dart
+++ b/lib/test.dart
@@ -12,6 +12,7 @@ import 'src/backend/declarer.dart';
 import 'src/backend/invoker.dart';
 import 'src/backend/suite.dart';
 import 'src/backend/test_platform.dart';
+import 'src/frontend/timeout.dart';
 import 'src/runner/reporter/no_io_compact.dart';
 import 'src/utils.dart';
 
@@ -24,6 +25,7 @@ export 'src/frontend/prints_matcher.dart';
 export 'src/frontend/test_on.dart';
 export 'src/frontend/throws_matcher.dart';
 export 'src/frontend/throws_matchers.dart';
+export 'src/frontend/timeout.dart';
 
 /// The global declarer.
 ///
@@ -66,8 +68,12 @@ Declarer get _declarer {
 /// test will only be run on matching platforms.
 ///
 /// [platform selector]: https://github.com/dart-lang/test/#platform-selector-syntax
-void test(String description, body(), {String testOn}) =>
-    _declarer.test(description, body, testOn: testOn);
+///
+/// If [timeout] is passed, it's used to modify or replace the default timeout
+/// of 30 seconds. Timeout modifications take precedence in suite-group-test
+/// order, so [timeout] will also modify any timeouts set on the group or suite.
+void test(String description, body(), {String testOn, Timeout timeout}) =>
+    _declarer.test(description, body, testOn: testOn, timeout: timeout);
 
 /// Creates a group of tests.
 ///
@@ -79,8 +85,13 @@ void test(String description, body(), {String testOn}) =>
 /// only be run on matching platforms.
 ///
 /// [platform selector]: https://github.com/dart-lang/test/#platform-selector-syntax
-void group(String description, void body(), {String testOn}) =>
-    _declarer.group(description, body, testOn: testOn);
+///
+/// If [timeout] is passed, it's used to modify or replace the default timeout
+/// of 30 seconds. Timeout modifications take precedence in suite-group-test
+/// order, so [timeout] will also modify any timeouts set on the suite, and will
+/// be modified by any timeouts set on individual tests.
+void group(String description, void body(), {String testOn, Timeout timeout}) =>
+    _declarer.group(description, body, testOn: testOn, timeout: timeout);
 
 /// Registers a function to be run before tests.
 ///
diff --git a/test/backend/declarer_test.dart b/test/backend/declarer_test.dart
index f8640aaa..f8ed99cb 100644
--- a/test/backend/declarer_test.dart
+++ b/test/backend/declarer_test.dart
@@ -6,6 +6,7 @@ import 'dart:async';
 
 import 'package:test/src/backend/declarer.dart';
 import 'package:test/src/backend/suite.dart';
+import 'package:test/src/frontend/timeout.dart';
 import 'package:test/test.dart';
 
 Declarer _declarer;
@@ -130,6 +131,38 @@ void main() {
       expect(_declarer.tests.single.name, "group description");
     });
 
+    test("a test's timeout factor is applied to the group's", () {
+      _declarer.group("group", () {
+        _declarer.test("test", () {},
+            timeout: new Timeout.factor(3));
+      }, timeout: new Timeout.factor(2));
+
+      expect(_declarer.tests, hasLength(1));
+      expect(_declarer.tests.single.metadata.timeout.scaleFactor, equals(6));
+    });
+
+    test("a test's timeout factor is applied to the group's duration", () {
+      _declarer.group("group", () {
+        _declarer.test("test", () {},
+            timeout: new Timeout.factor(2));
+      }, timeout: new Timeout(new Duration(seconds: 10)));
+
+      expect(_declarer.tests, hasLength(1));
+      expect(_declarer.tests.single.metadata.timeout.duration,
+          equals(new Duration(seconds: 20)));
+    });
+
+    test("a test's timeout duration is applied over the group's", () {
+      _declarer.group("group", () {
+        _declarer.test("test", () {},
+            timeout: new Timeout(new Duration(seconds: 15)));
+      }, timeout: new Timeout(new Duration(seconds: 10)));
+
+      expect(_declarer.tests, hasLength(1));
+      expect(_declarer.tests.single.metadata.timeout.duration,
+          equals(new Duration(seconds: 15)));
+    });
+
     group(".setUp()", () {
       test("is scoped to the group", () {
         var setUpRun = false;
diff --git a/test/backend/invoker_test.dart b/test/backend/invoker_test.dart
index 59d9c509..be27391e 100644
--- a/test/backend/invoker_test.dart
+++ b/test/backend/invoker_test.dart
@@ -473,27 +473,6 @@ void main() {
     });
   });
 
-  test("a test times out after 30 seconds", () {
-    new FakeAsync().run((async) {
-      var liveTest = _localTest(() {
-        Invoker.current.addOutstandingCallback();
-      }).load(suite);
-
-      expectStates(liveTest, [
-        const State(Status.running, Result.success),
-        const State(Status.complete, Result.error)
-      ]);
-
-      expectErrors(liveTest, [(error) {
-        expect(lastState.status, equals(Status.complete));
-        expect(error, new isInstanceOf<TimeoutException>());
-      }]);
-
-      liveTest.run();
-      async.elapse(new Duration(seconds: 30));
-    });
-  });
-
   test("a test's prints are captured and reported", () {
     expect(() {
       var liveTest = _localTest(() {
@@ -507,7 +486,76 @@ void main() {
       return liveTest.run();
     }, prints(isEmpty));
   });
+
+  group("timeout:", () {
+    test("a test times out after 30 seconds by default", () {
+      new FakeAsync().run((async) {
+        var liveTest = _localTest(() {
+          Invoker.current.addOutstandingCallback();
+        }).load(suite);
+
+        expectStates(liveTest, [
+          const State(Status.running, Result.success),
+          const State(Status.complete, Result.error)
+        ]);
+
+        expectErrors(liveTest, [(error) {
+          expect(lastState.status, equals(Status.complete));
+          expect(error, new isInstanceOf<TimeoutException>());
+        }]);
+
+        liveTest.run();
+        async.elapse(new Duration(seconds: 30));
+      });
+    });
+
+    test("a test's custom timeout takes precedence", () {
+      new FakeAsync().run((async) {
+        var liveTest = _localTest(() {
+          Invoker.current.addOutstandingCallback();
+        }, metadata: new Metadata(
+            timeout: new Timeout(new Duration(seconds: 15)))).load(suite);
+
+        expectStates(liveTest, [
+          const State(Status.running, Result.success),
+          const State(Status.complete, Result.error)
+        ]);
+
+        expectErrors(liveTest, [(error) {
+          expect(lastState.status, equals(Status.complete));
+          expect(error, new isInstanceOf<TimeoutException>());
+        }]);
+
+        liveTest.run();
+        async.elapse(new Duration(seconds: 15));
+      });
+    });
+
+    test("a timeout factor is applied on top of the 30s default", () {
+      new FakeAsync().run((async) {
+        var liveTest = _localTest(() {
+          Invoker.current.addOutstandingCallback();
+        }, metadata: new Metadata(timeout: new Timeout.factor(0.5)))
+            .load(suite);
+
+        expectStates(liveTest, [
+          const State(Status.running, Result.success),
+          const State(Status.complete, Result.error)
+        ]);
+
+        expectErrors(liveTest, [(error) {
+          expect(lastState.status, equals(Status.complete));
+          expect(error, new isInstanceOf<TimeoutException>());
+        }]);
+
+        liveTest.run();
+        async.elapse(new Duration(seconds: 15));
+      });
+    });
+  });
 }
 
-LocalTest _localTest(body(), {tearDown()}) =>
-    new LocalTest("test", new Metadata(), body, tearDown: tearDown);
+LocalTest _localTest(body(), {tearDown(), Metadata metadata}) {
+  if (metadata == null) metadata = new Metadata();
+  return new LocalTest("test", metadata, body, tearDown: tearDown);
+}
-- 
GitLab