test_async_utils.dart 16.5 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
// 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
36
/// call [expect] from inside callbacks.
37 38 39 40 41 42
///
/// 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
43
/// Future<void> myTestFunction() => TestAsyncUtils.guard(() async {
44 45 46 47
///   // ...
/// });
/// ```
class TestAsyncUtils {
48
  // This class is not meant to be instantiated or extended; this constructor
49
  // prevents instantiation and extension.
50 51 52
  TestAsyncUtils._();
  static const String _className = 'TestAsyncUtils';

53
  static final List<_AsyncScope> _scopeStack = <_AsyncScope>[];
54

55
  /// Calls the given callback in a new async scope. The callback argument is
56 57 58 59 60 61
  /// 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].
62
  static Future<T> guard<T>(Future<T> Function() body) {
63
    guardSync();
64
    final Zone zone = Zone.current.fork(
65
      zoneValues: <dynamic, dynamic>{
66
        _scopeStack: true, // so we can recognize this as our own zone
67 68
      }
    );
69
    final _AsyncScope scope = _AsyncScope(StackTrace.current, zone);
70
    _scopeStack.add(scope);
71
    final Future<T> result = scope.zone.run<Future<T>>(body);
72 73
    late T resultValue; // This is set when the body of work completes with a result value.
    Future<T> completionHandler(dynamic error, StackTrace? stack) {
74 75 76 77
      assert(_scopeStack.isNotEmpty);
      assert(_scopeStack.contains(scope));
      bool leaked = false;
      _AsyncScope closedScope;
78
      final List<DiagnosticsNode> information = <DiagnosticsNode>[];
79 80
      while (_scopeStack.isNotEmpty) {
        closedScope = _scopeStack.removeLast();
81
        if (closedScope == scope) {
82
          break;
83
        }
84
        if (!leaked) {
85 86
          information.add(ErrorSummary('Asynchronous call to guarded function leaked.'));
          information.add(ErrorHint('You must use "await" with all Future-returning test APIs.'));
87 88
          leaked = true;
        }
89
        final _StackEntry? originalGuarder = _findResponsibleMethod(closedScope.creationStack, 'guard', information);
90
        if (originalGuarder != null) {
91
          information.add(ErrorDescription(
92 93 94 95 96
            '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.'
97
          ));
98 99
        }
      }
100 101
      if (leaked) {
        if (error != null) {
102 103 104 105 106 107
          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));
108
        }
109
        throw FlutterError.fromParts(information);
110
      }
111
      if (error != null) {
112
        return Future<T>.error(error! as Object, stack);
113
      }
114
      return Future<T>.value(resultValue);
115
    }
116 117 118
    return result.then<T>(
      (T value) {
        resultValue = value;
119
        return completionHandler(null, null);
120
      },
121
      onError: completionHandler,
122
    );
123 124
  }

125 126
  static Zone? get _currentScopeZone {
    Zone? zone = Zone.current;
127
    while (zone != null) {
128
      if (zone[_scopeStack] == true) {
129
        return zone;
130
      }
131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146
      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.
147
    final Zone? zone = _currentScopeZone;
148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178
    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;
179
      if (skipCount >= _scopeStack.length) {
180
        if (zone == null) {
181
          break;
182
        }
183 184 185 186 187 188 189 190
        // 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];
    } while (candidateScope.zone != zone);
191 192 193 194
    final List<DiagnosticsNode> information = <DiagnosticsNode>[
      ErrorSummary('Guarded function conflict.'),
      ErrorHint('You must use "await" with all Future-returning test APIs.'),
    ];
195 196
    final _StackEntry? originalGuarder = _findResponsibleMethod(scope.creationStack, 'guard', information);
    final _StackEntry? collidingGuarder = _findResponsibleMethod(StackTrace.current, 'guardSync', information);
197
    if (originalGuarder != null && collidingGuarder != null) {
198
      final String originalKind = originalGuarder.className == null ? 'function' : 'method';
199 200
      String originalName;
      if (originalGuarder.className == null) {
201
        originalName = '$originalKind (${originalGuarder.methodName})';
202
        information.add(ErrorDescription(
203 204 205
          'The guarded "${originalGuarder.methodName}" function '
          'was called from ${originalGuarder.callerFile} '
          'on line ${originalGuarder.callerLine}.'
206
        ));
207
      } else {
208
        originalName = '$originalKind (${originalGuarder.className}.${originalGuarder.methodName})';
209
        information.add(ErrorDescription(
210 211 212 213
          'The guarded method "${originalGuarder.methodName}" '
          'from class ${originalGuarder.className} '
          'was called from ${originalGuarder.callerFile} '
          'on line ${originalGuarder.callerLine}.'
214
        ));
215 216 217 218
      }
      final String again = (originalGuarder.callerFile == collidingGuarder.callerFile) &&
                           (originalGuarder.callerLine == collidingGuarder.callerLine) ?
                           'again ' : '';
219
      final String collidingKind = collidingGuarder.className == null ? 'function' : 'method';
220 221 222
      String collidingName;
      if ((originalGuarder.className == collidingGuarder.className) &&
          (originalGuarder.methodName == collidingGuarder.methodName)) {
223 224
        originalName = originalKind;
        collidingName = collidingKind;
225
        information.add(ErrorDescription(
226 227 228
          'Then, it '
          'was called ${again}from ${collidingGuarder.callerFile} '
          'on line ${collidingGuarder.callerLine}.'
229
        ));
230
      } else if (collidingGuarder.className == null) {
231
        collidingName = '$collidingKind (${collidingGuarder.methodName})';
232
        information.add(ErrorDescription(
233 234 235
          'Then, the "${collidingGuarder.methodName}" function '
          'was called ${again}from ${collidingGuarder.callerFile} '
          'on line ${collidingGuarder.callerLine}.'
236
        ));
237
      } else {
238
        collidingName = '$collidingKind (${collidingGuarder.className}.${collidingGuarder.methodName})';
239
        information.add(ErrorDescription(
240 241 242 243 244
          '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}.'
245
        ));
246
      }
247
      information.add(ErrorDescription(
248
        'The first $originalName '
249
        'had not yet finished executing at the time that '
250
        'the second $collidingName '
251 252 253
        '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.'
254
      ));
255
      if (collidingGuarder.className == null && collidingGuarder.methodName == 'expect') {
256
        information.add(ErrorHint(
257
          'If you are confident that all test APIs are being called using "await", and '
258
          'this expect() call is not being called at the top level but is itself being '
259 260
          'called from some sort of callback registered before the ${originalGuarder.methodName} '
          'method was called, then consider using expectSync() instead.'
261
        ));
262
      }
263
      information.add(DiagnosticsStackTrace(
264
        '\nWhen the first $originalName was called, this was the stack',
265 266
        scope.creationStack,
      ));
267 268 269 270 271
    } else {
      information.add(DiagnosticsStackTrace(
        '\nWhen the first function was called, this was the stack',
        scope.creationStack,
      ));
272
    }
273
    throw FlutterError.fromParts(information);
274 275 276 277 278 279 280
  }

  /// 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) {
281 282
      final List<DiagnosticsNode> information = <DiagnosticsNode>[
        ErrorSummary('Asynchronous call to guarded function leaked.'),
283
        ErrorHint('You must use "await" with all Future-returning test APIs.'),
284
      ];
285
      for (final _AsyncScope scope in _scopeStack) {
286
        final _StackEntry? guarder = _findResponsibleMethod(scope.creationStack, 'guard', information);
287
        if (guarder != null) {
288
          information.add(ErrorDescription(
289 290 291 292 293
            '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.'
294
          ));
295 296
        }
      }
297
      throw FlutterError.fromParts(information);
298 299 300
    }
  }

