Instrument pending timers in tests (#37646)

Flutter widget tests assert if a test completes with timers still
pending.  However, it can be hard to diagnose where a pending timer
came from.  For example, a widget might consume a third-party library
that internally uses a timer.

I added a FakeAsync.pendingTimersDebugInfo getter to quiver
(https://github.com/google/quiver-dart/pull/500).  Make flutter_test
use it.

Additionally modify Flutter's debugPrintStack to take an optional
StackTrace argument instead of always printing StackTrace.current.

Fixes #4237.
diff --git a/dev/automated_tests/test_smoke_test/pending_timer_fail_test.dart b/dev/automated_tests/test_smoke_test/pending_timer_fail_test.dart
new file mode 100644
index 0000000..ce223e6
--- /dev/null
+++ b/dev/automated_tests/test_smoke_test/pending_timer_fail_test.dart
@@ -0,0 +1,17 @@
+// Copyright 2018 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:async';
+
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+  failingPendingTimerTest();
+}
+
+void failingPendingTimerTest() {
+  testWidgets('flutter_test pending timer - negative', (WidgetTester tester) async {
+    Timer(const Duration(minutes: 10), () {});
+  });
+}
diff --git a/dev/bots/test.dart b/dev/bots/test.dart
index c546075..48a729f 100644
--- a/dev/bots/test.dart
+++ b/dev/bots/test.dart
@@ -111,6 +111,16 @@
     printOutput: false,
     timeout: _kShortTimeout,
   );
+  await _runFlutterTest(automatedTests,
+    script: path.join('test_smoke_test', 'pending_timer_fail_test.dart'),
+    expectFailure: true,
+    printOutput: false,
+    outputChecker: (CapturedOutput output) =>
+      output.stdout.contains('failingPendingTimerTest')
+      ? null
+      : 'Failed to find the stack trace for the pending Timer.',
+    timeout: _kShortTimeout,
+  );
   // We run the remaining smoketests in parallel, because they each take some
   // time to run (e.g. compiling), so we don't want to run them in series,
   // especially on 20-core machines...
diff --git a/packages/flutter/lib/src/foundation/assertions.dart b/packages/flutter/lib/src/foundation/assertions.dart
index adcb3a1..6697b15 100644
--- a/packages/flutter/lib/src/foundation/assertions.dart
+++ b/packages/flutter/lib/src/foundation/assertions.dart
@@ -737,20 +737,25 @@
   }
 }
 
-/// Dump the current stack to the console using [debugPrint] and
+/// Dump the stack to the console using [debugPrint] and
 /// [FlutterError.defaultStackFilter].
 ///
-/// The current stack is obtained using [StackTrace.current].
+/// If the `stackTrace` parameter is null, the [StackTrace.current] is used to
+/// obtain the stack.
 ///
 /// The `maxFrames` argument can be given to limit the stack to the given number
-/// of lines. By default, all non-filtered stack lines are shown.
+/// of lines before filtering is applied. By default, all stack lines are
+/// included.
 ///
 /// The `label` argument, if present, will be printed before the stack.
-void debugPrintStack({ String label, int maxFrames }) {
+void debugPrintStack({StackTrace stackTrace, String label, int maxFrames}) {
   if (label != null)
     debugPrint(label);
-  Iterable<String> lines = StackTrace.current.toString().trimRight().split('\n');
-  if (kIsWeb) {
+  stackTrace ??= StackTrace.current;
+  Iterable<String> lines = stackTrace.toString().trimRight().split('\n');
+  if (   kIsWeb
+      && lines.isNotEmpty
+      && lines.first.contains('StackTrace.current')) {
     // Remove extra call to StackTrace.current for web platform.
     // TODO(ferhat): remove when https://github.com/flutter/flutter/issues/37635
     // is addressed.
diff --git a/packages/flutter_test/lib/src/binding.dart b/packages/flutter_test/lib/src/binding.dart
index 88e1ccd..0389e50 100644
--- a/packages/flutter_test/lib/src/binding.dart
+++ b/packages/flutter_test/lib/src/binding.dart
@@ -1048,14 +1048,29 @@
   @override
   void _verifyInvariants() {
     super._verifyInvariants();
-    assert(
-      _currentFakeAsync.periodicTimerCount == 0,
-      'A periodic Timer is still running even after the widget tree was disposed.'
-    );
-    assert(
-      _currentFakeAsync.nonPeriodicTimerCount == 0,
-      'A Timer is still pending even after the widget tree was disposed.'
-    );
+
+    assert(() {
+      if (   _currentFakeAsync.periodicTimerCount == 0
+          && _currentFakeAsync.nonPeriodicTimerCount == 0) {
+        return true;
+      }
+
+      debugPrint('Pending timers:');
+      for (String timerInfo in _currentFakeAsync.pendingTimersDebugInfo) {
+        final int firstLineEnd = timerInfo.indexOf('\n');
+        assert(firstLineEnd != -1);
+
+        // No need to include the newline.
+        final String firstLine = timerInfo.substring(0, firstLineEnd);
+        final String stackTrace = timerInfo.substring(firstLineEnd + 1);
+
+        debugPrint(firstLine);
+        debugPrintStack(stackTrace: StackTrace.fromString(stackTrace));
+        debugPrint('');
+      }
+      return false;
+    }(), 'A Timer is still pending even after the widget tree was disposed.');
+
     assert(_currentFakeAsync.microtaskCount == 0); // Shouldn't be possible.
   }