1// Copyright 2016 The Chromium Authors. All rights reserved. 2// Use of this source code is governed by a BSD-style license that can be 3// found in the LICENSE file. 4 5import 'dart:async'; 6 7import 'package:flutter/foundation.dart'; 8 9class _AsyncScope { 10 _AsyncScope(this.creationStack, this.zone); 11 final StackTrace creationStack; 12 final Zone zone; 13} 14 15/// Utility class for all the async APIs in the `flutter_test` library. 16/// 17/// This class provides checking for asynchronous APIs, allowing the library to 18/// verify that all the asynchronous APIs are properly `await`ed before calling 19/// another. 20/// 21/// For example, it prevents this kind of code: 22/// 23/// ```dart 24/// tester.pump(); // forgot to call "await"! 25/// tester.pump(); 26/// ``` 27/// 28/// ...by detecting, in the second call to `pump`, that it should actually be: 29/// 30/// ```dart 31/// await tester.pump(); 32/// await tester.pump(); 33/// ``` 34/// 35/// It does this while still allowing nested calls, e.g. so that you can 36/// call [expect] from inside callbacks. 37/// 38/// You can use this in your own test functions, if you have some asynchronous 39/// functions that must be used with "await". Wrap the contents of the function 40/// in a call to TestAsyncUtils.guard(), as follows: 41/// 42/// ```dart 43/// Future<void> myTestFunction() => TestAsyncUtils.guard(() async { 44/// // ... 45/// }); 46/// ``` 47class TestAsyncUtils { 48 TestAsyncUtils._(); 49 static const String _className = 'TestAsyncUtils'; 50 51 static final List<_AsyncScope> _scopeStack = <_AsyncScope>[]; 52 53 /// Calls the given callback in a new async scope. The callback argument is 54 /// the asynchronous body of the calling method. The calling method is said to 55 /// be "guarded". Nested calls to guarded methods from within the body of this 56 /// one are fine, but calls to other guarded methods from outside the body of 57 /// this one before this one has finished will throw an exception. 58 /// 59 /// This method first calls [guardSync]. 60 static Future<T> guard<T>(Future<T> body()) { 61 guardSync(); 62 final Zone zone = Zone.current.fork( 63 zoneValues: <dynamic, dynamic>{ 64 _scopeStack: true, // so we can recognize this as our own zone 65 } 66 ); 67 final _AsyncScope scope = _AsyncScope(StackTrace.current, zone); 68 _scopeStack.add(scope); 69 final Future<T> result = scope.zone.run<Future<T>>(body); 70 T resultValue; // This is set when the body of work completes with a result value. 71 Future<T> completionHandler(dynamic error, StackTrace stack) { 72 assert(_scopeStack.isNotEmpty); 73 assert(_scopeStack.contains(scope)); 74 bool leaked = false; 75 _AsyncScope closedScope; 76 final List<DiagnosticsNode> information = <DiagnosticsNode>[]; 77 while (_scopeStack.isNotEmpty) { 78 closedScope = _scopeStack.removeLast(); 79 if (closedScope == scope) 80 break; 81 if (!leaked) { 82 information.add(ErrorSummary('Asynchronous call to guarded function leaked.')); 83 information.add(ErrorHint('You must use "await" with all Future-returning test APIs.')); 84 leaked = true; 85 } 86 final _StackEntry originalGuarder = _findResponsibleMethod(closedScope.creationStack, 'guard', information); 87 if (originalGuarder != null) { 88 information.add(ErrorDescription( 89 'The test API method "${originalGuarder.methodName}" ' 90 'from class ${originalGuarder.className} ' 91 'was called from ${originalGuarder.callerFile} ' 92 'on line ${originalGuarder.callerLine}, ' 93 'but never completed before its parent scope closed.' 94 )); 95 } 96 } 97 if (leaked) { 98 if (error != null) { 99 information.add(DiagnosticsProperty<dynamic>( 100 'An uncaught exception may have caused the guarded function leak. The exception was', 101 error, 102 style: DiagnosticsTreeStyle.errorProperty, 103 )); 104 information.add(DiagnosticsStackTrace('The stack trace associated with this exception was', stack)); 105 } 106 throw FlutterError.fromParts(information); 107 } 108 if (error != null) 109 return Future<T>.error(error, stack); 110 return Future<T>.value(resultValue); 111 } 112 return result.then<T>( 113 (T value) { 114 resultValue = value; 115 return completionHandler(null, null); 116 }, 117 onError: completionHandler, 118 ); 119 } 120 121 static Zone get _currentScopeZone { 122 Zone zone = Zone.current; 123 while (zone != null) { 124 if (zone[_scopeStack] == true) 125 return zone; 126 zone = zone.parent; 127 } 128 return null; 129 } 130 131 /// Verifies that there are no guarded methods currently pending (see [guard]). 132 /// 133 /// If a guarded method is currently pending, and this is not a call nested 134 /// from inside that method's body (directly or indirectly), then this method 135 /// will throw a detailed exception. 136 static void guardSync() { 137 if (_scopeStack.isEmpty) { 138 // No scopes open, so we must be fine. 139 return; 140 } 141 // Find the current TestAsyncUtils scope zone so we can see if it's the one we expect. 142 final Zone zone = _currentScopeZone; 143 if (zone == _scopeStack.last.zone) { 144 // We're still in the current scope zone. All good. 145 return; 146 } 147 // If we get here, we know we've got a conflict on our hands. 148 // We got an async barrier, but the current zone isn't the last scope that 149 // we pushed on the stack. 150 // Find which scope the conflict happened in, so that we know 151 // which stack trace to report the conflict as starting from. 152 // 153 // For example, if we called an async method A, which ran its body in a 154 // guarded block, and in its body it ran an async method B, which ran its 155 // body in a guarded block, but we didn't await B, then in A's block we ran 156 // an async method C, which ran its body in a guarded block, then we should 157 // complain about the call to B then the call to C. BUT. If we called an async 158 // method A, which ran its body in a guarded block, and in its body it ran 159 // an async method B, which ran its body in a guarded block, but we didn't 160 // await A, and then at the top level we called a method D, then we should 161 // complain about the call to A then the call to D. 162 // 163 // In both examples, the scope stack would have two scopes. In the first 164 // example, the current zone would be the zone of the _scopeStack[0] scope, 165 // and we would want to show _scopeStack[1]'s creationStack. In the second 166 // example, the current zone would not be in the _scopeStack, and we would 167 // want to show _scopeStack[0]'s creationStack. 168 int skipCount = 0; 169 _AsyncScope candidateScope = _scopeStack.last; 170 _AsyncScope scope; 171 do { 172 skipCount += 1; 173 scope = candidateScope; 174 if (skipCount >= _scopeStack.length) { 175 if (zone == null) 176 break; 177 // Some people have reported reaching this point, but it's not clear 178 // why. For now, just silently return. 179 // TODO(ianh): If we ever get a test case that shows how we reach 180 // this point, reduce it and report the error if there is one. 181 return; 182 } 183 candidateScope = _scopeStack[_scopeStack.length - skipCount - 1]; 184 assert(candidateScope != null); 185 assert(candidateScope.zone != null); 186 } while (candidateScope.zone != zone); 187 assert(scope != null); 188 final List<DiagnosticsNode> information = <DiagnosticsNode>[]; 189 information.add(ErrorSummary('Guarded function conflict.')); 190 information.add(ErrorHint('You must use "await" with all Future-returning test APIs.')); 191 final _StackEntry originalGuarder = _findResponsibleMethod(scope.creationStack, 'guard', information); 192 final _StackEntry collidingGuarder = _findResponsibleMethod(StackTrace.current, 'guardSync', information); 193 if (originalGuarder != null && collidingGuarder != null) { 194 String originalName; 195 if (originalGuarder.className == null) { 196 originalName = '(${originalGuarder.methodName}) '; 197 information.add(ErrorDescription( 198 'The guarded "${originalGuarder.methodName}" function ' 199 'was called from ${originalGuarder.callerFile} ' 200 'on line ${originalGuarder.callerLine}.' 201 )); 202 } else { 203 originalName = '(${originalGuarder.className}.${originalGuarder.methodName}) '; 204 information.add(ErrorDescription( 205 'The guarded method "${originalGuarder.methodName}" ' 206 'from class ${originalGuarder.className} ' 207 'was called from ${originalGuarder.callerFile} ' 208 'on line ${originalGuarder.callerLine}.' 209 )); 210 } 211 final String again = (originalGuarder.callerFile == collidingGuarder.callerFile) && 212 (originalGuarder.callerLine == collidingGuarder.callerLine) ? 213 'again ' : ''; 214 String collidingName; 215 if ((originalGuarder.className == collidingGuarder.className) && 216 (originalGuarder.methodName == collidingGuarder.methodName)) { 217 originalName = ''; 218 collidingName = ''; 219 information.add(ErrorDescription( 220 'Then, it ' 221 'was called ${again}from ${collidingGuarder.callerFile} ' 222 'on line ${collidingGuarder.callerLine}.' 223 )); 224 } else if (collidingGuarder.className == null) { 225 collidingName = '(${collidingGuarder.methodName}) '; 226 information.add(ErrorDescription( 227 'Then, the "${collidingGuarder.methodName}" function ' 228 'was called ${again}from ${collidingGuarder.callerFile} ' 229 'on line ${collidingGuarder.callerLine}.' 230 )); 231 } else { 232 collidingName = '(${collidingGuarder.className}.${collidingGuarder.methodName}) '; 233 information.add(ErrorDescription( 234 'Then, the "${collidingGuarder.methodName}" method ' 235 '${originalGuarder.className == collidingGuarder.className ? "(also from class ${collidingGuarder.className})" 236 : "from class ${collidingGuarder.className}"} ' 237 'was called ${again}from ${collidingGuarder.callerFile} ' 238 'on line ${collidingGuarder.callerLine}.' 239 )); 240 } 241 information.add(ErrorDescription( 242 'The first ${originalGuarder.className == null ? "function" : "method"} $originalName' 243 'had not yet finished executing at the time that ' 244 'the second ${collidingGuarder.className == null ? "function" : "method"} $collidingName' 245 'was called. Since both are guarded, and the second was not a nested call inside the first, the ' 246 'first must complete its execution before the second can be called. Typically, this is achieved by ' 247 'putting an "await" statement in front of the call to the first.' 248 )); 249 if (collidingGuarder.className == null && collidingGuarder.methodName == 'expect') { 250 information.add(ErrorHint( 251 'If you are confident that all test APIs are being called using "await", and ' 252 'this expect() call is not being called at the top level but is itself being ' 253 'called from some sort of callback registered before the ${originalGuarder.methodName} ' 254 'method was called, then consider using expectSync() instead.' 255 )); 256 } 257 information.add(DiagnosticsStackTrace( 258 '\nWhen the first ${originalGuarder.className == null ? "function" : "method"} ' 259 '$originalName' 260 'was called, this was the stack', 261 scope.creationStack, 262 )); 263 } 264 throw FlutterError.fromParts(information); 265 } 266 267 /// Verifies that there are no guarded methods currently pending (see [guard]). 268 /// 269 /// This is used at the end of tests to ensure that nothing leaks out of the test. 270 static void verifyAllScopesClosed() { 271 if (_scopeStack.isNotEmpty) { 272 final List<DiagnosticsNode> information = <DiagnosticsNode>[ 273 ErrorSummary('Asynchronous call to guarded function leaked.'), 274 ErrorHint('You must use "await" with all Future-returning test APIs.') 275 ]; 276 for (_AsyncScope scope in _scopeStack) { 277 final _StackEntry guarder = _findResponsibleMethod(scope.creationStack, 'guard', information); 278 if (guarder != null) { 279 information.add(ErrorDescription( 280 'The guarded method "${guarder.methodName}" ' 281 '${guarder.className != null ? "from class ${guarder.className} " : ""}' 282 'was called from ${guarder.callerFile} ' 283 'on line ${guarder.callerLine}, ' 284 'but never completed before its parent scope closed.' 285 )); 286 } 287 } 288 throw FlutterError.fromParts(information); 289 } 290 } 291 292 static bool _stripAsynchronousSuspensions(String line) { 293 return line != '<asynchronous suspension>'; 294 } 295 296 static _StackEntry _findResponsibleMethod(StackTrace rawStack, String method, List<DiagnosticsNode> information) { 297 assert(method == 'guard' || method == 'guardSync'); 298 final List<String> stack = rawStack.toString().split('\n').where(_stripAsynchronousSuspensions).toList(); 299 assert(stack.last == ''); 300 stack.removeLast(); 301 final RegExp getClassPattern = RegExp(r'^#[0-9]+ +([^. ]+)'); 302 Match lineMatch; 303 int index = -1; 304 do { // skip past frames that are from this class 305 index += 1; 306 assert(index < stack.length); 307 lineMatch = getClassPattern.matchAsPrefix(stack[index]); 308 assert(lineMatch != null); 309 assert(lineMatch.groupCount == 1); 310 } while (lineMatch.group(1) == _className); 311 // try to parse the stack to find the interesting frame 312 if (index < stack.length) { 313 final RegExp guardPattern = RegExp(r'^#[0-9]+ +(?:([^. ]+)\.)?([^. ]+)'); 314 final Match guardMatch = guardPattern.matchAsPrefix(stack[index]); // find the class that called us 315 if (guardMatch != null) { 316 assert(guardMatch.groupCount == 2); 317 final String guardClass = guardMatch.group(1); // might be null 318 final String guardMethod = guardMatch.group(2); 319 while (index < stack.length) { // find the last stack frame that called the class that called us 320 lineMatch = getClassPattern.matchAsPrefix(stack[index]); 321 if (lineMatch != null) { 322 assert(lineMatch.groupCount == 1); 323 if (lineMatch.group(1) == (guardClass ?? guardMethod)) { 324 index += 1; 325 continue; 326 } 327 } 328 break; 329 } 330 if (index < stack.length) { 331 final RegExp callerPattern = RegExp(r'^#[0-9]+ .* \((.+?):([0-9]+)(?::[0-9]+)?\)$'); 332 final Match callerMatch = callerPattern.matchAsPrefix(stack[index]); // extract the caller's info 333 if (callerMatch != null) { 334 assert(callerMatch.groupCount == 2); 335 final String callerFile = callerMatch.group(1); 336 final String callerLine = callerMatch.group(2); 337 return _StackEntry(guardClass, guardMethod, callerFile, callerLine); 338 } else { 339 // One reason you might get here is if the guarding method was called directly from 340 // a 'dart:' API, like from the Future/microtask mechanism, because dart: URLs in the 341 // stack trace don't have a column number and so don't match the regexp above. 342 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.)')); 343 information.add(ErrorDescription('${stack[index]}')); 344 } 345 } else { 346 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.)')); 347 } 348 } else { 349 information.add(ErrorSummary('(Unable to parse the stack frame of the method that called $_className.$method(). The stack may be incomplete or bogus.)')); 350 information.add(ErrorDescription('${stack[index]}')); 351 } 352 } else { 353 information.add(ErrorSummary('(Unable to find the method that called $_className.$method(). The stack may be incomplete or bogus.)')); 354 } 355 return null; 356 } 357} 358 359class _StackEntry { 360 const _StackEntry(this.className, this.methodName, this.callerFile, this.callerLine); 361 final String className; 362 final String methodName; 363 final String callerFile; 364 final String callerLine; 365} 366