301 302 303 304
  static bool _stripAsynchronousSuspensions(String line) {
    return line != '<asynchronous suspension>';
  }

305
  static _StackEntry? _findResponsibleMethod(StackTrace rawStack, String method, List<DiagnosticsNode> information) {
306
    assert(method == 'guard' || method == 'guardSync');
307 308 309 310
    // Web/JavaScript stack traces use a different format.
    if (kIsWeb) {
      return null;
    }
311
    final List<String> stack = rawStack.toString().split('\n').where(_stripAsynchronousSuspensions).toList();
312 313
    assert(stack.last == '');
    stack.removeLast();
314
    final RegExp getClassPattern = RegExp(r'^#[0-9]+ +([^. ]+)');
315
    Match? lineMatch;
316 317 318 319
    int index = -1;
    do { // skip past frames that are from this class
      index += 1;
      assert(index < stack.length);
320
      lineMatch = getClassPattern.matchAsPrefix(stack[index]);
321
      assert(lineMatch != null);
322
      lineMatch = lineMatch!;
323 324 325 326
      assert(lineMatch.groupCount == 1);
    } while (lineMatch.group(1) == _className);
    // try to parse the stack to find the interesting frame
    if (index < stack.length) {
327
      final RegExp guardPattern = RegExp(r'^#[0-9]+ +(?:([^. ]+)\.)?([^. ]+)');
328
      final Match? guardMatch = guardPattern.matchAsPrefix(stack[index]); // find the class that called us
329 330
      if (guardMatch != null) {
        assert(guardMatch.groupCount == 2);
331 332
        final String? guardClass = guardMatch.group(1); // might be null
        final String? guardMethod = guardMatch.group(2);
333 334 335 336 337 338 339 340 341 342 343 344
        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) {
345
          final RegExp callerPattern = RegExp(r'^#[0-9]+ .* \((.+?):([0-9]+)(?::[0-9]+)?\)$');
346
          final Match? callerMatch = callerPattern.matchAsPrefix(stack[index]); // extract the caller's info
347 348
          if (callerMatch != null) {
            assert(callerMatch.groupCount == 2);
349 350
            final String? callerFile = callerMatch.group(1);
            final String? callerLine = callerMatch.group(2);
351
            return _StackEntry(guardClass, guardMethod, callerFile, callerLine);
352 353 354 355
          } 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.
356
            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.)'));
357
            information.add(ErrorDescription(stack[index]));
358 359
          }
        } else {
360
          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.)'));
361 362
        }
      } else {
363
        information.add(ErrorSummary('(Unable to parse the stack frame of the method that called $_className.$method(). The stack may be incomplete or bogus.)'));
364
        information.add(ErrorDescription(stack[index]));
365 366
      }
    } else {
367
      information.add(ErrorSummary('(Unable to find the method that called $_className.$method(). The stack may be incomplete or bogus.)'));
368 369 370 371 372 373 374
    }
    return null;
  }
}

class _StackEntry {
  const _StackEntry(this.className, this.methodName, this.callerFile, this.callerLine);
375 376 377 378
  final String? className;
  final String? methodName;
  final String? callerFile;
  final String? callerLine;
379
}