blob: c06f68d3dad7189cc78fc6d0f4c914ca5a576578 [file] [log] [blame]
// Copyright 2014 The Flutter 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';
class _AsyncScope {
_AsyncScope(this.creationStack, this.zone);
final StackTrace creationStack;
final Zone zone;
}
/// Utility class for all the async APIs in the `flutter_test` library.
///
/// This class provides checking for asynchronous APIs, allowing the library to
/// verify that all the asynchronous APIs are properly `await`ed before calling
/// another.
///
/// For example, it prevents this kind of code:
///
/// ```dart
/// tester.pump(); // forgot to call "await"!
/// tester.pump();
/// ```
///
/// ...by detecting, in the second call to `pump`, that it should actually be:
///
/// ```dart
/// await tester.pump();
/// await tester.pump();
/// ```
///
/// It does this while still allowing nested calls, e.g. so that you can
/// call [expect] from inside callbacks.
///
/// You can use this in your own test functions, if you have some asynchronous
/// functions that must be used with "await". Wrap the contents of the function
/// in a call to TestAsyncUtils.guard(), as follows:
///
/// ```dart
/// Future<void> myTestFunction() => TestAsyncUtils.guard(() async {
/// // ...
/// });
/// ```
class TestAsyncUtils {
// This class is not meant to be instatiated or extended; this constructor
// prevents instantiation and extension.
// ignore: unused_element
TestAsyncUtils._();
static const String _className = 'TestAsyncUtils';
static final List<_AsyncScope> _scopeStack = <_AsyncScope>[];
/// Calls the given callback in a new async scope. The callback argument is
/// the asynchronous body of the calling method. The calling method is said to
/// be "guarded". Nested calls to guarded methods from within the body of this
/// one are fine, but calls to other guarded methods from outside the body of
/// this one before this one has finished will throw an exception.
///
/// This method first calls [guardSync].
static Future<T> guard<T>(Future<T> body()) {
guardSync();
final Zone zone = Zone.current.fork(
zoneValues: <dynamic, dynamic>{
_scopeStack: true, // so we can recognize this as our own zone
}
);
final _AsyncScope scope = _AsyncScope(StackTrace.current, zone);
_scopeStack.add(scope);
final Future<T> result = scope.zone.run<Future<T>>(body);
late T resultValue; // This is set when the body of work completes with a result value.
Future<T> completionHandler(dynamic error, StackTrace? stack) {
assert(_scopeStack.isNotEmpty);
assert(_scopeStack.contains(scope));
bool leaked = false;
_AsyncScope closedScope;
final List<DiagnosticsNode> information = <DiagnosticsNode>[];
while (_scopeStack.isNotEmpty) {
closedScope = _scopeStack.removeLast();
if (closedScope == scope)
break;
if (!leaked) {
information.add(ErrorSummary('Asynchronous call to guarded function leaked.'));
information.add(ErrorHint('You must use "await" with all Future-returning test APIs.'));
leaked = true;
}
final _StackEntry? originalGuarder = _findResponsibleMethod(closedScope.creationStack, 'guard', information);
if (originalGuarder != null) {
information.add(ErrorDescription(
'The test API method "${originalGuarder.methodName}" '
'from class ${originalGuarder.className} '
'was called from ${originalGuarder.callerFile} '
'on line ${originalGuarder.callerLine}, '
'but never completed before its parent scope closed.'
));
}
}
if (leaked) {
if (error != null) {
information.add(DiagnosticsProperty<dynamic>(
'An uncaught exception may have caused the guarded function leak. The exception was',
error,
style: DiagnosticsTreeStyle.errorProperty,
));
information.add(DiagnosticsStackTrace('The stack trace associated with this exception was', stack));
}
throw FlutterError.fromParts(information);
}
if (error != null)
return Future<T>.error(error! as Object, stack);
return Future<T>.value(resultValue);
}
return result.then<T>(
(T value) {
resultValue = value;
return completionHandler(null, null);
},
onError: completionHandler,
);
}
static Zone? get _currentScopeZone {
Zone? zone = Zone.current;
while (zone != null) {
if (zone[_scopeStack] == true)
return zone;
zone = zone.parent;
}
return null;
}
/// Verifies that there are no guarded methods currently pending (see [guard]).
///
/// If a guarded method is currently pending, and this is not a call nested
/// from inside that method's body (directly or indirectly), then this method
/// will throw a detailed exception.
static void guardSync() {
if (_scopeStack.isEmpty) {
// No scopes open, so we must be fine.
return;
}
// Find the current TestAsyncUtils scope zone so we can see if it's the one we expect.
final Zone? zone = _currentScopeZone;
if (zone == _scopeStack.last.zone) {
// We're still in the current scope zone. All good.
return;
}
// If we get here, we know we've got a conflict on our hands.
// We got an async barrier, but the current zone isn't the last scope that
// we pushed on the stack.
// Find which scope the conflict happened in, so that we know
// which stack trace to report the conflict as starting from.
//
// For example, if we called an async method A, which ran its body in a
// guarded block, and in its body it ran an async method B, which ran its
// body in a guarded block, but we didn't await B, then in A's block we ran
// an async method C, which ran its body in a guarded block, then we should
// complain about the call to B then the call to C. BUT. If we called an async
// method A, which ran its body in a guarded block, and in its body it ran
// an async method B, which ran its body in a guarded block, but we didn't
// await A, and then at the top level we called a method D, then we should
// complain about the call to A then the call to D.
//
// In both examples, the scope stack would have two scopes. In the first
// example, the current zone would be the zone of the _scopeStack[0] scope,
// and we would want to show _scopeStack[1]'s creationStack. In the second
// example, the current zone would not be in the _scopeStack, and we would
// want to show _scopeStack[0]'s creationStack.
int skipCount = 0;
_AsyncScope candidateScope = _scopeStack.last;
_AsyncScope scope;
do {
skipCount += 1;
scope = candidateScope;
if (skipCount >= _scopeStack.length) {
if (zone == null)
break;
// Some people have reported reaching this point, but it's not clear
// why. For now, just silently return.
// TODO(ianh): If we ever get a test case that shows how we reach
// this point, reduce it and report the error if there is one.
return;
}
candidateScope = _scopeStack[_scopeStack.length - skipCount - 1];
assert(candidateScope != null);
assert(candidateScope.zone != null);
} while (candidateScope.zone != zone);
assert(scope != null);
final List<DiagnosticsNode> information = <DiagnosticsNode>[
ErrorSummary('Guarded function conflict.'),
ErrorHint('You must use "await" with all Future-returning test APIs.'),
];
final _StackEntry? originalGuarder = _findResponsibleMethod(scope.creationStack, 'guard', information);
final _StackEntry? collidingGuarder = _findResponsibleMethod(StackTrace.current, 'guardSync', information);
if (originalGuarder != null && collidingGuarder != null) {
final String originalKind = originalGuarder.className == null ? 'function' : 'method';
String originalName;
if (originalGuarder.className == null) {
originalName = '$originalKind (${originalGuarder.methodName})';
information.add(ErrorDescription(
'The guarded "${originalGuarder.methodName}" function '
'was called from ${originalGuarder.callerFile} '
'on line ${originalGuarder.callerLine}.'
));
} else {
originalName = '$originalKind (${originalGuarder.className}.${originalGuarder.methodName})';
information.add(ErrorDescription(
'The guarded method "${originalGuarder.methodName}" '
'from class ${originalGuarder.className} '
'was called from ${originalGuarder.callerFile} '
'on line ${originalGuarder.callerLine}.'
));
}
final String again = (originalGuarder.callerFile == collidingGuarder.callerFile) &&
(originalGuarder.callerLine == collidingGuarder.callerLine) ?
'again ' : '';
final String collidingKind = collidingGuarder.className == null ? 'function' : 'method';
String collidingName;
if ((originalGuarder.className == collidingGuarder.className) &&
(originalGuarder.methodName == collidingGuarder.methodName)) {
originalName = originalKind;
collidingName = collidingKind;
information.add(ErrorDescription(
'Then, it '
'was called ${again}from ${collidingGuarder.callerFile} '
'on line ${collidingGuarder.callerLine}.'
));
} else if (collidingGuarder.className == null) {
collidingName = '$collidingKind (${collidingGuarder.methodName})';
information.add(ErrorDescription(
'Then, the "${collidingGuarder.methodName}" function '
'was called ${again}from ${collidingGuarder.callerFile} '
'on line ${collidingGuarder.callerLine}.'
));
} else {
collidingName = '$collidingKind (${collidingGuarder.className}.${collidingGuarder.methodName})';
information.add(ErrorDescription(
'Then, the "${collidingGuarder.methodName}" method '
'${originalGuarder.className == collidingGuarder.className ? "(also from class ${collidingGuarder.className})"
: "from class ${collidingGuarder.className}"} '
'was called ${again}from ${collidingGuarder.callerFile} '
'on line ${collidingGuarder.callerLine}.'
));
}
information.add(ErrorDescription(
'The first $originalName '
'had not yet finished executing at the time that '
'the second $collidingName '
'was called. Since both are guarded, and the second was not a nested call inside the first, the '
'first must complete its execution before the second can be called. Typically, this is achieved by '
'putting an "await" statement in front of the call to the first.'
));
if (collidingGuarder.className == null && collidingGuarder.methodName == 'expect') {
information.add(ErrorHint(
'If you are confident that all test APIs are being called using "await", and '
'this expect() call is not being called at the top level but is itself being '
'called from some sort of callback registered before the ${originalGuarder.methodName} '
'method was called, then consider using expectSync() instead.'
));
}
information.add(DiagnosticsStackTrace(
'\nWhen the first $originalName was called, this was the stack',
scope.creationStack,
));
}
throw FlutterError.fromParts(information);
}
/// Verifies that there are no guarded methods currently pending (see [guard]).
///
/// This is used at the end of tests to ensure that nothing leaks out of the test.
static void verifyAllScopesClosed() {
if (_scopeStack.isNotEmpty) {
final List<DiagnosticsNode> information = <DiagnosticsNode>[
ErrorSummary('Asynchronous call to guarded function leaked.'),
ErrorHint('You must use "await" with all Future-returning test APIs.')
];
for (final _AsyncScope scope in _scopeStack) {
final _StackEntry? guarder = _findResponsibleMethod(scope.creationStack, 'guard', information);
if (guarder != null) {
information.add(ErrorDescription(
'The guarded method "${guarder.methodName}" '
'${guarder.className != null ? "from class ${guarder.className} " : ""}'
'was called from ${guarder.callerFile} '
'on line ${guarder.callerLine}, '
'but never completed before its parent scope closed.'
));
}
}
throw FlutterError.fromParts(information);
}
}
static bool _stripAsynchronousSuspensions(String line) {
return line != '<asynchronous suspension>';
}
static _StackEntry? _findResponsibleMethod(StackTrace rawStack, String method, List<DiagnosticsNode> information) {
assert(method == 'guard' || method == 'guardSync');
final List<String> stack = rawStack.toString().split('\n').where(_stripAsynchronousSuspensions).toList();
assert(stack.last == '');
stack.removeLast();
final RegExp getClassPattern = RegExp(r'^#[0-9]+ +([^. ]+)');
Match? lineMatch;
int index = -1;
do { // skip past frames that are from this class
index += 1;
assert(index < stack.length);
lineMatch = getClassPattern.matchAsPrefix(stack[index])!;
assert(lineMatch != null);
assert(lineMatch.groupCount == 1);
} while (lineMatch.group(1) == _className);
// try to parse the stack to find the interesting frame
if (index < stack.length) {
final RegExp guardPattern = RegExp(r'^#[0-9]+ +(?:([^. ]+)\.)?([^. ]+)');
final Match? guardMatch = guardPattern.matchAsPrefix(stack[index]); // find the class that called us
if (guardMatch != null) {
assert(guardMatch.groupCount == 2);
final String? guardClass = guardMatch.group(1); // might be null
final String? guardMethod = guardMatch.group(2);
while (index < stack.length) { // find the last stack frame that called the class that called us
lineMatch = getClassPattern.matchAsPrefix(stack[index]);
if (lineMatch != null) {
assert(lineMatch.groupCount == 1);
if (lineMatch.group(1) == (guardClass ?? guardMethod)) {
index += 1;
continue;
}
}
break;
}
if (index < stack.length) {
final RegExp callerPattern = RegExp(r'^#[0-9]+ .* \((.+?):([0-9]+)(?::[0-9]+)?\)$');
final Match? callerMatch = callerPattern.matchAsPrefix(stack[index]); // extract the caller's info
if (callerMatch != null) {
assert(callerMatch.groupCount == 2);
final String? callerFile = callerMatch.group(1);
final String? callerLine = callerMatch.group(2);
return _StackEntry(guardClass, guardMethod, callerFile, callerLine);
} else {
// One reason you might get here is if the guarding method was called directly from
// a 'dart:' API, like from the Future/microtask mechanism, because dart: URLs in the
// stack trace don't have a column number and so don't match the regexp above.
information.add(ErrorSummary('(Unable to parse the stack frame of the method that called the method that called $_className.$method(). The stack may be incomplete or bogus.)'));
information.add(ErrorDescription(stack[index]));
}
} else {
information.add(ErrorSummary('(Unable to find the stack frame of the method that called the method that called $_className.$method(). The stack may be incomplete or bogus.)'));
}
} else {
information.add(ErrorSummary('(Unable to parse the stack frame of the method that called $_className.$method(). The stack may be incomplete or bogus.)'));
information.add(ErrorDescription(stack[index]));
}
} else {
information.add(ErrorSummary('(Unable to find the method that called $_className.$method(). The stack may be incomplete or bogus.)'));
}
return null;
}
}
class _StackEntry {
const _StackEntry(this.className, this.methodName, this.callerFile, this.callerLine);
final String? className;
final String? methodName;
final String? callerFile;
final String? callerLine;
}