Add support for custom test error reporters (#17727)

This allows test environments other than `flutter test` to have a hook
into the test exception reporting. Some test environments, for example,
don't just dump error details to the console, but rather require them
to be reported to a separate server.
diff --git a/dev/automated_tests/test_smoke_test/disallow_error_reporter_modification_test.dart b/dev/automated_tests/test_smoke_test/disallow_error_reporter_modification_test.dart
new file mode 100644
index 0000000..6ea4242
--- /dev/null
+++ b/dev/automated_tests/test_smoke_test/disallow_error_reporter_modification_test.dart
@@ -0,0 +1,13 @@
+// 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 'package:flutter/foundation.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+  testWidgets('tests must restore the value of reportTestException', (WidgetTester tester) async {
+    // This test is expected to fail.
+    reportTestException = (FlutterErrorDetails details, String testDescription) {};
+  });
+}
diff --git a/dev/bots/test.dart b/dev/bots/test.dart
index 2e822f3..e5f8c45 100644
--- a/dev/bots/test.dart
+++ b/dev/bots/test.dart
@@ -179,6 +179,13 @@
     printOutput: false,
     timeout: _kShortTimeout,
   );
+  await _runFlutterTest(automatedTests,
+    script: path.join('test_smoke_test', 'disallow_error_reporter_modification_test.dart'),
+    options: options,
+    expectFailure: true,
+    printOutput: false,
+    timeout: _kShortTimeout,
+  );
   await _runCommand(flutter,
     <String>['drive', '--use-existing-app']
         ..addAll(options)
diff --git a/packages/flutter_test/lib/flutter_test.dart b/packages/flutter_test/lib/flutter_test.dart
index 43c44d6..c46e733 100644
--- a/packages/flutter_test/lib/flutter_test.dart
+++ b/packages/flutter_test/lib/flutter_test.dart
@@ -27,7 +27,7 @@
 /// with the following signature:
 ///
 /// ```dart
-/// void main(FutureOr<void> testMain());
+/// Future<void> main(FutureOr<void> testMain());
 /// ```
 ///
 /// The test framework will execute that method and pass it the `main()` method
@@ -56,6 +56,7 @@
 export 'src/platform.dart';
 export 'src/stack_manipulation.dart';
 export 'src/test_async_utils.dart';
+export 'src/test_exception_reporter.dart';
 export 'src/test_pointer.dart';
 export 'src/test_text_input.dart';
 export 'src/test_vsync.dart';
diff --git a/packages/flutter_test/lib/src/binding.dart b/packages/flutter_test/lib/src/binding.dart
index ffdce67..9630449 100644
--- a/packages/flutter_test/lib/src/binding.dart
+++ b/packages/flutter_test/lib/src/binding.dart
@@ -22,6 +22,7 @@
 import 'goldens.dart';
 import 'stack_manipulation.dart';
 import 'test_async_utils.dart';
+import 'test_exception_reporter.dart';
 import 'test_text_input.dart';
 
 /// Phases that can be reached by [WidgetTester.pumpWidget] and
@@ -358,16 +359,7 @@
     assert(_currentTestCompleter != null);
     if (_pendingExceptionDetails != null) {
       debugPrint = debugPrintOverride; // just in case the test overrides it -- otherwise we won't see the error!
-      FlutterError.dumpErrorToConsole(_pendingExceptionDetails, forceReport: true);
-      // test_package.registerException actually just calls the current zone's error handler (that
-      // is to say, _parentZone's handleUncaughtError function). FakeAsync doesn't add one of those,
-      // but the test package does, that's how the test package tracks errors. So really we could
-      // get the same effect here by calling that error handler directly or indeed just throwing.
-      // However, we call registerException because that's the semantically correct thing...
-      String additional = '';
-      if (_currentTestDescription != '')
-        additional = '\nThe test description was: $_currentTestDescription';
-      test_package.registerException('Test failed. See exception logs above.$additional', _emptyStackTrace);
+      reportTestException(_pendingExceptionDetails, _currentTestDescription);
       _pendingExceptionDetails = null;
     }
     _currentTestDescription = null;
@@ -504,6 +496,7 @@
     await pump();
 
     final bool autoUpdateGoldensBeforeTest = autoUpdateGoldenFiles;
+    final TestExceptionReporter reportTestExceptionBeforeTest = reportTestException;
 
     // run the test
     await testBody();
@@ -517,6 +510,7 @@
       await pump();
       invariantTester();
       _verifyAutoUpdateGoldensUnset(autoUpdateGoldensBeforeTest);
+      _verifyReportTestExceptionUnset(reportTestExceptionBeforeTest);
       _verifyInvariants();
     }
 
@@ -566,6 +560,26 @@
     }());
   }
 
