Configurable package folding (#650)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0a72e51..d13790d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,8 @@
+## 0.12.23
+
+* Add a `fold_stack_frames` field for `dart_test.yaml`. This will
+  allow users to customize which packages' frames are folded.
+  
 ## 0.12.22+2
 
 * Properly allocate ports when debugging Chrome and Dartium in an IPv6-only
diff --git a/doc/configuration.md b/doc/configuration.md
index 7f29442..24cf7c0 100644
--- a/doc/configuration.md
+++ b/doc/configuration.md
@@ -45,6 +45,7 @@
   * [`run_skipped`](#run_skipped)
   * [`pub_serve`](#pub_serve)
   * [`reporter`](#reporter)
+  * [`fold_stack_frames`](#fold_stack_frames)
 * [Configuring Tags](#configuring-tags)
   * [`tags`](#tags)
   * [`add_tags`](#add_tags)
@@ -394,6 +395,35 @@
 This field is not supported in the
 [global configuration file](#global-configuration).
 
+### `fold_stack_frames`
+
+This field controls which packages' stack frames will be folded away
+when displaying stack traces. Packages contained in the `exclude` 
+option will be folded. If `only` is provided, all packages not
+contained in this list will be folded. By default,
+frames from the `test` package and the `stream_channel`
+package are folded.
+
+```yaml
+fold_stack_frames:
+  except:
+  - test 
+  - stream_channel 
+```
+
+Sample stack trace, note the absence of `package:test`
+and `package:stream_channel`:
+```
+test/sample_test.dart 7:5   main.<fn>
+===== asynchronous gap ===========================
+dart:async                  _Completer.completeError
+test/sample_test.dart 8:3   main.<fn>
+===== asynchronous gap ===========================
+dart:async                  _asyncThenWrapperHelper
+test/sample_test.dart 5:27  main.<fn>
+```
+
+
 ## Configuring Tags
 
 ### `tags`
diff --git a/lib/src/frontend/stream_matcher.dart b/lib/src/frontend/stream_matcher.dart
index 6c90674..9e166ca 100644
--- a/lib/src/frontend/stream_matcher.dart
+++ b/lib/src/frontend/stream_matcher.dart
@@ -8,6 +8,8 @@
 import 'package:matcher/matcher.dart';
 
 import '../utils.dart';
+import '../backend/invoker.dart';
+import 'test_chain.dart';
 import 'async_matcher.dart';
 
 /// The type for [_StreamMatcher._matchQueue].
@@ -164,7 +166,8 @@
           return addBullet(event.asValue.value.toString());
         } else {
           var error = event.asError;
-          var text = "${error.error}\n${testChain(error.stackTrace)}";
+          var chain = testChain(error.stackTrace);
+          var text = "${error.error}\n$chain";
           return prefixLines(text, "  ", first: "! ");
         }
       }).join("\n");
diff --git a/lib/src/frontend/test_chain.dart b/lib/src/frontend/test_chain.dart
new file mode 100644
index 0000000..41bd9e7
--- /dev/null
+++ b/lib/src/frontend/test_chain.dart
@@ -0,0 +1,52 @@
+// Copyright (c) 2017, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:stack_trace/stack_trace.dart';
+
+import '../backend/invoker.dart';
+import '../util/stack_trace_mapper.dart';
+
+/// Converts [trace] into a Dart stack trace
+StackTraceMapper _mapper;
+
+/// The list of packages to fold when producing [Chain]s.
+Set<String> _exceptPackages = new Set.from(['test', 'stream_channel']);
+
+/// If non-empty, all packages not in this list will be folded when producing
+/// [Chain]s.
+Set<String> _onlyPackages = new Set();
+
+/// Configure the resources used for test chaining.
+///
+/// [mapper] is used to convert traces into Dart stack traces.
+/// [exceptPackages] is the list of packages to fold when producing a [Chain].
+/// [onlyPackages] is the list of packages to keep in a [Chain]. If non-empty,
+/// all packages not in this will be folded.
+void configureTestChaining(
+    {StackTraceMapper mapper,
+    Set<String> exceptPackages,
+    Set<String> onlyPackages}) {
+  if (mapper != null) _mapper = mapper;
+  if (exceptPackages != null) _exceptPackages = exceptPackages;
+  if (onlyPackages != null) _onlyPackages = onlyPackages;
+}
+
+/// Returns [stackTrace] converted to a [Chain] with all irrelevant frames
+/// folded together.
+///
+/// If [verbose] is `true`, returns the chain for [stackTrace] unmodified.
+Chain terseChain(StackTrace stackTrace, {bool verbose: false}) {
+  var testTrace = _mapper?.mapStackTrace(stackTrace) ?? stackTrace;
+  if (verbose) return new Chain.forTrace(testTrace);
+  return new Chain.forTrace(testTrace).foldFrames((frame) {
+    if (_onlyPackages.isNotEmpty) {
+      return !_onlyPackages.contains(frame.package);
+    }
+    return _exceptPackages.contains(frame.package);
+  }, terse: true);
+}
+
+/// Converts [stackTrace] to a [Chain] following the test's configuration.
+Chain testChain(StackTrace stackTrace) => terseChain(stackTrace,
+    verbose: Invoker.current?.liveTest?.test?.metadata?.verboseTrace ?? true);
diff --git a/lib/src/frontend/throws_matcher.dart b/lib/src/frontend/throws_matcher.dart
index 8518c0f..75c5d23 100644
--- a/lib/src/frontend/throws_matcher.dart
+++ b/lib/src/frontend/throws_matcher.dart
@@ -8,6 +8,8 @@
 
 import '../utils.dart';
 import 'async_matcher.dart';
+import '../frontend/test_chain.dart';
+import '../backend/invoker.dart';
 
 /// This function is deprecated.
 ///
diff --git a/lib/src/runner/browser/browser_manager.dart b/lib/src/runner/browser/browser_manager.dart
index 7f25b38..841b0b3 100644
--- a/lib/src/runner/browser/browser_manager.dart
+++ b/lib/src/runner/browser/browser_manager.dart
@@ -238,7 +238,7 @@
       try {
         controller = await deserializeSuite(
             path, _platform, suiteConfig, await _environment, suiteChannel,
-            mapTrace: mapper?.mapStackTrace);
+            mapper: mapper);
         _controllers.add(controller);
         return controller.suite;
       } catch (_) {
diff --git a/lib/src/runner/configuration.dart b/lib/src/runner/configuration.dart
index fc43ed0..06cc76c 100644
--- a/lib/src/runner/configuration.dart
+++ b/lib/src/runner/configuration.dart
@@ -97,6 +97,15 @@
   /// See [shardIndex] for details.
   final int totalShards;
 
+  /// The list of packages to fold when producing [StackTrace]s.
+  Set<String> get foldTraceExcept => _foldTraceExcept ?? new Set();
+  final Set<String> _foldTraceExcept;
+
+  /// If non-empty, all packages not in this list will be folded when producing
+  /// [StackTrace]s.
+  Set<String> get foldTraceOnly => _foldTraceOnly ?? new Set();
+  final Set<String> _foldTraceOnly;
+
   /// The paths from which to load tests.
   List<String> get paths => _paths ?? ["test"];
   final List<String> _paths;
@@ -198,6 +207,8 @@
       int shardIndex,
       int totalShards,
       Iterable<String> paths,
+      Iterable<String> foldTraceExcept,
+      Iterable<String> foldTraceOnly,
       Glob filename,
       Iterable<String> chosenPresets,
       Map<String, Configuration> presets,
@@ -238,6 +249,8 @@
         shardIndex: shardIndex,
         totalShards: totalShards,
         paths: paths,
+        foldTraceExcept: foldTraceExcept,
+        foldTraceOnly: foldTraceOnly,
         filename: filename,
         chosenPresets: chosenPresetSet,
         presets: _withChosenPresets(presets, chosenPresetSet),
@@ -290,6 +303,8 @@
       this.shardIndex,
       this.totalShards,
       Iterable<String> paths,
+      Iterable<String> foldTraceExcept,
+      Iterable<String> foldTraceOnly,
       Glob filename,
       Iterable<String> chosenPresets,
       Map<String, Configuration> presets,
@@ -307,6 +322,8 @@
             : Uri.parse("http://localhost:$pubServePort"),
         _concurrency = concurrency,
         _paths = _list(paths),
+        _foldTraceExcept = _set(foldTraceExcept),
+        _foldTraceOnly = _set(foldTraceOnly),
         _filename = filename,
         chosenPresets =
             new UnmodifiableSetView(chosenPresets?.toSet() ?? new Set()),
@@ -347,6 +364,14 @@
     return list;
   }
 
+  /// Returns a set from [input].
+  static Set<T> _set<T>(Iterable<T> input) {
+    if (input == null) return null;
+    var set = new Set<T>.from(input);
+    if (set.isEmpty) return null;
+    return set;
+  }
+
   /// Returns an unmodifiable copy of [input] or an empty unmodifiable map.
   static Map/*<K, V>*/ _map/*<K, V>*/(Map/*<K, V>*/ input) {
     if (input == null || input.isEmpty) return const {};
@@ -369,6 +394,22 @@
     if (this == Configuration.empty) return other;
     if (other == Configuration.empty) return this;
 
+    var foldTraceOnly = other._foldTraceOnly ?? _foldTraceOnly;
+    var foldTraceExcept = other._foldTraceExcept ?? _foldTraceExcept;
+    if (_foldTraceOnly != null) {
+      if (other._foldTraceExcept != null) {
+        foldTraceOnly = _foldTraceOnly.difference(other._foldTraceExcept);
+      } else if (other._foldTraceOnly != null) {
+        foldTraceOnly = other._foldTraceOnly.intersection(_foldTraceOnly);
+      }
+    } else if (_foldTraceExcept != null) {
+      if (other._foldTraceOnly != null) {
+        foldTraceOnly = other._foldTraceOnly.difference(_foldTraceExcept);
+      } else if (other._foldTraceExcept != null) {
+        foldTraceExcept = other._foldTraceExcept.union(_foldTraceExcept);
+      }
+    }
+
     var result = new Configuration._(
         help: other._help ?? _help,
         version: other._version ?? _version,
@@ -382,6 +423,8 @@
         shardIndex: other.shardIndex ?? shardIndex,
         totalShards: other.totalShards ?? totalShards,
         paths: other._paths ?? _paths,
+        foldTraceExcept: foldTraceExcept,
+        foldTraceOnly: foldTraceOnly,
         filename: other._filename ?? _filename,
         chosenPresets: chosenPresets.union(other.chosenPresets),
         presets: _mergeConfigMaps(presets, other.presets),
@@ -412,6 +455,8 @@
       int shardIndex,
       int totalShards,
       Iterable<String> paths,
+      Iterable<String> exceptPackages,
+      Iterable<String> onlyPackages,
       Glob filename,
       Iterable<String> chosenPresets,
       Map<String, Configuration> presets,
@@ -450,6 +495,8 @@
         shardIndex: shardIndex ?? this.shardIndex,
         totalShards: totalShards ?? this.totalShards,
         paths: paths ?? _paths,
+        foldTraceExcept: exceptPackages ?? _foldTraceExcept,
+        foldTraceOnly: onlyPackages ?? _foldTraceOnly,
         filename: filename ?? _filename,
         chosenPresets: chosenPresets ?? this.chosenPresets,
         presets: presets ?? this.presets,
diff --git a/lib/src/runner/configuration/load.dart b/lib/src/runner/configuration/load.dart
index 00e89ad..3a51edd 100644
--- a/lib/src/runner/configuration/load.dart
+++ b/lib/src/runner/configuration/load.dart
@@ -21,6 +21,19 @@
 import '../configuration/suite.dart';
 import 'reporters.dart';
 
+/// A regular expression matching a Dart identifier.
+///
+/// This also matches a package name, since they must be Dart identifiers.
+final identifierRegExp = new RegExp(r"[a-zA-Z_]\w*");
+
+/// A regular expression matching allowed package names.
+///
+/// This allows dot-separated valid Dart identifiers. The dots are there for
+/// compatibility with Google's internal Dart packages, but they may not be used
+/// when publishing a package to pub.dartlang.org.
+final _packageName = new RegExp(
+    "^${identifierRegExp.pattern}(\\.${identifierRegExp.pattern})*\$");
+
 /// Loads configuration information from a YAML file at [path].
 ///
 /// If [global] is `true`, this restricts the configuration file to only rules
@@ -74,6 +87,7 @@
   Configuration _loadGlobalTestConfig() {
     var verboseTrace = _getBool("verbose_trace");
     var chainStackTraces = _getBool("chain_stack_traces");
+    var foldStackFrames = _loadFoldedStackFrames();
     var jsTrace = _getBool("js_trace");
 
     var timeout = _parseValue("timeout", (value) => new Timeout.parse(value));
@@ -108,7 +122,9 @@
             jsTrace: jsTrace,
             timeout: timeout,
             presets: presets,
-            chainStackTraces: chainStackTraces)
+            chainStackTraces: chainStackTraces,
+            foldTraceExcept: foldStackFrames["except"],
+            foldTraceOnly: foldStackFrames["only"])
         .merge(_extractPresets/*<PlatformSelector>*/(
             onPlatform, (map) => new Configuration(onPlatform: map)));
 
@@ -263,6 +279,44 @@
         excludeTags: excludeTags);
   }
 
+  /// Returns a map representation of the `fold_stack_frames` configuration.
+  ///
+  /// The key `except` will correspond to the list of packages to fold.
+  /// The key `only` will correspond to the list of packages to keep in a
+  /// test [Chain].
+  Map<String, List<String>> _loadFoldedStackFrames() {
+    var foldOptionSet = false;
+    return _getMap("fold_stack_frames", key: (keyNode) {
+      _validate(keyNode, "Must be a string", (value) => value is String);
+      _validate(keyNode, 'Must be "only" or "except".',
+          (value) => value == "only" || value == "except");
+
+      if (foldOptionSet) {
+        throw new SourceSpanFormatException(
+            'Can only contain one of "only" or "except".',
+            keyNode.span,
+            _source);
+      }
+      foldOptionSet = true;
+      return keyNode.value;
+    }, value: (valueNode) {
+      _validate(
+          valueNode,
+          "Folded packages must be strings.",
+          (valueList) =>
+              valueList is YamlList &&
+              valueList.every((value) => value is String));
+
+      _validate(
+          valueNode,
+          "Invalid package name.",
+          (valueList) =>
+              valueList.every((value) => _packageName.hasMatch(value)));
+
+      return valueNode.value;
+    });
+  }
+
   /// Throws an exception with [message] if [test] returns `false` when passed
   /// [node]'s value.
   void _validate(YamlNode node, String message, bool test(value)) {
diff --git a/lib/src/runner/plugin/platform_helpers.dart b/lib/src/runner/plugin/platform_helpers.dart
index bddaea7..841b075 100644
--- a/lib/src/runner/plugin/platform_helpers.dart
+++ b/lib/src/runner/plugin/platform_helpers.dart
@@ -14,6 +14,7 @@
 import '../../backend/test_platform.dart';
 import '../../util/io.dart';
 import '../../util/remote_exception.dart';
+import '../../util/stack_trace_mapper.dart';
 import '../application_exception.dart';
 import '../configuration.dart';
 import '../configuration/suite.dart';
@@ -46,9 +47,7 @@
     SuiteConfiguration suiteConfig,
     Environment environment,
     StreamChannel channel,
-    {StackTrace mapTrace(StackTrace trace)}) async {
-  if (mapTrace == null) mapTrace = (trace) => trace;
-
+    {StackTraceMapper mapper}) async {
   var disconnector = new Disconnector();
   var suiteChannel = new MultiChannel(channel.transform(disconnector));
 
@@ -60,6 +59,9 @@
     'path': path,
     'collectTraces': Configuration.current.reporter == 'json',
     'noRetry': Configuration.current.noRetry,
+    'stackTraceMapper': mapper?.serialize(),
+    'foldTraceExcept': Configuration.current.foldTraceExcept.toList(),
+    'foldTraceOnly': Configuration.current.foldTraceOnly.toList(),
   });
 
   var completer = new Completer();
@@ -72,9 +74,9 @@
       // If we've already provided a controller, send the error to the
       // LoadSuite. This will cause the virtual load test to fail, which will
       // notify the user of the error.
-      loadSuiteZone.handleUncaughtError(error, mapTrace(stackTrace));
+      loadSuiteZone.handleUncaughtError(error, stackTrace);
     } else {
-      completer.completeError(error, mapTrace(stackTrace));
+      completer.completeError(error);
     }
   }
 
@@ -93,11 +95,11 @@
           case "error":
             var asyncError = RemoteException.deserialize(response["error"]);
             handleError(new LoadException(path, asyncError.error),
-                mapTrace(asyncError.stackTrace));
+                asyncError.stackTrace);
             break;
 
           case "success":
