OLD | NEW |
(Empty) | |
| 1 // Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file |
| 2 // for details. All rights reserved. Use of this source code is governed by a |
| 3 // BSD-style license that can be found in the LICENSE file. |
| 4 |
| 5 library test.utils; |
| 6 |
| 7 import 'dart:async'; |
| 8 import 'dart:convert'; |
| 9 import 'dart:math' as math; |
| 10 |
| 11 import 'package:crypto/crypto.dart'; |
| 12 import 'package:path/path.dart' as p; |
| 13 import 'package:shelf/shelf.dart' as shelf; |
| 14 import 'package:stack_trace/stack_trace.dart'; |
| 15 |
| 16 import 'backend/operating_system.dart'; |
| 17 import 'util/cancelable_future.dart'; |
| 18 import 'util/path_handler.dart'; |
| 19 import 'util/stream_queue.dart'; |
| 20 |
| 21 /// The maximum console line length. |
| 22 const _lineLength = 100; |
| 23 |
| 24 /// A typedef for a possibly-asynchronous function. |
| 25 /// |
| 26 /// The return type should only ever by [Future] or void. |
| 27 typedef AsyncFunction(); |
| 28 |
| 29 /// A typedef for a zero-argument callback function. |
| 30 typedef void Callback(); |
| 31 |
| 32 /// A converter that decodes bytes using UTF-8 and splits them on newlines. |
| 33 final lineSplitter = UTF8.decoder.fuse(const LineSplitter()); |
| 34 |
| 35 /// A regular expression to match the exception prefix that some exceptions' |
| 36 /// [Object.toString] values contain. |
| 37 final _exceptionPrefix = new RegExp(r'^([A-Z][a-zA-Z]*)?(Exception|Error): '); |
| 38 |
| 39 /// Directories that are specific to OS X. |
| 40 /// |
| 41 /// This is used to try to distinguish OS X and Linux in [currentOSGuess]. |
| 42 final _macOSDirectories = new Set<String>.from([ |
| 43 "/Applications", |
| 44 "/Library", |
| 45 "/Network", |
| 46 "/System", |
| 47 "/Users" |
| 48 ]); |
| 49 |
| 50 /// Returns the best guess for the current operating system without using |
| 51 /// `dart:io`. |
| 52 /// |
| 53 /// This is useful for running test files directly and skipping tests as |
| 54 /// appropriate. The only OS-specific information we have is the current path, |
| 55 /// which we try to use to figure out the OS. |
| 56 final OperatingSystem currentOSGuess = (() { |
| 57 if (p.style == p.Style.url) return OperatingSystem.none; |
| 58 if (p.style == p.Style.windows) return OperatingSystem.windows; |
| 59 if (_macOSDirectories.any(p.current.startsWith)) return OperatingSystem.macOS; |
| 60 return OperatingSystem.linux; |
| 61 })(); |
| 62 |
| 63 /// A pair of values. |
| 64 class Pair<E, F> { |
| 65 E first; |
| 66 F last; |
| 67 |
| 68 Pair(this.first, this.last); |
| 69 |
| 70 String toString() => '($first, $last)'; |
| 71 |
| 72 bool operator==(other) { |
| 73 if (other is! Pair) return false; |
| 74 return other.first == first && other.last == last; |
| 75 } |
| 76 |
| 77 int get hashCode => first.hashCode ^ last.hashCode; |
| 78 } |
| 79 |
| 80 /// Get a string description of an exception. |
| 81 /// |
| 82 /// Many exceptions include the exception class name at the beginning of their |
| 83 /// [toString], so we remove that if it exists. |
| 84 String getErrorMessage(error) => |
| 85 error.toString().replaceFirst(_exceptionPrefix, ''); |
| 86 |
| 87 /// Indent each line in [str] by two spaces. |
| 88 String indent(String str) => |
| 89 str.replaceAll(new RegExp("^", multiLine: true), " "); |
| 90 |
| 91 /// Returns a sentence fragment listing the elements of [iter]. |
| 92 /// |
| 93 /// This converts each element of [iter] to a string and separates them with |
| 94 /// commas and/or "and" where appropriate. |
| 95 String toSentence(Iterable iter) { |
| 96 if (iter.length == 1) return iter.first.toString(); |
| 97 var result = iter.take(iter.length - 1).join(", "); |
| 98 if (iter.length > 2) result += ","; |
| 99 return "$result and ${iter.last}"; |
| 100 } |
| 101 |
| 102 /// Wraps [text] so that it fits within [lineLength], which defaults to 100 |
| 103 /// characters. |
| 104 /// |
| 105 /// This preserves existing newlines and doesn't consider terminal color escapes |
| 106 /// part of a word's length. |
| 107 String wordWrap(String text, {int lineLength}) { |
| 108 if (lineLength == null) lineLength = _lineLength; |
| 109 return text.split("\n").map((originalLine) { |
| 110 var buffer = new StringBuffer(); |
| 111 var lengthSoFar = 0; |
| 112 for (var word in originalLine.split(" ")) { |
| 113 var wordLength = withoutColors(word).length; |
| 114 if (wordLength > lineLength) { |
| 115 if (lengthSoFar != 0) buffer.writeln(); |
| 116 buffer.writeln(word); |
| 117 } else if (lengthSoFar == 0) { |
| 118 buffer.write(word); |
| 119 lengthSoFar = wordLength; |
| 120 } else if (lengthSoFar + 1 + wordLength > lineLength) { |
| 121 buffer.writeln(); |
| 122 buffer.write(word); |
| 123 lengthSoFar = wordLength; |
| 124 } else { |
| 125 buffer.write(" $word"); |
| 126 lengthSoFar += 1 + wordLength; |
| 127 } |
| 128 } |
| 129 return buffer.toString(); |
| 130 }).join("\n"); |
| 131 } |
| 132 |
| 133 /// A regular expression matching terminal color codes. |
| 134 final _colorCode = new RegExp('\u001b\\[[0-9;]+m'); |
| 135 |
| 136 /// Returns [str] without any color codes. |
| 137 String withoutColors(String str) => str.replaceAll(_colorCode, ''); |
| 138 |
| 139 /// A regular expression matching the path to a temporary file used to start an |
| 140 /// isolate. |
| 141 /// |
| 142 /// These paths aren't relevant and are removed from stack traces. |
| 143 final _isolatePath = |
| 144 new RegExp(r"/test_[A-Za-z0-9]{6}/runInIsolate\.dart$"); |
| 145 |
| 146 /// Returns [stackTrace] converted to a [Chain] with all irrelevant frames |
| 147 /// folded together. |
| 148 /// |
| 149 /// If [verbose] is `true`, returns the chain for [stackTrace] unmodified. |
| 150 Chain terseChain(StackTrace stackTrace, {bool verbose: false}) { |
| 151 if (verbose) return new Chain.forTrace(stackTrace); |
| 152 return new Chain.forTrace(stackTrace).foldFrames((frame) { |
| 153 if (frame.package == 'test') return true; |
| 154 |
| 155 // Filter out frames from our isolate bootstrap as well. |
| 156 if (frame.uri.scheme != 'file') return false; |
| 157 return frame.uri.path.contains(_isolatePath); |
| 158 }, terse: true); |
| 159 } |
| 160 |
| 161 /// Flattens nested [Iterable]s inside an [Iterable] into a single [List] |
| 162 /// containing only non-[Iterable] elements. |
| 163 List flatten(Iterable nested) { |
| 164 var result = []; |
| 165 helper(iter) { |
| 166 for (var element in iter) { |
| 167 if (element is Iterable) { |
| 168 helper(element); |
| 169 } else { |
| 170 result.add(element); |
| 171 } |
| 172 } |
| 173 } |
| 174 helper(nested); |
| 175 return result; |
| 176 } |
| 177 |
| 178 /// Returns a new map with all values in both [map1] and [map2]. |
| 179 /// |
| 180 /// If there are conflicting keys, [map2]'s value wins. |
| 181 Map mergeMaps(Map map1, Map map2) { |
| 182 var result = {}; |
| 183 map1.forEach((key, value) { |
| 184 result[key] = value; |
| 185 }); |
| 186 map2.forEach((key, value) { |
| 187 result[key] = value; |
| 188 }); |
| 189 return result; |
| 190 } |
| 191 |
| 192 /// Returns a sink that maps events sent to [original] using [fn]. |
| 193 StreamSink mapSink(StreamSink original, fn(event)) { |
| 194 var controller = new StreamController(sync: true); |
| 195 controller.stream.listen( |
| 196 (event) => original.add(fn(event)), |
| 197 onError: (error, stackTrace) => original.addError(error, stackTrace), |
| 198 onDone: () => original.close()); |
| 199 return controller.sink; |
| 200 } |
| 201 |
| 202 /// Like [runZoned], but [zoneValues] are set for the callbacks in |
| 203 /// [zoneSpecification] and [onError]. |
| 204 runZonedWithValues(body(), {Map zoneValues, |
| 205 ZoneSpecification zoneSpecification, Function onError}) { |
| 206 return runZoned(() { |
| 207 return runZoned(body, |
| 208 zoneSpecification: zoneSpecification, onError: onError); |
| 209 }, zoneValues: zoneValues); |
| 210 } |
| 211 |
| 212 /// Truncates [text] to fit within [maxLength]. |
| 213 /// |
| 214 /// This will try to truncate along word boundaries and preserve words both at |
| 215 /// the beginning and the end of [text]. |
| 216 String truncate(String text, int maxLength) { |
| 217 // Return the full message if it fits. |
| 218 if (text.length <= maxLength) return text; |
| 219 |
| 220 // If we can fit the first and last three words, do so. |
| 221 var words = text.split(' '); |
| 222 if (words.length > 1) { |
| 223 var i = words.length; |
| 224 var length = words.first.length + 4; |
| 225 do { |
| 226 i--; |
| 227 length += 1 + words[i].length; |
| 228 } while (length <= maxLength && i > 0); |
| 229 if (length > maxLength || i == 0) i++; |
| 230 if (i < words.length - 4) { |
| 231 // Require at least 3 words at the end. |
| 232 var buffer = new StringBuffer(); |
| 233 buffer.write(words.first); |
| 234 buffer.write(' ...'); |
| 235 for ( ; i < words.length; i++) { |
| 236 buffer.write(' '); |
| 237 buffer.write(words[i]); |
| 238 } |
| 239 return buffer.toString(); |
| 240 } |
| 241 } |
| 242 |
| 243 // Otherwise truncate to return the trailing text, but attempt to start at |
| 244 // the beginning of a word. |
| 245 var result = text.substring(text.length - maxLength + 4); |
| 246 var firstSpace = result.indexOf(' '); |
| 247 if (firstSpace > 0) { |
| 248 result = result.substring(firstSpace); |
| 249 } |
| 250 return '...$result'; |
| 251 } |
| 252 |
| 253 /// Returns a human-friendly representation of [duration]. |
| 254 String niceDuration(Duration duration) { |
| 255 var minutes = duration.inMinutes; |
| 256 var seconds = duration.inSeconds % 59; |
| 257 var decaseconds = (duration.inMilliseconds % 1000) ~/ 100; |
| 258 |
| 259 var buffer = new StringBuffer(); |
| 260 if (minutes != 0) buffer.write("$minutes minutes"); |
| 261 |
| 262 if (minutes == 0 || seconds != 0) { |
| 263 if (minutes != 0) buffer.write(", "); |
| 264 buffer.write(seconds); |
| 265 if (decaseconds != 0) buffer.write(".$decaseconds"); |
| 266 buffer.write(" seconds"); |
| 267 } |
| 268 |
| 269 return buffer.toString(); |
| 270 } |
| 271 |
| 272 /// Merges [streams] into a single stream that emits events from all sources. |
| 273 Stream mergeStreams(Iterable<Stream> streamIter) { |
| 274 var streams = streamIter.toList(); |
| 275 |
| 276 var subscriptions = new Set(); |
| 277 var controller; |
| 278 controller = new StreamController(sync: true, onListen: () { |
| 279 for (var stream in streams) { |
| 280 var subscription; |
| 281 subscription = stream.listen( |
| 282 controller.add, |
| 283 onError: controller.addError, |
| 284 onDone: () { |
| 285 subscriptions.remove(subscription); |
| 286 if (subscriptions.isEmpty) controller.close(); |
| 287 }); |
| 288 subscriptions.add(subscription); |
| 289 } |
| 290 }, onPause: () { |
| 291 for (var subscription in subscriptions) { |
| 292 subscription.pause(); |
| 293 } |
| 294 }, onResume: () { |
| 295 for (var subscription in subscriptions) { |
| 296 subscription.resume(); |
| 297 } |
| 298 }, onCancel: () { |
| 299 for (var subscription in subscriptions) { |
| 300 subscription.cancel(); |
| 301 } |
| 302 }); |
| 303 |
| 304 return controller.stream; |
| 305 } |
| 306 |
| 307 /// Returns the first value [stream] emits, or `null` if [stream] closes before |
| 308 /// emitting a value. |
| 309 Future maybeFirst(Stream stream) { |
| 310 var completer = new Completer(); |
| 311 |
| 312 var subscription; |
| 313 subscription = stream.listen((data) { |
| 314 completer.complete(data); |
| 315 subscription.cancel(); |
| 316 }, onError: (error, stackTrace) { |
| 317 completer.completeError(error, stackTrace); |
| 318 subscription.cancel(); |
| 319 }, onDone: () { |
| 320 completer.complete(); |
| 321 }); |
| 322 |
| 323 return completer.future; |
| 324 } |
| 325 |
| 326 /// Returns a [CancelableFuture] that returns the next value of [queue] unless |
| 327 /// it's canceled. |
| 328 /// |
| 329 /// If the future is canceled, [queue] is not moved forward at all. Note that |
| 330 /// it's not safe to call further methods on [queue] until this future has |
| 331 /// either completed or been canceled. |
| 332 CancelableFuture cancelableNext(StreamQueue queue) { |
| 333 var fork = queue.fork(); |
| 334 var completer = new CancelableCompleter(() => fork.cancel(immediate: true)); |
| 335 completer.complete(fork.next.then((_) { |
| 336 fork.cancel(); |
| 337 return queue.next; |
| 338 })); |
| 339 return completer.future; |
| 340 } |
| 341 |
| 342 /// Returns a single-subscription stream that emits the results of [futures] in |
| 343 /// the order they complete. |
| 344 /// |
| 345 /// If any futures in [futures] are [CancelableFuture]s, this will cancel them |
| 346 /// if the subscription is canceled. |
| 347 Stream inCompletionOrder(Iterable<Future> futures) { |
| 348 var futureSet = futures.toSet(); |
| 349 var controller = new StreamController(sync: true, onCancel: () { |
| 350 return Future.wait(futureSet.map((future) { |
| 351 return future is CancelableFuture ? future.cancel() : null; |
| 352 }).where((future) => future != null)); |
| 353 }); |
| 354 |
| 355 for (var future in futureSet) { |
| 356 future.then(controller.add).catchError(controller.addError) |
| 357 .whenComplete(() { |
| 358 futureSet.remove(future); |
| 359 if (futureSet.isEmpty) controller.close(); |
| 360 }); |
| 361 } |
| 362 |
| 363 return controller.stream; |
| 364 } |
| 365 |
| 366 /// Returns a stream that emits [error] and [stackTrace], then closes. |
| 367 /// |
| 368 /// This is useful for adding errors to streams defined via `async*`. |
| 369 Stream errorStream(error, StackTrace stackTrace) { |
| 370 var controller = new StreamController(); |
| 371 controller.addError(error, stackTrace); |
| 372 controller.close(); |
| 373 return controller.stream; |
| 374 } |
| 375 |
| 376 /// Runs [fn] and discards its return value. |
| 377 /// |
| 378 /// This is useful for making a block of code async without forcing the |
| 379 /// containing method to return a future. |
| 380 void invoke(fn()) { |
| 381 fn(); |
| 382 } |
| 383 |
| 384 /// Returns a random base64 string containing [bytes] bytes of data. |
| 385 /// |
| 386 /// [seed] is passed to [math.Random]; [urlSafe] and [addLineSeparator] are |
| 387 /// passed to [CryptoUtils.bytesToBase64]. |
| 388 String randomBase64(int bytes, {int seed, bool urlSafe: false, |
| 389 bool addLineSeparator: false}) { |
| 390 var random = new math.Random(seed); |
| 391 var data = []; |
| 392 for (var i = 0; i < bytes; i++) { |
| 393 data.add(random.nextInt(256)); |
| 394 } |
| 395 return CryptoUtils.bytesToBase64(data, |
| 396 urlSafe: urlSafe, addLineSeparator: addLineSeparator); |
| 397 } |
| 398 |
| 399 /// Returns middleware that nests all requests beneath the URL prefix [beneath]. |
| 400 shelf.Middleware nestingMiddleware(String beneath) { |
| 401 return (handler) { |
| 402 var pathHandler = new PathHandler()..add(beneath, handler); |
| 403 return pathHandler.handler; |
| 404 }; |
| 405 } |
OLD | NEW |