diff --git a/packages/flutter/lib/src/gestures/binding.dart b/packages/flutter/lib/src/gestures/binding.dart index d71c40168c4..b1d9ee56e24 100644 --- a/packages/flutter/lib/src/gestures/binding.dart +++ b/packages/flutter/lib/src/gestures/binding.dart @@ -16,8 +16,6 @@ import 'events.dart'; import 'hit_test.dart'; import 'pointer_router.dart'; -typedef void GesturerExceptionHandler(PointerEvent event, HitTestTarget target, dynamic exception, StackTrace stack); - /// A binding for the gesture subsystem. abstract class Gesturer extends BindingBase implements HitTestTarget, HitTestable { @@ -82,14 +80,6 @@ abstract class Gesturer extends BindingBase implements HitTestTarget, HitTestabl result.add(new HitTestEntry(this)); } - /// This callback is invoked whenever an exception is caught by the Gesturer - /// binding. The 'event' argument is the pointer event that was being routed. - /// The 'target' argument is the class whose handleEvent function threw the - /// exception. The 'exception' argument contains the object that was thrown, - /// and the 'stack' argument contains the stack trace. If no handler is - /// registered, then the information will be printed to the console instead. - GesturerExceptionHandler debugGesturerExceptionHandler; - /// Dispatch the given event to the path of the given hit test result void dispatchEvent(PointerEvent event, HitTestResult result) { assert(result != null); @@ -97,20 +87,20 @@ abstract class Gesturer extends BindingBase implements HitTestTarget, HitTestabl try { entry.target.handleEvent(event, entry); } catch (exception, stack) { - if (debugGesturerExceptionHandler != null) { - debugGesturerExceptionHandler(event, entry.target, exception, stack); - } else { - debugPrint('-- EXCEPTION CAUGHT BY GESTURE LIBRARY ---------------------------------'); - debugPrint('The following exception was raised while dispatching a pointer event:'); - debugPrint('$exception'); - debugPrint('Event:'); - debugPrint('$event'); - debugPrint('Target:'); - debugPrint('${entry.target}'); - debugPrint('Stack trace:'); - debugPrint('$stack'); - debugPrint('------------------------------------------------------------------------'); - } + FlutterError.reportError(new FlutterErrorDetailsForPointerEventDispatcher( + exception: exception, + stack: stack, + library: 'gesture library', + context: 'while dispatching a pointer event', + event: event, + hitTestEntry: entry, + informationCollector: (StringBuffer information) { + information.writeln('Event:'); + information.writeln(' $event'); + information.writeln('Target:'); + information.write(' ${entry.target}'); + } + )); } } } @@ -125,3 +115,44 @@ abstract class Gesturer extends BindingBase implements HitTestTarget, HitTestabl } } } + +/// Variant of [FlutterErrorDetails] with extra fields for the gesture +/// library's binding's pointer event dispatcher ([Gesturer.dispatchEvent]). +/// +/// See also [FlutterErrorDetailsForPointerRouter], which is also used by the +/// gesture library. +class FlutterErrorDetailsForPointerEventDispatcher extends FlutterErrorDetails { + /// Creates a [FlutterErrorDetailsForPointerEventDispatcher] object with the given + /// arguments setting the object's properties. + /// + /// The gesture library calls this constructor when catching an exception + /// that will subsequently be reported using [FlutterError.onError]. + const FlutterErrorDetailsForPointerEventDispatcher({ + dynamic exception, + StackTrace stack, + String library, + String context, + this.event, + this.hitTestEntry, + FlutterInformationCollector informationCollector, + bool silent + }) : super( + exception: exception, + stack: stack, + library: library, + context: context, + informationCollector: informationCollector, + silent: silent + ); + + /// The pointer event that was being routed when the exception was raised. + final PointerEvent event; + + /// The hit test result entry for the object whose handleEvent method threw + /// the exception. + /// + /// The target object itself is given by the [HitTestEntry.target] property of + /// the hitTestEntry object. + final HitTestEntry hitTestEntry; +} + diff --git a/packages/flutter/lib/src/gestures/pointer_router.dart b/packages/flutter/lib/src/gestures/pointer_router.dart index 5985187f389..b791b55df11 100644 --- a/packages/flutter/lib/src/gestures/pointer_router.dart +++ b/packages/flutter/lib/src/gestures/pointer_router.dart @@ -11,8 +11,6 @@ import 'events.dart'; /// A callback that receives a [PointerEvent] typedef void PointerRoute(PointerEvent event); -typedef void PointerExceptionHandler(PointerRouter source, PointerEvent event, PointerRoute route, dynamic exception, StackTrace stack); - /// A routing table for [PointerEvent] events. class PointerRouter { final Map> _routeMap = new Map>(); @@ -40,16 +38,6 @@ class PointerRouter { _routeMap.remove(pointer); } - /// This callback is invoked whenever an exception is caught by the pointer - /// router. The 'source' argument is the [PointerRouter] object that caught - /// the exception. The 'event' argument is the pointer event that was being - /// routed. The 'route' argument is the callback that threw the exception. The - /// 'exception' argument contains the object that was thrown, and the 'stack' - /// argument contains the stack trace. If no handler is registered, then the - /// human-readable parts of this information (the exception, event, and stack - /// trace) will be printed to the console instead. - PointerExceptionHandler debugPointerExceptionHandler; - /// Calls the routes registered for this pointer event. /// /// Routes are called in the order in which they were added to the @@ -64,19 +52,64 @@ class PointerRouter { try { route(event); } catch (exception, stack) { - if (debugPointerExceptionHandler != null) { - debugPointerExceptionHandler(this, event, route, exception, stack); - } else { - debugPrint('-- EXCEPTION CAUGHT BY GESTURE LIBRARY ---------------------------------'); - debugPrint('The following exception was raised while routing a pointer event:'); - debugPrint('$exception'); - debugPrint('Event:'); - debugPrint('$event'); - debugPrint('Stack trace:'); - debugPrint('$stack'); - debugPrint('------------------------------------------------------------------------'); - } + FlutterError.reportError(new FlutterErrorDetailsForPointerRouter( + exception: exception, + stack: stack, + library: 'gesture library', + context: 'while routing a pointer event', + router: this, + route: route, + event: event, + informationCollector: (StringBuffer information) { + information.writeln('Event:'); + information.write(' $event'); + } + )); } } } } + +/// Variant of [FlutterErrorDetails] with extra fields for the gestures +/// library's pointer router ([PointerRouter]). +/// +/// See also [FlutterErrorDetailsForPointerEventDispatcher], which is also used +/// by the gestures library. +class FlutterErrorDetailsForPointerRouter extends FlutterErrorDetails { + /// Creates a [FlutterErrorDetailsForPointerRouter] object with the given + /// arguments setting the object's properties. + /// + /// The gestures library calls this constructor when catching an exception + /// that will subsequently be reported using [FlutterError.onError]. + const FlutterErrorDetailsForPointerRouter({ + dynamic exception, + StackTrace stack, + String library, + String context, + this.router, + this.route, + this.event, + FlutterInformationCollector informationCollector, + bool silent + }) : super( + exception: exception, + stack: stack, + library: library, + context: context, + informationCollector: informationCollector, + silent: silent + ); + + /// The pointer router that caught the exception. + /// + /// In a typical application, this is the value of [Gesturer.pointerRouter] on + /// the binding ([Gesturer.instance]). + final PointerRouter router; + + /// The callback that threw the exception. + final PointerRoute route; + + /// The pointer event that was being routed when the exception was raised. + final PointerEvent event; +} + diff --git a/packages/flutter/lib/src/http/mojo_client.dart b/packages/flutter/lib/src/http/mojo_client.dart index af1d8f066aa..2fb4df92395 100644 --- a/packages/flutter/lib/src/http/mojo_client.dart +++ b/packages/flutter/lib/src/http/mojo_client.dart @@ -149,14 +149,14 @@ class MojoClient { ByteData data = await mojo.DataPipeDrainer.drainHandle(response.body); Uint8List bodyBytes = new Uint8List.view(data.buffer); return new Response(bodyBytes: bodyBytes, statusCode: response.statusCode); - } catch (exception) { - assert(() { - debugPrint('-- EXCEPTION CAUGHT BY NETWORKING HTTP LIBRARY -------------------------'); - debugPrint('An exception was raised while sending bytes to the Mojo network library:'); - debugPrint('$exception'); - debugPrint('------------------------------------------------------------------------'); - return true; - }); + } catch (exception, stack) { + FlutterError.reportError(new FlutterErrorDetails( + exception: exception, + stack: stack, + library: 'networking HTTP library', + context: 'while sending bytes to the Mojo network library', + silent: true + )); return new Response(statusCode: 500); } finally { loader.close(); diff --git a/packages/flutter/lib/src/rendering/box.dart b/packages/flutter/lib/src/rendering/box.dart index e20837ac824..bc6c347c513 100644 --- a/packages/flutter/lib/src/rendering/box.dart +++ b/packages/flutter/lib/src/rendering/box.dart @@ -795,10 +795,13 @@ abstract class RenderBox extends RenderObject { void performLayout() { assert(() { if (!sizedByParent) { - debugPrint('$runtimeType needs to either override performLayout() to\n' - 'set size and lay out children, or, set sizedByParent to true\n' - 'so that performResize() sizes the render object.'); - assert(sizedByParent); + throw new FlutterError( + '$runtimeType did not implement performLayout().\n' + 'RenderBox subclasses need to either override performLayout() to ' + 'set a size and lay out any children, or, set sizedByParent to true ' + 'so that performResize() sizes the render object.' + ); + return true; } return true; }); diff --git a/packages/flutter/lib/src/rendering/object.dart b/packages/flutter/lib/src/rendering/object.dart index 77dc0fec86f..f38fc662b60 100644 --- a/packages/flutter/lib/src/rendering/object.dart +++ b/packages/flutter/lib/src/rendering/object.dart @@ -394,16 +394,6 @@ typedef void RenderObjectVisitor(RenderObject child); typedef void LayoutCallback(Constraints constraints); typedef double ExtentCallback(Constraints constraints); -typedef void RenderingExceptionHandler(RenderObject source, String method, dynamic exception, StackTrace stack); -/// This callback is invoked whenever an exception is caught by the rendering -/// system. The 'source' argument is the [RenderObject] object that caught the -/// exception. The 'method' argument is the method in which the exception -/// occurred; it will be one of 'performResize', 'performLayout, or 'paint'. The -/// 'exception' argument contains the object that was thrown, and the 'stack' -/// argument contains the stack trace. If no handler is registered, then the -/// information will be printed to the console instead. -RenderingExceptionHandler debugRenderingExceptionHandler; - class _SemanticsGeometry { _SemanticsGeometry() : transform = new Matrix4.identity(); _SemanticsGeometry.withClipFrom(_SemanticsGeometry other) { @@ -889,49 +879,45 @@ abstract class RenderObject extends AbstractNode implements HitTestTarget { void visitChildren(RenderObjectVisitor visitor) { } dynamic debugCreator; - static int _debugPrintedExceptionCount = 0; void _debugReportException(String method, dynamic exception, StackTrace stack) { - try { - if (debugRenderingExceptionHandler != null) { - debugRenderingExceptionHandler(this, method, exception, stack); - } else { - _debugPrintedExceptionCount += 1; - if (_debugPrintedExceptionCount == 1) { - debugPrint('-- EXCEPTION CAUGHT BY RENDERING LIBRARY -------------------------------'); - debugPrint('The following exception was raised during $method():'); - debugPrint('$exception'); - debugPrint('The following RenderObject was being processed when the exception was fired:\n${this}'); - if (debugCreator != null) - debugPrint('This RenderObject had the following creator:\n$debugCreator'); - int depth = 0; - List descendants = []; - const int maxDepth = 5; - void visitor(RenderObject child) { - depth += 1; + FlutterError.reportError(new FlutterErrorDetailsForRendering( + exception: exception, + stack: stack, + library: 'rendering library', + context: 'during $method()', + renderObject: this, + informationCollector: (StringBuffer information) { + information.writeln('The following RenderObject was being processed when the exception was fired:\n${this}'); + if (debugCreator != null) + information.writeln('This RenderObject had the following creator:\n$debugCreator'); + List descendants = []; + const int maxDepth = 5; + int depth = 0; + const int maxLines = 30; + int lines = 0; + void visitor(RenderObject child) { + if (lines < maxLines) { descendants.add('${" " * depth}$child'); + depth += 1; if (depth < maxDepth) child.visitChildren(visitor); depth -= 1; + } else if (lines == maxLines) { + descendants.add(' ...(descendants list truncated after $lines lines)'); } - visitChildren(visitor); - if (descendants.length > 1) { - debugPrint('This RenderObject had the following descendants (showing up to depth $maxDepth):'); - } else if (descendants.length == 1) { - debugPrint('This RenderObject had the following child:'); - } else { - debugPrint('This RenderObject has no descendants.'); - } - descendants.forEach(debugPrint); - debugPrint('Stack trace:'); - debugPrint('$stack'); - debugPrint('------------------------------------------------------------------------'); - } else { - debugPrint('Another exception was raised: ${exception.toString().split("\n")[0]}'); + lines += 1; } + visitChildren(visitor); + if (lines > 1) { + information.writeln('This RenderObject had the following descendants (showing up to depth $maxDepth):'); + } else if (descendants.length == 1) { + information.writeln('This RenderObject had the following child:'); + } else { + information.writeln('This RenderObject has no descendants.'); + } + information.writeAll(descendants, '\n'); } - } catch (exception) { - debugPrint('(exception during exception handler: $exception)'); - } + )); } bool _debugDoingThisResize = false; @@ -2153,3 +2139,32 @@ abstract class ContainerRenderObjectMixin message; + + /// Called whenever the Flutter framework catches an error. + /// + /// The default behavior is to invoke [dumpErrorToConsole]. + /// + /// You can set this to your own function to override this default behavior. + /// For example, you could report all errors to your server. + /// + /// If the error handler throws an exception, it will not be caught by the + /// Flutter framework. + /// + /// Set this to null to silently catch and ignore errors. This is not + /// recommended. + static FlutterExceptionHandler onError = dumpErrorToConsole; + + static int _errorCount = 0; + + /// Prints the given exception details to the console. + /// + /// The first time this is called, it dumps a very verbose message to the + /// console using [debugPrint]. + /// + /// Subsequent calls only dump the first line of the exception. + /// + /// This is the default behavior for the [onError] handler. + static void dumpErrorToConsole(FlutterErrorDetails details) { + assert(details != null); + assert(details.exception != null); + bool reportError = !details.silent; + assert(() { + // In checked mode, we ignore the "silent" flag. + reportError = true; + return true; + }); + if (!reportError) + return; + if (_errorCount == 0) { + final String header = '-- EXCEPTION CAUGHT BY ${details.library} '.toUpperCase(); + const String footer = '------------------------------------------------------------------------'; + debugPrint('$header${"-" * (footer.length - header.length)}'); + debugPrint('The following exception was raised${ details.context != null ? " ${details.context}" : ""}:'); + debugPrint('${details.exception}'); + if (details.informationCollector != null) { + StringBuffer information = new StringBuffer(); + details.informationCollector(information); + debugPrint(information.toString()); + } + if (details.stack != null) { + debugPrint('Stack trace:'); + debugPrint('${details.stack}$footer'); + } else { + debugPrint(footer); + } + } else { + debugPrint('Another exception was raised: ${details.exception.toString().split("\n")[0]}'); + } + _errorCount += 1; + } + + /// Calls [onError] with the given details, unless it is null. + static void reportError(FlutterErrorDetails details) { + assert(details != null); + assert(details.exception != null); + if (onError != null) + onError(details); + } } diff --git a/packages/flutter/lib/src/services/fetch.dart b/packages/flutter/lib/src/services/fetch.dart index bf2f7fc1f8a..b4d2a782637 100644 --- a/packages/flutter/lib/src/services/fetch.dart +++ b/packages/flutter/lib/src/services/fetch.dart @@ -8,8 +8,8 @@ import 'package:mojo/mojo/url_request.mojom.dart' as mojom; import 'package:mojo/mojo/url_response.mojom.dart' as mojom; import 'package:mojo_services/mojo/url_loader.mojom.dart' as mojom; +import 'assertions.dart'; import '../http/mojo_client.dart'; -import 'print.dart'; export 'package:mojo/mojo/url_response.mojom.dart' show UrlResponse; @@ -27,14 +27,17 @@ Future fetch(mojom.UrlRequest request, { bool require200: fal message.writeln('Protocol error: ${response.statusCode} ${response.statusLine ?? ""}'); if (response.url != request.url) message.writeln('Final URL after redirects was: ${response.url}'); - throw message; + throw message; // this is not a FlutterError, because it's a real error, not an assertion } return response; - } catch (exception) { - debugPrint('-- EXCEPTION CAUGHT BY NETWORKING HTTP LIBRARY -------------------------'); - debugPrint('An exception was raised while sending bytes to the Mojo network library:'); - debugPrint('$exception'); - debugPrint('------------------------------------------------------------------------'); + } catch (exception, stack) { + FlutterError.reportError(new FlutterErrorDetails( + exception: exception, + stack: stack, + library: 'fetch service', + context: 'while sending bytes to the Mojo network library', + silent: true + )); return null; } finally { loader.close(); diff --git a/packages/flutter/lib/src/services/image_resource.dart b/packages/flutter/lib/src/services/image_resource.dart index cb11644e8ef..8537c8dc0db 100644 --- a/packages/flutter/lib/src/services/image_resource.dart +++ b/packages/flutter/lib/src/services/image_resource.dart @@ -5,7 +5,7 @@ import 'dart:async'; import 'dart:ui' as ui show Image; -import 'print.dart'; +import 'assertions.dart'; /// A [ui.Image] object with its corresponding scale. /// @@ -61,7 +61,7 @@ class ImageResource { _futureImage.then( _handleImageLoaded, onError: (dynamic exception, dynamic stack) { - _handleImageError('Failed to load image:', exception, stack); + _handleImageError('while loading an image', exception, stack); } ); } @@ -86,7 +86,7 @@ class ImageResource { try { listener(_image); } catch (exception, stack) { - _handleImageError('The following exception was thrown by a synchronously-invoked image listener:', exception, stack); + _handleImageError('by a synchronously-invoked image listener', exception, stack); } } } @@ -109,18 +109,18 @@ class ImageResource { try { listener(_image); } catch (exception, stack) { - _handleImageError('The following exception was thrown by an image listener:', exception, stack); + _handleImageError('by an image listener', exception, stack); } } } - void _handleImageError(String message, dynamic exception, dynamic stack) { - debugPrint('-- EXCEPTION CAUGHT BY SERVICES LIBRARY --------------------------------'); - debugPrint(message); - debugPrint('$exception'); - debugPrint('Stack trace:'); - debugPrint('$stack'); - debugPrint('------------------------------------------------------------------------'); + void _handleImageError(String context, dynamic exception, dynamic stack) { + FlutterError.reportError(new FlutterErrorDetails( + exception: exception, + stack: stack, + library: 'image resource service', + context: context + )); } @override diff --git a/packages/flutter/lib/src/widgets/binding.dart b/packages/flutter/lib/src/widgets/binding.dart index d07a3e45def..a97ad998d8d 100644 --- a/packages/flutter/lib/src/widgets/binding.dart +++ b/packages/flutter/lib/src/widgets/binding.dart @@ -170,7 +170,7 @@ class RenderObjectToWidgetAdapter extends RenderObjectWi } else { element.update(this); } - }, building: true); + }, building: true, context: 'while attaching root widget to rendering tree'); return element; } diff --git a/packages/flutter/lib/src/widgets/framework.dart b/packages/flutter/lib/src/widgets/framework.dart index 61ff0acaa27..d73507c4eec 100644 --- a/packages/flutter/lib/src/widgets/framework.dart +++ b/packages/flutter/lib/src/widgets/framework.dart @@ -9,6 +9,7 @@ import 'dart:developer'; import 'debug.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; export 'dart:ui' show hashValues, hashList; export 'package:flutter/rendering.dart' show RenderObject, RenderBox, debugPrint; @@ -695,17 +696,17 @@ class BuildOwner { bool _debugBuilding = false; BuildableElement _debugCurrentBuildTarget; - /// Establishes a scope in which widget build functions can run. + /// Establishes a scope in which calls to [State.setState] are forbidden. /// - /// Inside a build scope, widget build functions are allowed to run, but - /// State.setState() is forbidden. This mechanism prevents build functions - /// from transitively requiring other build functions to run, potentially - /// causing infinite loops. + /// This mechanism prevents build functions from transitively requiring other + /// build functions to run, potentially causing infinite loops. /// - /// After unwinding the last build scope on the stack, the framework verifies - /// that each global key is used at most once and notifies listeners about - /// changes to global keys. - void lockState(void callback(), { bool building: false }) { + /// If the building argument is true, then this is a build scope. Build scopes + /// cannot be nested. + /// + /// The context argument is used to describe the scope in case an exception is + /// caught while invoking the callback. + void lockState(void callback(), { bool building: false, String context }) { assert(_debugStateLockLevel >= 0); assert(() { if (building) { @@ -718,6 +719,8 @@ class BuildOwner { }); try { callback(); + } catch (e, stack) { + _debugReportException(context, e, stack); } finally { assert(() { _debugStateLockLevel -= 1; @@ -766,18 +769,25 @@ class BuildOwner { } assert(!_dirtyElements.any((BuildableElement element) => element.dirty)); _dirtyElements.clear(); - }, building: true); + }, building: true, context: 'while rebuilding dirty elements'); assert(_dirtyElements.isEmpty); Timeline.finishSync(); } /// Complete the element build pass by unmounting any elements that are no /// longer active. + /// /// This is called by beginFrame(). + /// + /// In checked mode, this also verifies that each global key is used at most + /// once. + /// + /// After the current call stack unwinds, a microtask that notifies listeners + /// about changes to global keys will run. void finalizeTree() { lockState(() { _inactiveElements._unmountAll(); - }); + }, context: 'while finalizing the widget tree'); assert(GlobalKey._debugCheckForDuplicates); scheduleMicrotask(GlobalKey._notifyListeners); } @@ -2126,24 +2136,11 @@ class MultiChildRenderObjectElement extends RenderObjectElement { } } -typedef void WidgetsExceptionHandler(String context, dynamic exception, StackTrace stack); -/// This callback is invoked whenever an exception is caught by the widget -/// system. The 'context' argument is a description of what was happening when -/// the exception occurred, and may include additional details such as -/// descriptions of the objects involved. The 'exception' argument contains the -/// object that was thrown, and the 'stack' argument contains the stack trace. -/// If no callback is set, then a default behavior consisting of dumping the -/// context, exception, and stack trace to the console is used instead. -WidgetsExceptionHandler debugWidgetsExceptionHandler; void _debugReportException(String context, dynamic exception, StackTrace stack) { - if (debugWidgetsExceptionHandler != null) { - debugWidgetsExceptionHandler(context, exception, stack); - } else { - debugPrint('-- EXCEPTION CAUGHT BY WIDGETS LIBRARY ---------------------------------'); - debugPrint('Exception caught while $context'); - debugPrint('$exception'); - debugPrint('Stack trace:'); - debugPrint('$stack'); - debugPrint('------------------------------------------------------------------------'); - } + FlutterError.reportError(new FlutterErrorDetails( + exception: exception, + stack: stack, + library: 'widgets library', + context: context + )); } diff --git a/packages/flutter/lib/src/widgets/mixed_viewport.dart b/packages/flutter/lib/src/widgets/mixed_viewport.dart index a28449e9818..cef35fa1927 100644 --- a/packages/flutter/lib/src/widgets/mixed_viewport.dart +++ b/packages/flutter/lib/src/widgets/mixed_viewport.dart @@ -254,7 +254,7 @@ class _MixedViewportElement extends RenderObjectElement { } owner.lockState(() { _doLayout(constraints); - }, building: true); + }, building: true, context: 'during $runtimeType layout'); } void postLayout() { diff --git a/packages/flutter/lib/src/widgets/virtual_viewport.dart b/packages/flutter/lib/src/widgets/virtual_viewport.dart index 204a9a2832d..eca2c0c4d35 100644 --- a/packages/flutter/lib/src/widgets/virtual_viewport.dart +++ b/packages/flutter/lib/src/widgets/virtual_viewport.dart @@ -157,7 +157,7 @@ abstract class VirtualViewportElement extends RenderObjectElement { assert(startOffsetBase != null); assert(startOffsetLimit != null); _updatePaintOffset(); - owner.lockState(_materializeChildren, building: true); + owner.lockState(_materializeChildren, building: true, context: 'during $runtimeType layout'); } void _materializeChildren() { diff --git a/packages/flutter/test/widget/build_scope_test.dart b/packages/flutter/test/widget/build_scope_test.dart index 12e35cbbe06..2693fa5c384 100644 --- a/packages/flutter/test/widget/build_scope_test.dart +++ b/packages/flutter/test/widget/build_scope_test.dart @@ -3,7 +3,6 @@ // found in the LICENSE file. import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; import 'package:test/test.dart'; @@ -73,33 +72,12 @@ class BadDisposeWidgetState extends State { @override void dispose() { - setState(() {}); + setState(() { /* This is invalid behavior. */ }); super.dispose(); } } void main() { - dynamic cachedException; - - // ** WARNING ** - // THIS TEST OVERRIDES THE NORMAL EXCEPTION HANDLING - // AND DOES NOT REPORT EXCEPTIONS FROM THE FRAMEWORK - - setUp(() { - assert(cachedException == null); - debugWidgetsExceptionHandler = (String context, dynamic exception, StackTrace stack) { - cachedException = exception; - }; - debugSchedulerExceptionHandler = (dynamic exception, StackTrace stack) { throw exception; }; - }); - - tearDown(() { - assert(cachedException == null); - cachedException = null; - debugWidgetsExceptionHandler = null; - debugSchedulerExceptionHandler = null; - }); - test('Legal times for setState', () { testWidgets((WidgetTester tester) { GlobalKey flipKey = new GlobalKey(); @@ -129,21 +107,18 @@ void main() { test('Setting parent state during build is forbidden', () { testWidgets((WidgetTester tester) { - expect(cachedException, isNull); tester.pumpWidget(new BadWidgetParent()); - expect(cachedException, isNotNull); - cachedException = null; + expect(tester.takeException(), isNotNull); tester.pumpWidget(new Container()); - expect(cachedException, isNull); }); }); test('Setting state during dispose is forbidden', () { testWidgets((WidgetTester tester) { tester.pumpWidget(new BadDisposeWidget()); - expect(() { - tester.pumpWidget(new Container()); - }, throws); + expect(tester.takeException(), isNull); + tester.pumpWidget(new Container()); + expect(tester.takeException(), isNotNull); }); }); } diff --git a/packages/flutter/test/widget/image_test.dart b/packages/flutter/test/widget/image_test.dart index a3f0d80a131..176f14f9e0b 100644 --- a/packages/flutter/test/widget/image_test.dart +++ b/packages/flutter/test/widget/image_test.dart @@ -112,18 +112,16 @@ void main() { child: new AsyncImage( provider: imageProvider1 ) - ) + ), + null, + EnginePhase.layout ); RenderImage renderImage = key.currentContext.findRenderObject(); expect(renderImage.image, isNull); - // An exception will be thrown when we try to draw the image. Catch it. - RenderingExceptionHandler originalRenderingExceptionHandler = debugRenderingExceptionHandler; - debugRenderingExceptionHandler = (_, __, ___, ____) => null; imageProvider1.complete(); - tester.pump(); - tester.pump(); - debugRenderingExceptionHandler = originalRenderingExceptionHandler; + tester.async.flushMicrotasks(); // resolve the future from the image provider + tester.pump(null, EnginePhase.layout); renderImage = key.currentContext.findRenderObject(); expect(renderImage.image, isNotNull); @@ -135,7 +133,9 @@ void main() { child: new AsyncImage( provider: imageProvider2 ) - ) + ), + null, + EnginePhase.layout ); renderImage = key.currentContext.findRenderObject(); @@ -152,18 +152,16 @@ void main() { new AsyncImage( key: key, provider: imageProvider1 - ) + ), + null, + EnginePhase.layout ); RenderImage renderImage = key.currentContext.findRenderObject(); expect(renderImage.image, isNull); - // An exception will be thrown when we try to draw the image. Catch it. - RenderingExceptionHandler originalRenderingExceptionHandler = debugRenderingExceptionHandler; - debugRenderingExceptionHandler = (_, __, ___, ____) => null; imageProvider1.complete(); - tester.pump(); - tester.pump(); - debugRenderingExceptionHandler = originalRenderingExceptionHandler; + tester.async.flushMicrotasks(); // resolve the future from the image provider + tester.pump(null, EnginePhase.layout); renderImage = key.currentContext.findRenderObject(); expect(renderImage.image, isNotNull); @@ -173,7 +171,9 @@ void main() { new AsyncImage( key: key, provider: imageProvider2 - ) + ), + null, + EnginePhase.layout ); renderImage = key.currentContext.findRenderObject(); diff --git a/packages/flutter/test/widget/parent_data_test.dart b/packages/flutter/test/widget/parent_data_test.dart index 491ad45b052..c12c5dd2503 100644 --- a/packages/flutter/test/widget/parent_data_test.dart +++ b/packages/flutter/test/widget/parent_data_test.dart @@ -48,20 +48,6 @@ void checkTree(WidgetTester tester, List expectedParentData) { final TestParentData kNonPositioned = new TestParentData(); void main() { - dynamic cachedException; - - setUp(() { - assert(cachedException == null); - debugWidgetsExceptionHandler = (String context, dynamic exception, StackTrace stack) { - cachedException = exception; - }; - }); - - tearDown(() { - cachedException = null; - debugWidgetsExceptionHandler = null; - }); - test('ParentDataWidget control test', () { testWidgets((WidgetTester tester) { @@ -259,8 +245,6 @@ void main() { test('ParentDataWidget conflicting data', () { testWidgets((WidgetTester tester) { - expect(cachedException, isNull); - tester.pumpWidget( new Stack( children: [ @@ -276,14 +260,11 @@ void main() { ] ) ); - - expect(cachedException, isNotNull); - cachedException = null; + expect(tester.takeException(), isNotNull); tester.pumpWidget(new Stack()); checkTree(tester, []); - expect(cachedException, isNull); tester.pumpWidget( new Container( @@ -298,9 +279,7 @@ void main() { ) ) ); - - expect(cachedException, isNotNull); - cachedException = null; + expect(tester.takeException(), isNotNull); tester.pumpWidget( new Stack() diff --git a/packages/flutter_test/lib/src/widget_tester.dart b/packages/flutter_test/lib/src/widget_tester.dart index 60cce29938b..2b24355c158 100644 --- a/packages/flutter_test/lib/src/widget_tester.dart +++ b/packages/flutter_test/lib/src/widget_tester.dart @@ -4,11 +4,12 @@ import 'dart:ui' as ui show window; -import 'package:quiver/testing/async.dart'; -import 'package:quiver/time.dart'; +import 'package:flutter/services.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; +import 'package:quiver/testing/async.dart'; +import 'package:quiver/time.dart'; import 'instrumentation.dart'; @@ -89,6 +90,21 @@ class WidgetTester extends Instrumentation { /// The supplied EnginePhase is the final phase reached during the pump pass; /// if not supplied, the whole pass is executed. void pumpWidget(Widget widget, [ Duration duration, EnginePhase phase ]) { + runApp(widget); + pump(duration, phase); + } + + /// Triggers a frame sequence (build/layout/paint/etc), + /// then flushes microtasks. + /// + /// If duration is set, then advances the clock by that much first. + /// Doing this flushes microtasks. + /// + /// The supplied EnginePhase is the final phase reached during the pump pass; + /// if not supplied, the whole pass is executed. + void pump([ Duration duration, EnginePhase phase ]) { + if (duration != null) + async.elapse(duration); if (binding is _SteppedWidgetFlutterBinding) { // Some tests call WidgetFlutterBinding.ensureInitialized() manually, so // we can't actually be sure we have a stepped binding. @@ -98,8 +114,10 @@ class WidgetTester extends Instrumentation { // Can't step to a given phase in that case assert(phase == null); } - runApp(widget); - pump(duration); + binding.handleBeginFrame(new Duration( + milliseconds: clock.now().millisecondsSinceEpoch) + ); + async.flushMicrotasks(); } /// Artificially calls dispatchLocaleChanged on the Widget binding, @@ -110,46 +128,72 @@ class WidgetTester extends Instrumentation { async.flushMicrotasks(); } - /// Triggers a frame sequence (build/layout/paint/etc), - /// then flushes microtasks. - /// - /// If duration is set, then advances the clock by that much first. - /// Doing this flushes microtasks. - void pump([ Duration duration ]) { - if (duration != null) - async.elapse(duration); - binding.handleBeginFrame(new Duration( - milliseconds: clock.now().millisecondsSinceEpoch) - ); - async.flushMicrotasks(); - } - @override void dispatchEvent(PointerEvent event, HitTestResult result) { super.dispatchEvent(event, result); async.flushMicrotasks(); } + + /// Returns the exception most recently caught by the Flutter framework. + /// + /// Call this if you expect an exception during a test. If an exception is + /// thrown and this is not called, then the exception is rethrown when + /// the [testWidgets] call completes. + /// + /// If two exceptions are thrown in a row without the first one being + /// acknowledged with a call to this method, then when the second exception is + /// thrown, they are both dumped to the console and then the second is + /// rethrown from the exception handler. This will likely result in the + /// framework entering a highly unstable state and everything collapsing. + /// + /// It's safe to call this when there's no pending exception; it will return + /// null in that case. + dynamic takeException() { + dynamic result = _pendingException; + _pendingException = null; + return result; + } + dynamic _pendingException; } void testWidgets(callback(WidgetTester tester)) { new FakeAsync().run((FakeAsync async) { - WidgetTester tester = new WidgetTester._(async); - runApp(new Container(key: new UniqueKey())); // Reset the tree to a known state. - callback(tester); - runApp(new Container(key: new UniqueKey())); // Unmount any remaining widgets. - async.flushMicrotasks(); - assert(() { - "An animation is still running even after the widget tree was disposed."; - return Scheduler.instance.transientCallbackCount == 0; - }); - assert(() { - "A Timer is still running even after the widget tree was disposed."; - return async.periodicTimerCount == 0; - }); - assert(() { - "A Timer is still running even after the widget tree was disposed."; - return async.nonPeriodicTimerCount == 0; - }); - assert(async.microtaskCount == 0); // Shouldn't be possible. + FlutterExceptionHandler oldHandler = FlutterError.onError; + try { + WidgetTester tester = new WidgetTester._(async); + FlutterError.onError = (FlutterErrorDetails details) { + if (tester._pendingException != null) { + FlutterError.dumpErrorToConsole(tester._pendingException); + FlutterError.dumpErrorToConsole(details.exception); + tester._pendingException = 'An uncaught exception was thrown.'; + throw details.exception; + } + tester._pendingException = details; + }; + runApp(new Container(key: new UniqueKey())); // Reset the tree to a known state. + callback(tester); + runApp(new Container(key: new UniqueKey())); // Unmount any remaining widgets. + async.flushMicrotasks(); + assert(() { + "An animation is still running even after the widget tree was disposed."; + return Scheduler.instance.transientCallbackCount == 0; + }); + assert(() { + "A Timer is still running even after the widget tree was disposed."; + return async.periodicTimerCount == 0; + }); + assert(() { + "A Timer is still running even after the widget tree was disposed."; + return async.nonPeriodicTimerCount == 0; + }); + assert(async.microtaskCount == 0); // Shouldn't be possible. + assert(() { + if (tester._pendingException != null) + FlutterError.dumpErrorToConsole(tester._pendingException); + return tester._pendingException == null; + }); + } finally { + FlutterError.onError = oldHandler; + } }); }