-            var deserializer = new _Deserializer(suiteChannel, mapTrace);
+            var deserializer = new _Deserializer(suiteChannel);
             completer.complete(deserializer.deserializeGroup(response["root"]));
             break;
         }
@@ -130,10 +132,7 @@
   /// The channel over which tests communicate.
   final MultiChannel _channel;
 
-  /// The function used to errors' map stack traces.
-  final _MapTrace _mapTrace;
-
-  _Deserializer(this._channel, this._mapTrace);
+  _Deserializer(this._channel);
 
   /// Deserializes [group] into a concrete [Group].
   Group deserializeGroup(Map group) {
@@ -160,7 +159,6 @@
     var metadata = new Metadata.deserialize(test['metadata']);
     var trace = test['trace'] == null ? null : new Trace.parse(test['trace']);
     var testChannel = _channel.virtualChannel(test['channel']);
-    return new RunnerTest(
-        test['name'], metadata, trace, testChannel, _mapTrace);
+    return new RunnerTest(test['name'], metadata, trace, testChannel);
   }
 }
diff --git a/lib/src/runner/remote_listener.dart b/lib/src/runner/remote_listener.dart
index 5812174..a61fd0e 100644
--- a/lib/src/runner/remote_listener.dart
+++ b/lib/src/runner/remote_listener.dart
@@ -15,7 +15,9 @@
 import '../backend/suite.dart';
 import '../backend/test.dart';
 import '../backend/test_platform.dart';
