Switch from mustWaitFor to markPending (#1487)

Previously the call to `Invoker.current.removeOutstandingCallbacks`
happened synchronously when the callback was called. After the change to
use `testHandle.mustWaitFor(Future)` the callback would no longer be
called in a `FakeAsync` zone that does not have any further async
progression.

Avoid the complication with a more forgiving non-Future API which
abstracts the trickiness around zones (other than that the
`markPending` call must be in the test zone, which is the case for
all APIs) and errors.

diff --git a/pkgs/test_api/lib/hooks.dart b/pkgs/test_api/lib/hooks.dart
index c22fe55..cdfff57 100644
--- a/pkgs/test_api/lib/hooks.dart
+++ b/pkgs/test_api/lib/hooks.dart
@@ -2,6 +2,8 @@
 // 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.
 
+import 'dart:async';
+
 import 'package:stack_trace/stack_trace.dart';
 
 import 'src/backend/closed_exception.dart';
@@ -45,14 +47,13 @@
     _invoker.skip(message);
   }
 
-  /// Indicates that this test should not be considered done until [future]
-  /// completes.
+  /// Indicates that this test should not be considered done until the returned
+  /// [OutstandingWork] is marked as complete.
   ///
-  /// The test may time out before [future] completes.
-  Future<T> mustWaitFor<T>(Future<T> future) {
+  /// The test may time out before the outstanding work completes.
+  OutstandingWork markPending() {
     if (_invoker.closed) throw ClosedException();
-    _invoker.addOutstandingCallback();
-    return future.whenComplete(_invoker.removeOutstandingCallback);
+    return OutstandingWork._(_invoker, Zone.current);
   }
 
   /// Converts [stackTrace] to a [Chain] according to the current test's
@@ -61,6 +62,20 @@
       _stackTraceFormatter.formatStackTrace(stackTrace);
 }
 
+class OutstandingWork {
+  final Invoker _invoker;
+  final Zone _zone;
+  var _isComplete = false;
+  OutstandingWork._(this._invoker, this._zone) {
+    _invoker.addOutstandingCallback();
+  }
+  void complete() {
+    if (_isComplete) return;
+    _isComplete = true;
+    _zone.run(_invoker.removeOutstandingCallback);
+  }
+}
+
 class OutsideTestException implements Exception {}
 
 /// An exception thrown when a test assertion fails.