+  void _verifyReportTestExceptionUnset(TestExceptionReporter valueBeforeTest) {
+    assert(() {
+      if (reportTestException != valueBeforeTest) {
+        // We can't report this error to their modified reporter because we
+        // can't be guaranteed that their reporter will cause the test to fail.
+        // So we reset the error reporter to its initial value and then report
+        // this error.
+        reportTestException = valueBeforeTest;
+        FlutterError.reportError(new FlutterErrorDetails(
+          exception: new FlutterError(
+            'The value of reportTestException was changed by the test.',
+          ),
+          stack: StackTrace.current,
+          library: 'Flutter test framework',
+        ));
+      }
+      return true;
+    }());
+  }
+
   /// Called by the [testWidgets] function after a test is executed.
   void postTest() {
     assert(inTest);
@@ -1301,8 +1315,6 @@
   }
 }
 
-final StackTrace _emptyStackTrace = new stack_trace.Chain(const <stack_trace.Trace>[]);
-
 StackTrace _unmangle(StackTrace stack) {
   if (stack is stack_trace.Trace)
     return stack.vmTrace;
diff --git a/packages/flutter_test/lib/src/test_exception_reporter.dart b/packages/flutter_test/lib/src/test_exception_reporter.dart
new file mode 100644
index 0000000..df8386b
--- /dev/null
+++ b/packages/flutter_test/lib/src/test_exception_reporter.dart
@@ -0,0 +1,42 @@
+// 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 'package:flutter/foundation.dart';
+import 'package:stack_trace/stack_trace.dart' as stack_trace;
+import 'package:test/test.dart' as test_package;
+
+/// Signature for the [reportTestException] callback.
+typedef void TestExceptionReporter(FlutterErrorDetails details, String testDescription);
+
+/// A function that is called by the test framework when an unexpected error
+/// occurred during a test.
+///
+/// This function is responsible for reporting the error to the user such that
+/// the user can easily diagnose what failed when inspecting the test results.
+/// It is also responsible for reporting the error to the test framework itself
+/// in order to cause the test to fail.
+///
+/// This function is pluggable to handle the cases where tests are run in
+/// contexts _other_ than via `flutter test`.
+TestExceptionReporter get reportTestException => _reportTestException;
+TestExceptionReporter _reportTestException = _defaultTestExceptionReporter;
+set reportTestException(TestExceptionReporter handler) {
+  assert(handler != null);
+  _reportTestException = handler;
+}
+
+void _defaultTestExceptionReporter(FlutterErrorDetails errorDetails, String testDescription) {
+  FlutterError.dumpErrorToConsole(errorDetails, forceReport: true);
+  // test_package.registerException actually just calls the current zone's error handler (that
+  // is to say, _parentZone's handleUncaughtError function). FakeAsync doesn't add one of those,
+  // but the test package does, that's how the test package tracks errors. So really we could
+  // get the same effect here by calling that error handler directly or indeed just throwing.
+  // However, we call registerException because that's the semantically correct thing...
+  String additional = '';
+  if (testDescription.isNotEmpty)
+    additional = '\nThe test description was: $testDescription';
+  test_package.registerException('Test failed. See exception logs above.$additional', _emptyStackTrace);
+}
+
+final StackTrace _emptyStackTrace = new stack_trace.Chain(const <stack_trace.Trace>[]);
diff --git a/packages/flutter_test/test/custom_exception_reporter/flutter_test_config.dart b/packages/flutter_test/test/custom_exception_reporter/flutter_test_config.dart
new file mode 100644
index 0000000..2f9be23
--- /dev/null
+++ b/packages/flutter_test/test/custom_exception_reporter/flutter_test_config.dart
@@ -0,0 +1,26 @@
+// 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/foundation.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+Future<void> main(FutureOr<void> testMain()) async {
+  reportTestException = (FlutterErrorDetails details, String testDescription) {
+    expect(details.exception, const isInstanceOf<StateError>());
+    expect(details.exception.message, 'foo');
+    expect(testDescription, 'custom exception reporter');
+  };
+
+  // The error that the test throws in [runTest] will be forwarded to our
+  // reporter and should not cause the test to fail.
+  await testMain();
+}
+
+void runTest() {
+  testWidgets('custom exception reporter', (WidgetTester tester) {
+    throw new StateError('foo');
+  });
+}
diff --git a/packages/flutter_test/test/custom_exception_reporter/test_exception_reporter_test.dart b/packages/flutter_test/test/custom_exception_reporter/test_exception_reporter_test.dart
new file mode 100644
index 0000000..d93ed64
--- /dev/null
+++ b/packages/flutter_test/test/custom_exception_reporter/test_exception_reporter_test.dart
@@ -0,0 +1,7 @@
+// 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 'flutter_test_config.dart' as real_test;
+
+void main() => real_test.runTest();