+import '../frontend/test_chain.dart';
 import '../util/remote_exception.dart';
+import '../util/stack_trace_mapper.dart';
 import '../utils.dart';
 
 class RemoteListener {
@@ -46,6 +48,8 @@
         new StreamChannelController(allowForeignErrors: false, sync: true);
     var channel = new MultiChannel(controller.local);
 
+    var verboseChain = true;
+
     var printZone = hidePrints ? null : Zone.current;
     runZoned(() async {
       var main;
@@ -55,7 +59,7 @@
         _sendLoadException(channel, "No top-level main() function defined.");
         return;
       } catch (error, stackTrace) {
-        _sendError(channel, error, stackTrace);
+        _sendError(channel, error, stackTrace, verboseChain);
         return;
       }
 
@@ -72,10 +76,17 @@
 
       if (message['asciiGlyphs'] ?? false) glyph.ascii = true;
       var metadata = new Metadata.deserialize(message['metadata']);
+      verboseChain = metadata.verboseTrace;
       var declarer = new Declarer(
           metadata: metadata,
           collectTraces: message['collectTraces'],
           noRetry: message['noRetry']);
+
+      configureTestChaining(
+          mapper: StackTraceMapper.deserialize(message['stackTraceMapper']),
+          exceptPackages: _deserializeSet(message['foldTraceExcept']),
+          onlyPackages: _deserializeSet(message['foldTraceOnly']));
+
       await declarer.declare(main);
 
       var os =
@@ -85,7 +96,7 @@
           platform: platform, os: os, path: message['path']);
       new RemoteListener._(suite, printZone)._listen(channel);
     }, onError: (error, stackTrace) {
-      _sendError(channel, error, stackTrace);
+      _sendError(channel, error, stackTrace, verboseChain);
     }, zoneSpecification: new ZoneSpecification(print: (_, __, ___, line) {
       if (printZone != null) printZone.print(line);
       channel.sink.add({"type": "print", "line": line});
@@ -94,6 +105,13 @@
     return controller.foreign;
   }
 
+  /// Returns a [Set] from a JSON serialized list.
+  static Set<String> _deserializeSet(List<String> list) {
+    if (list == null) return null;
+    if (list.isEmpty) return null;
+    return new Set.from(list);
+  }
+
   /// Sends a message over [channel] indicating that the tests failed to load.
   ///
   /// [message] should describe the failure.
@@ -102,10 +120,12 @@
   }
 
   /// Sends a message over [channel] indicating an error from user code.