diff --git a/pkgs/test_api/lib/src/expect/async_matcher.dart b/pkgs/test_api/lib/src/expect/async_matcher.dart
index 236e0fd..2d920d4 100644
--- a/pkgs/test_api/lib/src/expect/async_matcher.dart
+++ b/pkgs/test_api/lib/src/expect/async_matcher.dart
@@ -29,17 +29,19 @@
 
   @override
   bool matches(item, Map matchState) {
-    var result = matchAsync(item);
+    final result = matchAsync(item);
     expect(result,
         anyOf([equals(null), TypeMatcher<Future>(), TypeMatcher<String>()]),
         reason: 'matchAsync() may only return a String, a Future, or null.');
 
     if (result is Future) {
-      TestHandle.current.mustWaitFor(result.then((realResult) {
+      final outstandingWork = TestHandle.current.markPending();
+      result.then((realResult) {
         if (realResult != null) {
           fail(formatFailure(this, item, realResult as String));
         }
-      }));
+        outstandingWork.complete();
+      });
     } else if (result is String) {
       matchState[this] = result;
       return false;
diff --git a/pkgs/test_api/lib/src/expect/expect.dart b/pkgs/test_api/lib/src/expect/expect.dart
index 0342e87..a1da26a 100644
--- a/pkgs/test_api/lib/src/expect/expect.dart
+++ b/pkgs/test_api/lib/src/expect/expect.dart
@@ -106,11 +106,16 @@
     if (result is String) {
       fail(formatFailure(matcher, actual, result, reason: reason));
     } else if (result is Future) {
-      return test.mustWaitFor(result.then((realResult) {
+      final outstandingWork = test.markPending();
+      return result.then((realResult) {
         if (realResult == null) return;
         fail(formatFailure(matcher as Matcher, actual, realResult as String,
             reason: reason));
-      }));
+      }).whenComplete(() {
+        // Always remove this, in case the failure is caught and handled
+        // gracefully.
+        outstandingWork.complete();
+      });
     }
 
     return Future.sync(() {});
diff --git a/pkgs/test_api/lib/src/expect/expect_async.dart b/pkgs/test_api/lib/src/expect/expect_async.dart
index e326e47..35a0e6f 100644
--- a/pkgs/test_api/lib/src/expect/expect_async.dart
+++ b/pkgs/test_api/lib/src/expect/expect_async.dart
@@ -2,8 +2,6 @@
 // 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.
 
-import 'dart:async';
-
 import 'package:test_api/hooks.dart';
 
 import 'util/placeholder.dart';
@@ -67,7 +65,7 @@
   /// Whether this function has been called the requisite number of times.
   late bool _complete;
 
-  Completer<void>? _expectationSatisfied;
+  OutstandingWork? _outstandingWork;
 
   /// Wraps [callback] in a function that asserts that it's called at least
   /// [minExpected] times and no more than [maxExpected] times.
@@ -96,8 +94,7 @@
     }
 
     if (isDone != null || minExpected > 0) {
-      var completer = _expectationSatisfied = Completer<void>();
-      _test.mustWaitFor(completer.future);
+      _outstandingWork = _test.markPending();
       _complete = false;
     } else {
       _complete = true;
@@ -137,7 +134,7 @@
     if (_callback is Function(Never)) return max1;
     if (_callback is Function()) return max0;
 
-    _expectationSatisfied?.complete();
+    _outstandingWork?.complete();
     throw ArgumentError(
         'The wrapped function has more than 6 required arguments');
   }
@@ -210,7 +207,7 @@
     // Mark this callback as complete and remove it from the test case's
     // oustanding callback count; if that hits zero the test is done.
     _complete = true;
-    _expectationSatisfied?.complete();
+    _outstandingWork?.complete();
   }
 }
 
diff --git a/pkgs/test_api/pubspec.yaml b/pkgs/test_api/pubspec.yaml
index e4c35ae..da7d4ec 100644
--- a/pkgs/test_api/pubspec.yaml
+++ b/pkgs/test_api/pubspec.yaml
@@ -22,6 +22,7 @@
   matcher: '>=0.12.10 <0.12.11'
 
 dev_dependencies:
+  fake_async: ^1.2.0
   pedantic: ^1.10.0
   test: any
   test_core: any
diff --git a/pkgs/test_api/test/frontend/expect_async_test.dart b/pkgs/test_api/test/frontend/expect_async_test.dart
index fb68246..0b40a42 100644
--- a/pkgs/test_api/test/frontend/expect_async_test.dart
+++ b/pkgs/test_api/test/frontend/expect_async_test.dart
@@ -4,9 +4,10 @@
 
 import 'dart:async';
 
+import 'package:fake_async/fake_async.dart';
+import 'package:test/test.dart';
 import 'package:test_api/src/backend/live_test.dart';
 import 'package:test_api/src/backend/state.dart';
-import 'package:test/test.dart';
 
 import '../utils.dart';
 
@@ -316,6 +317,16 @@
     expectTestPassed(liveTest);
   });
 
+  test('may be called in a FakeAsync zone that does not run further', () async {
+    var liveTest = await runTestBody(() {
+      FakeAsync().run((_) {
+        var callback = expectAsync0(() {});
+        callback();
+      });
+    });
+    expectTestPassed(liveTest);
+  });
+
   group('old-style expectAsync()', () {
     test('works with no arguments', () async {
       var callbackRun = false;