-  static void _sendError(StreamChannel channel, error, StackTrace stackTrace) {
+  static void _sendError(
+      StreamChannel channel, error, StackTrace stackTrace, bool verboseChain) {
     channel.sink.add({
       "type": "error",
-      "error": RemoteException.serialize(error, stackTrace)
+      "error": RemoteException.serialize(
+          error, terseChain(stackTrace, verbose: verboseChain))
     });
   }
 
@@ -182,8 +202,10 @@
     liveTest.onError.listen((asyncError) {
       channel.sink.add({
         "type": "error",
-        "error":
-            RemoteException.serialize(asyncError.error, asyncError.stackTrace)
+        "error": RemoteException.serialize(
+            asyncError.error,
+            terseChain(asyncError.stackTrace,
+                verbose: liveTest.test.metadata.verboseTrace))
       });
     });
 
diff --git a/lib/src/runner/reporter/compact.dart b/lib/src/runner/reporter/compact.dart
index a964672..cb4cc9a 100644
--- a/lib/src/runner/reporter/compact.dart
+++ b/lib/src/runner/reporter/compact.dart
@@ -212,9 +212,7 @@
 
     if (error is! LoadException) {
       print(indent(error.toString()));
-      var chain =
-          terseChain(stackTrace, verbose: liveTest.test.metadata.verboseTrace);
-      print(indent(chain.toString()));
+      print(indent('$stackTrace'));
       return;
     }
 
@@ -225,7 +223,7 @@
         error.innerError is! IsolateSpawnException &&
         error.innerError is! FormatException &&
         error.innerError is! String) {
-      print(indent(terseChain(stackTrace).toString()));
+      print(indent('$stackTrace'));
     }
   }
 
diff --git a/lib/src/runner/reporter/expanded.dart b/lib/src/runner/reporter/expanded.dart
index 35f289d..00d7915 100644
--- a/lib/src/runner/reporter/expanded.dart
+++ b/lib/src/runner/reporter/expanded.dart
@@ -201,9 +201,7 @@
 
     if (error is! LoadException) {
       print(indent(error.toString()));
-      var chain =
-          terseChain(stackTrace, verbose: liveTest.test.metadata.verboseTrace);
-      print(indent(chain.toString()));
+      print(indent('$stackTrace'));
       return;
     }
 
@@ -213,7 +211,7 @@
     if (error.innerError is! IsolateSpawnException &&
         error.innerError is! FormatException &&
         error.innerError is! String) {
-      print(indent(terseChain(stackTrace).toString()));
+      print(indent('$stackTrace'));
     }
   }
 
diff --git a/lib/src/runner/reporter/json.dart b/lib/src/runner/reporter/json.dart
index 374ad21..fba28a6 100644
--- a/lib/src/runner/reporter/json.dart
+++ b/lib/src/runner/reporter/json.dart
@@ -250,9 +250,7 @@
     _emit("error", {
       "testID": _liveTestIDs[liveTest],
       "error": error.toString(),
-      "stackTrace":
-          terseChain(stackTrace, verbose: liveTest.test.metadata.verboseTrace)
-              .toString(),
+      "stackTrace": '$stackTrace',
       "isFailure": error is TestFailure
     });
   }
diff --git a/lib/src/runner/runner_test.dart b/lib/src/runner/runner_test.dart
index 70b8eab..d3b4edd 100644
--- a/lib/src/runner/runner_test.dart
+++ b/lib/src/runner/runner_test.dart
@@ -19,8 +19,6 @@
 import '../utils.dart';
 import 'spawn_hybrid.dart';
 
-typedef StackTrace _MapTrace(StackTrace trace);
-
 /// A test running remotely, controlled by a stream channel.
 class RunnerTest extends Test {
   final String name;
@@ -30,16 +28,9 @@
   /// The channel used to communicate with the test's [IframeListener].
   final MultiChannel _channel;
 
-  /// The function used to reformat errors' stack traces.
-  final _MapTrace _mapTrace;
+  RunnerTest(this.name, this.metadata, this.trace, this._channel);
 
-  RunnerTest(
-      this.name, this.metadata, Trace trace, this._channel, _MapTrace mapTrace)
-      : trace = trace == null ? null : new Trace.from(mapTrace(trace)),
-        _mapTrace = mapTrace;
-
-  RunnerTest._(
-      this.name, this.metadata, this.trace, this._channel, this._mapTrace);
+  RunnerTest._(this.name, this.metadata, this.trace, this._channel);
 
   LiveTest load(Suite suite, {Iterable<Group> groups}) {
     var controller;
@@ -54,7 +45,7 @@
         switch (message['type']) {
           case 'error':
             var asyncError = RemoteException.deserialize(message['error']);
-            var stackTrace = _mapTrace(asyncError.stackTrace);
+            var stackTrace = asyncError.stackTrace;
             controller.addError(asyncError.error, stackTrace);
             break;
 
@@ -108,7 +99,7 @@
 
   Test forPlatform(TestPlatform platform, {OperatingSystem os}) {
     if (!metadata.testOn.evaluate(platform, os: os)) return null;
-    return new RunnerTest._(name, metadata.forPlatform(platform, os: os), trace,
-        _channel, _mapTrace);
+    return new RunnerTest._(
+        name, metadata.forPlatform(platform, os: os), trace, _channel);
   }
 }
diff --git a/lib/src/util/stack_trace_mapper.dart b/lib/src/util/stack_trace_mapper.dart
index 2299f47..52375d9 100644
--- a/lib/src/util/stack_trace_mapper.dart
+++ b/lib/src/util/stack_trace_mapper.dart
@@ -2,6 +2,7 @@
 // for details. All rights reserved. Use of this source code is governed by a
 // BSD-style license that can be found in the LICENSE file.
 
+import 'package:collection/collection.dart';
 import 'package:package_resolver/package_resolver.dart';
 import 'package:source_map_stack_trace/source_map_stack_trace.dart' as mapper;
 import 'package:source_maps/source_maps.dart';
@@ -9,22 +10,74 @@
 /// A class for mapping JS stack traces to Dart stack traces using source maps.
 class StackTraceMapper {
   /// The parsed source map.
-  final Mapping _mapping;
+  ///
+  /// This is initialized lazily in `mapStackTrace()`.
+  Mapping _mapping;
 
   /// The package resolution information passed to dart2js.
   final SyncPackageResolver _packageResolver;
 
-  /// The URI of the SDK root from which dart2js loaded its sources.
+  /// The URL of the SDK root from which dart2js loaded its sources.
   final Uri _sdkRoot;
 
-  StackTraceMapper(String contents,
+  /// The contents of the source map.
+  final String _mapContents;
+
+  /// The URL of the source map.
+  final Uri _mapUrl;
+
+  StackTraceMapper(this._mapContents,
       {Uri mapUrl, SyncPackageResolver packageResolver, Uri sdkRoot})
-      : _mapping = parseExtended(contents, mapUrl: mapUrl),
+      : _mapUrl = mapUrl,
         _packageResolver = packageResolver,
         _sdkRoot = sdkRoot;
 
   /// Converts [trace] into a Dart stack trace.
-  StackTrace mapStackTrace(StackTrace trace) =>
-      mapper.mapStackTrace(_mapping, trace,
-          packageResolver: _packageResolver, sdkRoot: _sdkRoot);
+  StackTrace mapStackTrace(StackTrace trace) {
+    _mapping ??= parseExtended(_mapContents, mapUrl: _mapUrl);
+    return mapper.mapStackTrace(_mapping, trace,
+        packageResolver: _packageResolver, sdkRoot: _sdkRoot);
+  }
+
+  /// Returns a Map representation which is suitable for JSON serialization.
+  Map<String, dynamic> serialize() {
+    return {
+      'mapContents': _mapContents,
+      'sdkRoot': _sdkRoot?.toString(),
+      'packageConfigMap':
+          _serializePackageConfigMap(_packageResolver.packageConfigMap),
+      'packageRoot': _packageResolver.packageRoot?.toString(),
+      'mapUrl': _mapUrl?.toString(),
+    };
+  }
+
+  /// Returns a [StackTraceMapper] contained in the provided serialized
+  /// representation.
+  static StackTraceMapper deserialize(Map serialized) {
+    if (serialized == null) return null;
+    String packageRoot = serialized['packageRoot'] as String ?? '';
+    return new StackTraceMapper(serialized['mapContents'],
+        sdkRoot: Uri.parse(serialized['sdkRoot']),
+        packageResolver: packageRoot.isNotEmpty
+            ? new SyncPackageResolver.root(Uri.parse(serialized['packageRoot']))
+            : new SyncPackageResolver.config(
+                _deserializePackageConfigMap(serialized['packageConfigMap'])),
+        mapUrl: Uri.parse(serialized['mapUrl']));
+  }
+
+  /// Converts a [packageConfigMap] into a format suitable for JSON
+  /// serialization.
+  static Map<String, String> _serializePackageConfigMap(
+      Map<String, Uri> packageConfigMap) {
+    if (packageConfigMap == null) return null;
+    return mapMap(packageConfigMap, value: (_, value) => '$value');
+  }
+
+  /// Converts a serialized package config map into a format suitable for
+  /// the [PackageResolver]
+  static Map<String, Uri> _deserializePackageConfigMap(
+      Map<String, String> serialized) {
+    if (serialized == null) return null;
+    return mapMap(serialized, value: (_, value) => Uri.parse(value));
+  }
 }
diff --git a/lib/src/utils.dart b/lib/src/utils.dart
index 060fff8..9b4abe6 100644
--- a/lib/src/utils.dart
+++ b/lib/src/utils.dart
@@ -10,7 +10,6 @@
 import 'package:async/async.dart' hide StreamQueue;
 import 'package:matcher/matcher.dart';
 import 'package:path/path.dart' as p;
-import 'package:stack_trace/stack_trace.dart';
 import 'package:term_glyph/term_glyph.dart' as glyph;
 
 import 'backend/invoker.dart';
@@ -169,24 +168,6 @@
 /// Returns [str] without any color codes.
 String withoutColors(String str) => str.replaceAll(_colorCode, '');
 
-/// Returns [stackTrace] converted to a [Chain] with all irrelevant frames
-/// folded together.
-///
-/// If [verbose] is `true`, returns the chain for [stackTrace] unmodified.
-Chain terseChain(StackTrace stackTrace, {bool verbose: false}) {
-  if (verbose) return new Chain.forTrace(stackTrace);
-  return new Chain.forTrace(stackTrace).foldFrames(
-      (frame) => frame.package == 'test' || frame.package == 'stream_channel',
-      terse: true);
-}
-
-/// Converts [stackTrace] to a [Chain] following the test's configuration.
-Chain testChain(StackTrace stackTrace) {
-  // TODO(nweiz): Follow more configuration when #527 is fixed.
-  return terseChain(stackTrace,
-      verbose: Invoker.current.liveTest.test.metadata.verboseTrace);
-}
-
 /// Flattens nested [Iterable]s inside an [Iterable] into a single [List]
 /// containing only non-[Iterable] elements.
 List flatten(Iterable nested) {
diff --git a/pubspec.yaml b/pubspec.yaml
index c93525a..c91bf4c 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: test
-version: 0.12.22+2
+version: 0.12.23
 author: Dart Team <misc@dartlang.org>
 description: A library for writing dart unit tests.
 homepage: https://github.com/dart-lang/test
diff --git a/test/frontend/test_chain_test.dart b/test/frontend/test_chain_test.dart
new file mode 100644
index 0000000..1a36039
--- /dev/null
+++ b/test/frontend/test_chain_test.dart
@@ -0,0 +1,85 @@
+// Copyright (c) 2017, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+@TestOn("vm")
+
+import 'dart:convert';
+
+import 'package:test_descriptor/test_descriptor.dart' as d;
+import 'package:test/test.dart';
+
+import '../io.dart';
+
+void main() {
+  setUp(() async {
+    await d
+        .file(
+            "test.dart",
+            """
+            import 'dart:async';
+
+            import 'package:test/test.dart';
+
+            void main() {
+              test("failure", () async{
+                await new Future((){});
+                await new Future((){});
+                throw "oh no";
+              });
+            }
+            """)
+        .create();
+  });
+  test("folds packages contained in the except list", () async {
+    await d
+        .file(
+            "dart_test.yaml",
+            JSON.encode({
+              "fold_stack_frames": {
+                "except": ["stream_channel"]
+              }
+            }))
+        .create();
+    var test = await runTest(["test.dart"]);
+    expect(test.stdoutStream(), neverEmits(contains('package:stream_channel')));
+    await test.shouldExit(1);
+  });
+
+  test("by default folds both stream_channel and test packages", () async {
+    var test = await runTest(["test.dart"]);
+    expect(test.stdoutStream(), neverEmits(contains('package:test')));
+    expect(test.stdoutStream(), neverEmits(contains('package:stream_channel')));
+    await test.shouldExit(1);
+  });
+
+  test("folds all packages not contained in the only list", () async {
+    await d
+        .file(
+            "dart_test.yaml",
+            JSON.encode({
+              "fold_stack_frames": {
+                "only": ["test"]
+              }
+            }))
+        .create();
+    var test = await runTest(["test.dart"]);
+    expect(test.stdoutStream(), neverEmits(contains('package:stream_channel')));
+    await test.shouldExit(1);
+  });
+
+  test("does not fold packages in the only list", () async {
+    await d
+        .file(
+            "dart_test.yaml",
+            JSON.encode({
+              "fold_stack_frames": {
+                "only": ["test"]
+              }
+            }))
+        .create();
+    var test = await runTest(["test.dart"]);
+    expect(test.stdoutStream(), emitsThrough(contains('package:test')));
+    await test.shouldExit(1);
+  });
+}
diff --git a/test/runner/configuration/top_level_error_test.dart b/test/runner/configuration/top_level_error_test.dart
index 0d93d63..712cf5e 100644
--- a/test/runner/configuration/top_level_error_test.dart
+++ b/test/runner/configuration/top_level_error_test.dart
@@ -13,6 +13,67 @@
 import '../../io.dart';
 
 void main() {
+  test("rejects an invalid fold_stack_frames", () async {
+    await d
+        .file("dart_test.yaml", JSON.encode({"fold_stack_frames": "flup"}))
+        .create();
+
+    var test = await runTest(["test.dart"]);
+    expect(test.stderr,
+        containsInOrder(["fold_stack_frames must be a map", "^^^^^^"]));
+    await test.shouldExit(exit_codes.data);
+  });
+
+  test("rejects multiple fold_stack_frames keys", () async {
+    await d
+        .file(
+            "dart_test.yaml",
+            JSON.encode({
+              "fold_stack_frames": {
+                "except": ["blah"],
+                "only": ["blah"]
+              }
+            }))
+        .create();
+
+    var test = await runTest(["test.dart"]);
+    expect(
+        test.stderr,
+        containsInOrder(
+            ['Can only contain one of "only" or "except".', "^^^^^^"]));
+    await test.shouldExit(exit_codes.data);
+  });
+
+  test("rejects invalid fold_stack_frames keys", () async {
+    await d
+        .file(
+            "dart_test.yaml",
+            JSON.encode({
+              "fold_stack_frames": {"invalid": "blah"}
+            }))
+        .create();
+
+    var test = await runTest(["test.dart"]);
+    expect(test.stderr,
+        containsInOrder(['Must be "only" or "except".', "^^^^^^"]));
+    await test.shouldExit(exit_codes.data);
+  });
+
+  test("rejects invalid fold_stack_frames values", () async {
+    await d
+        .file(
+            "dart_test.yaml",
+            JSON.encode({
+              "fold_stack_frames": {"only": "blah"}
+            }))
+        .create();
+
+    var test = await runTest(["test.dart"]);
+    expect(test.stderr,
+        containsInOrder(["Folded packages must be strings", "^^^^^^"]));
+    await test.shouldExit(exit_codes.data);
+  });
+
   test("rejects an invalid pause_after_load", () async {
     await d
         .file("dart_test.yaml", JSON.encode({"pause_after_load": "flup"}))