[fuchsia_inspect] Create matchers for testing.

Change-Id: I6fd0cde441f2666c60749492e8192c017f56bf5a
diff --git a/public/dart/fuchsia_inspect/BUILD.gn b/public/dart/fuchsia_inspect/BUILD.gn
index c932edc..230d722 100644
--- a/public/dart/fuchsia_inspect/BUILD.gn
+++ b/public/dart/fuchsia_inspect/BUILD.gn
@@ -18,6 +18,8 @@
     "src/inspect/internal/_inspect_impl.dart",
     "src/inspect/node.dart",
     "src/inspect/property.dart",
+    "src/testing/matcher.dart",
+    "src/testing/util.dart",
     "src/vmo/bitfield64.dart",
     "src/vmo/block.dart",
     "src/vmo/heap.dart",
@@ -26,9 +28,12 @@
     "src/vmo/vmo_fields.dart",
     "src/vmo/vmo_holder.dart",
     "src/vmo/vmo_writer.dart",
+    "testing.dart",
   ]
 
   deps = [
+    "//third_party/dart-pkg/pub/collection",
+    "//third_party/dart-pkg/pub/matcher",
     "//third_party/dart-pkg/pub/meta",
     "//topaz/public/dart/fuchsia_services",
     "//topaz/public/dart/fuchsia_vfs",
diff --git a/public/dart/fuchsia_inspect/examples/inspect_mod/test/inspect_mod_test.dart b/public/dart/fuchsia_inspect/examples/inspect_mod/test/inspect_mod_test.dart
index d941c91..20d8cda 100644
--- a/public/dart/fuchsia_inspect/examples/inspect_mod/test/inspect_mod_test.dart
+++ b/public/dart/fuchsia_inspect/examples/inspect_mod/test/inspect_mod_test.dart
@@ -14,11 +14,10 @@
 import 'package:fuchsia_services/services.dart';
 import 'package:glob/glob.dart';
 import 'package:test/test.dart';
-import 'package:test_vmo_reader/vmo_reader.dart' show VmoReader;
+import 'package:fuchsia_inspect/testing.dart' show VmoMatcher, hasNoErrors;
 // ignore: implementation_imports
 import 'util.dart';
 
-
 const Pattern _testAppName = 'inspect_mod.cmx';
 const _testAppUrl = 'fuchsia-pkg://fuchsia.com/inspect_mod#meta/$_testAppName';
 const _modularTestHarnessURL =
@@ -107,7 +106,7 @@
   await storyPuppetMasterProxy.execute();
 }
 
-Future<String> _readInspect() async {
+Future<FakeVmoReader> _readInspect() async {
   // WARNING: 0) These paths are extremely fragile.
   var globs = [
     // TODO(vickiecheng): remove this one once stories reuse session envs.
@@ -128,7 +127,7 @@
           vmoData.setUint8(i, vmoBytes[i]);
         }
         var vmo = FakeVmoReader.usingData(vmoData);
-        return VmoReader(vmo).toString();
+        return vmo;
       }
     }
   }
@@ -166,7 +165,7 @@
     testHarnessController.ctrl.close();
   });
 
-  Future<String> tapAndWait(String buttonName, String nextState) async {
+  Future<FakeVmoReader> tapAndWait(String buttonName, String nextState) async {
     await driver.tap(find.text(buttonName));
     await driver.waitFor(find.byValueKey(nextState));
     return await _readInspect();
@@ -175,61 +174,91 @@
   test('Put the program through its paces', () async {
     // Wait for initial StateBloc value to appear
     await driver.waitFor(find.byValueKey('Program has started'));
-    var inspect = await _readInspect();
-    expect('<> >> IntProperty "interesting": 118', isIn(inspect));
-    expect('<> >> DoubleProperty "double down": 3.23', isIn(inspect));
-    expect('<> >> ByteDataProperty  "bytes": 01 02 03 04', isIn(inspect));
-    expect('<> >> StringProperty "greeting": "Hello World"', isIn(inspect));
-    expect('<> >> Node: "home-page"', isIn(inspect));
-    expect('<> >> >> IntProperty "counter": 0', isIn(inspect));
-    expect('<> >> >> StringProperty "background-color": "Color(0xffffffff)"',
-        isIn(inspect));
-    expect('<> >> >> StringProperty "title": "Hello Inspect!"', isIn(inspect));
+    var matcher = VmoMatcher(await _readInspect());
 
-    // Tap the "Increment counter" button
-    inspect = await tapAndWait('Increment counter', 'Counter was incremented');
-    expect('IntProperty "counter": 0', isNot(isIn(inspect)));
-    expect('IntProperty "counter": 1', isIn(inspect));
+    expect(
+        matcher.node()
+          ..propertyEquals('interesting', 118)
+          ..propertyEquals('double down', 3.23)
+          ..propertyEquals('bytes', Uint8List.fromList([1, 2, 3, 4]))
+          ..propertyEquals('greeting', 'Hello World'),
+        hasNoErrors);
 
-    // Tap the "Decrement counter" button
-    inspect = await tapAndWait('Decrement counter', 'Counter was decremented');
-    expect('IntProperty "counter": 1', isNot(isIn(inspect)));
-    expect('IntProperty "counter": 0', isIn(inspect));
+    expect(
+        matcher.node().at(['home-page'])
+          ..propertyEquals('counter', 0)
+          ..propertyEquals('background-color', 'Color(0xffffffff)')
+          ..propertyEquals('title', 'Hello Inspect!'),
+        hasNoErrors);
+
+    matcher = VmoMatcher(
+        await tapAndWait('Increment counter', 'Counter was incremented'));
+    expect(matcher.node().at(['home-page'])..propertyEquals('counter', 1),
+        hasNoErrors);
+
+    matcher = VmoMatcher(
+        await tapAndWait('Decrement counter', 'Counter was decremented'));
+    expect(matcher.node().at(['home-page'])..propertyEquals('counter', 0),
+        hasNoErrors);
 
     // The node name below is truncated due to limitations of the maximum node
     // name length.
-    var preTreeInspect = inspect;
-    inspect = await tapAndWait('Make tree', 'Tree was made');
+    matcher = VmoMatcher(await tapAndWait('Make tree', 'Tree was made'));
     expect(
-        '<> >> >> Node: "I think that I shall never see01234567890123456789012345"\n'
-        '<> >> >> >> IntProperty "int0": 0',
-        isIn(inspect));
+        matcher.node().at([
+          'home-page',
+          'I think that I shall never see01234567890123456789012345'
+        ])
+          ..propertyEquals('int0', 0),
+        hasNoErrors);
 
-    inspect = await tapAndWait('Grow tree', 'Tree was grown');
-    expect('<> >> >> >> IntProperty "int0": 0', isIn(inspect));
-    expect('<> >> >> >> IntProperty "int1": 1', isIn(inspect));
-
-    inspect = await tapAndWait('Delete tree', 'Tree was deleted');
-    expect(inspect, preTreeInspect);
-
-    inspect = await tapAndWait('Grow tree', 'Tree was grown');
-    expect(inspect, preTreeInspect);
-
-    inspect = await tapAndWait('Make tree', 'Tree was made');
+    matcher = VmoMatcher(await tapAndWait('Grow tree', 'Tree was grown'));
     expect(
-        '<> >> >> Node: "I think that I shall never see01234567890123456789012345"\n'
-        '<> >> >> >> IntProperty "int3": 3',
-        isIn(inspect));
+        matcher.node().at([
+          'home-page',
+          'I think that I shall never see01234567890123456789012345'
+        ])
+          ..propertyEquals('int0', 0)
+          ..propertyEquals('int1', 1),
+        hasNoErrors);
 
-    inspect = await tapAndWait('Get answer', 'Waiting for answer');
-    expect('>> StringProperty "waiting": "for a hint"', isIn(inspect));
+    matcher = VmoMatcher(await tapAndWait('Delete tree', 'Tree was deleted'));
+    expect(
+        matcher.node().at(['home-page'])
+          ..missingChild(
+              'I think that I shall never see01234567890123456789012345'),
+        hasNoErrors);
 
-    inspect = await tapAndWait('Give hint', 'Displayed answer');
-    expect('>> StringProperty "waiting": "for a hint"', isNot(isIn(inspect)));
+    matcher = VmoMatcher(await tapAndWait('Grow tree', 'Tree was grown'));
+    expect(
+        matcher.node().at(['home-page'])
+          ..missingChild(
+              'I think that I shall never see01234567890123456789012345'),
+        hasNoErrors);
 
-    inspect = await tapAndWait('Change color', 'Color was changed');
-    expect('<> >> >> StringProperty "background-color": "Color(0xffffffff)"',
-        isNot(isIn(inspect)));
-    expect('<> >> >> StringProperty "background-color": "', isIn(inspect));
+    matcher = VmoMatcher(await tapAndWait('Make tree', 'Tree was made'));
+    expect(
+        matcher.node().at([
+          'home-page',
+          'I think that I shall never see01234567890123456789012345'
+        ])
+          ..propertyEquals('int3', 3),
+        hasNoErrors);
+
+    matcher = VmoMatcher(await tapAndWait('Get answer', 'Waiting for answer'));
+    expect(
+        matcher.node().at(['home-page'])
+          ..propertyEquals('waiting', 'for a hint'),
+        hasNoErrors);
+
+    matcher = VmoMatcher(await tapAndWait('Give hint', 'Displayed answer'));
+    expect(
+        matcher.node().at(['home-page'])..missingChild('waiting'), hasNoErrors);
+
+    matcher = VmoMatcher(await tapAndWait('Change color', 'Color was changed'));
+    expect(
+        matcher.node().at(['home-page']).propertyNotEquals(
+            'background-color', 'Color(0xffffffff)'),
+        hasNoErrors);
   });
 }
diff --git a/public/dart/fuchsia_inspect/lib/src/testing/matcher.dart b/public/dart/fuchsia_inspect/lib/src/testing/matcher.dart
new file mode 100644
index 0000000..00853dc
--- /dev/null
+++ b/public/dart/fuchsia_inspect/lib/src/testing/matcher.dart
@@ -0,0 +1,369 @@
+// Copyright 2019 The Fuchsia 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:convert' show utf8;
+import 'dart:math' show min;
+import 'dart:typed_data';
+import 'package:collection/collection.dart';
+import 'package:matcher/matcher.dart';
+
+import 'package:fuchsia_inspect/src/vmo/block.dart';
+import 'package:fuchsia_inspect/src/vmo/vmo_fields.dart';
+import 'package:fuchsia_inspect/src/vmo/vmo_holder.dart';
+
+abstract class _HasErrors {
+  List<String> get errors;
+  void resetErrors();
+}
+
+/// A matcher for testing the structure of Inspect data written to a VMO.
+///
+/// This matcher aggregates errors from attempting to access or match
+/// values stored in the VMO. The set of aggregated errors may be taken
+/// and reset using the |errors| getter for the purposes of unit testing an
+/// Inspect hierarchy.
+///
+/// Note that this class is not optimized for efficiency, and should only
+/// be used in tests.
+class VmoMatcher implements _HasErrors {
+  final VmoHolder _holder;
+  List<String> _errors = [];
+
+  /// Create a new matcher that matches against the given VmoHolder.
+  VmoMatcher(this._holder);
+
+  /// Gets the list of errors.
+  @override
+  List<String> get errors => _errors;
+
+  /// Resets the recorded errors from this VmoMatcher.
+  @override
+  void resetErrors() {
+    _errors = [];
+  }
+
+  /// Retrieve the root node matcher, which can be used to match against
+  /// nested properties and children.
+  NodeMatcher node() {
+    if (Block.read(_holder, 1).type == BlockType.nodeValue) {
+      return NodeMatcher._valid(this, 1);
+    } else {
+      _addError('No root node found at index 1');
+      return NodeMatcher._invalid(this);
+    }
+  }
+
+  // Internal method to check if the given index is valid and can be
+  // safely read as a block.
+  bool _specifiesValidBlock(int index) {
+    if (index * bytesPerIndex + 16 > _holder.size) {
+      // Entire block header doesn't fit into the VMO.
+      return false;
+    }
+    var blockSize = Block.read(_holder, index).size;
+
+    // Ensure the entire block as specified fits into the VMO.
+    return index * bytesPerIndex + blockSize <= _holder.size;
+  }
+
+  // Internal method to aggregate errors.
+  void _addError(String s) {
+    _errors.add(s);
+  }
+
+  // Internal method to find the named child of a given parent index.
+  int _findNamedValue(String name, int parentIndex) {
+    for (int readPosBytes = 0; readPosBytes < _holder.size;) {
+      var index = readPosBytes ~/ bytesPerIndex;
+      var block = Block.read(_holder, index);
+      switch (block.type) {
+        case BlockType.nodeValue:
+        case BlockType.propertyValue:
+        case BlockType.intValue:
+        case BlockType.doubleValue:
+          if (block.parentIndex == parentIndex &&
+              _nameForBlock(block) == name) {
+            return index;
+          }
+          break;
+        default:
+          break;
+      }
+      readPosBytes += block.size;
+    }
+
+    return 0;
+  }
+
+  // Internal method to find and parse the name for a block.
+  String _nameForBlock(Block block) {
+    if (!_specifiesValidBlock(block.nameIndex)) {
+      return null;
+    }
+
+    var nameBlock = Block.read(_holder, block.nameIndex);
+    return _utf8ToString(nameBlock.nameUtf8);
+  }
+
+  ByteData _extentsToByteData(Block property) {
+    var payloadLength = property.propertyTotalLength;
+    var bytes = ByteData(payloadLength);
+    int amountCopied = 0;
+    for (Block extentBlock = Block.read(_holder, property.propertyExtentIndex);;
+        extentBlock = Block.read(property.vmo, extentBlock.nextExtent)) {
+      int copyEnd =
+          min(payloadLength, amountCopied + extentBlock.payloadSpaceBytes);
+      bytes.buffer.asUint8List().setRange(
+          amountCopied, copyEnd, extentBlock.payloadBytes.buffer.asUint8List());
+      if (extentBlock.nextExtent == 0) {
+        break;
+      }
+    }
+    return bytes;
+  }
+
+  String _extentsToString(Block property) {
+    return _utf8ToString(_extentsToByteData(property));
+  }
+}
+
+/// Matcher for a particular Node in the VMO.
+///
+/// NodeMatchers may be valid or invalid. A valid NodeMatcher refers to
+/// an actual node stored in the VMO, while an invalid NodeMatcher represents
+/// a node that could not be found. The creation of an invalid NodeMatcher
+/// records an error in the top-level matcher, and operations on it have
+/// no effect.
+class NodeMatcher implements _HasErrors {
+  final VmoMatcher _parent;
+  final int _index;
+  final bool _valid;
+
+  /// Creates a valid NodeMatcher with the given index.
+  NodeMatcher._valid(this._parent, this._index) : _valid = true;
+
+  /// Creates an invalid NodeMatcher.
+  NodeMatcher._invalid(this._parent)
+      : _valid = false,
+        _index = 0;
+
+  /// Get a NodeMatcher for the node at the given path below this one.
+  ///
+  /// If any step of the path could not be found or is not a node,
+  /// an error is recorded and an invalid NodeMatcher is returned.
+  NodeMatcher at(List<String> path) {
+    if (!_valid) {
+      // No error, it was already recorded by the creation of this.
+      return NodeMatcher._invalid(_parent);
+    }
+
+    int curIndex = _index;
+    for (var p in path) {
+      int next = _parent._findNamedValue(p, curIndex);
+      if (next == 0) {
+        _parent._addError('Cannot find node $p in $path');
+        return NodeMatcher._invalid(_parent);
+      } else if (Block.read(_parent._holder, next).type !=
+          BlockType.nodeValue) {
+        _parent._addError('Value $p in $path found, but it is not a node');
+        return NodeMatcher._invalid(_parent);
+      }
+
+      curIndex = next;
+    }
+    return NodeMatcher._valid(_parent, curIndex);
+  }
+
+  /// Asserts that this Node does not have the named child.
+  void missingChild(String name) {
+    if (!_valid) {
+      return;
+    }
+
+    int next = _parent._findNamedValue(name, _index);
+    if (next != 0) {
+      _parent._addError('Found $name which was expected to be missing');
+    }
+  }
+
+  /// Get a PropertyMatcher for a property on this node.
+  ///
+  /// If the property cannot be found or is not a value type, an invalid
+  /// PropertyMatcher is returned.
+  PropertyMatcher property(String name) {
+    if (!_valid) {
+      // No error, it would have been added creating the node itself.
+      return PropertyMatcher._invalid(_parent);
+    }
+
+    int valueIndex = _parent._findNamedValue(name, _index);
+    if (valueIndex == 0) {
+      _parent._addError('Cannot find property $name');
+      return PropertyMatcher._invalid(_parent);
+    } else if ([
+      BlockType.nodeValue,
+      BlockType.tombstone,
+      BlockType.nameUtf8,
+      BlockType.extent
+    ].contains(Block.read(_parent._holder, valueIndex).type)) {
+      _parent._addError('Value $name found, but it is not a property type');
+      return PropertyMatcher._invalid(_parent);
+    }
+
+    return PropertyMatcher._valid(_parent, valueIndex);
+  }
+
+  /// Checks that a property of this node equals the given value.
+  PropertyMatcher propertyEquals(String name, dynamic val) {
+    return property(name)..equals(val);
+  }
+
+  /// Checks that a property of this node exists but does not equal the given value.
+  PropertyMatcher propertyNotEquals(String name, dynamic val) {
+    return property(name)..notEquals(val);
+  }
+
+  /// Gets the list of errors.
+  @override
+  List<String> get errors => _parent._errors;
+
+  /// Resets the recorded errors from the parent matcher.
+  @override
+  void resetErrors() {
+    _parent.resetErrors();
+  }
+}
+
+/// Matcher for a particular Property in the VMO.
+///
+/// PropertyMatchers may be valid or invalid, with the semantics of
+/// NodeMatcher.
+class PropertyMatcher implements _HasErrors {
+  final VmoMatcher _parent;
+  final int _index;
+
+  // Create a valid PropertyMatcher for the given index.
+  PropertyMatcher._valid(this._parent, this._index);
+
+  // Create an invalid PropertyMatcher.
+  PropertyMatcher._invalid(this._parent) : _index = 0;
+
+  // Internal getter to check if this matcher is valid.
+  bool get _valid => _index != 0; // Properties cannot ever be at index 0.
+
+  void _internalEquals(dynamic val, bool invert) {
+    if (!_valid) {
+      return;
+    }
+
+    var negation = invert ? 'not ' : '';
+    // ignore: avoid_positional_boolean_parameters
+    bool maybeNegate(bool val) => (invert ? !val : val);
+
+    var block = Block.read(_parent._holder, _index);
+    if (val is int) {
+      if (block.type != BlockType.intValue) {
+        _parent
+            ._addError('Expected int ($val), found ${block.type.toString()}');
+      } else if (maybeNegate(block.intValue != val)) {
+        _parent._addError(
+            'Expected ${negation}value $val, found ${block.intValue}');
+      }
+    } else if (val is double) {
+      if (block.type != BlockType.doubleValue) {
+        _parent._addError(
+            'Expected double ($val), found ${block.type.toString()}');
+      } else if (maybeNegate(block.doubleValue != val)) {
+        _parent._addError(
+            'Expected ${negation}value $val, found ${block.doubleValue}');
+      }
+    } else if (val is String) {
+      if (block.type != BlockType.propertyValue) {
+        _parent._addError(
+            'Expected string ($val), found ${block.type.toString()}');
+      } else if (block.propertyFlags != propertyUtf8Flag) {
+        _parent._addError('Expected UTF-8 string, found binary');
+      } else {
+        var storedValue = _parent._extentsToString(block);
+        if (maybeNegate(storedValue != val)) {
+          _parent
+              ._addError('Expected ${negation}value $val, found $storedValue');
+        }
+      }
+    } else if (val is Uint8List) {
+      if (block.type != BlockType.propertyValue) {
+        _parent._addError(
+            'Expected bytes (${val.join(", ")}), found ${block.type.toString()}');
+      } else if (block.propertyFlags == propertyUtf8Flag) {
+        _parent._addError('Expected bytes string, found UTF-8');
+      } else {
+        var storedValue =
+            _parent._extentsToByteData(block).buffer.asUint8List();
+        if (maybeNegate(!ListEquality().equals(storedValue, val))) {
+          _parent._addError(
+              'Expected ${negation}value [${val.join(", ")}], found [${storedValue.join(", ")}]');
+        }
+      }
+    } else {
+      _parent._addError(
+          'Unknown type ${val.runtimeType} passed to matcher. Expected int, double, String, or Uint8List.');
+    }
+  }
+
+  /// Match that this property equals a value.
+  void equals(dynamic val) => _internalEquals(val, false);
+
+  /// Matches that this property exists but does not equal a value.
+  void notEquals(dynamic val) => _internalEquals(val, true);
+
+  /// Gets the list of errors.
+  @override
+  List<String> get errors => _parent._errors;
+
+  /// Resets the recorded errors from the parent matcher.
+  @override
+  void resetErrors() {
+    _parent.resetErrors();
+  }
+}
+
+class _HasNoErrorsMatcher extends Matcher {
+  const _HasNoErrorsMatcher();
+
+  @override
+  bool matches(dynamic item, Map<dynamic, dynamic> matchState) {
+    if (item is _HasErrors) {
+      return item.errors.isEmpty;
+    } else {
+      return false;
+    }
+  }
+
+  @override
+  Description describe(Description description) {
+    return description..add('The Inspect matcher has no recorded errors');
+  }
+
+  @override
+  Description describeMismatch(dynamic item, Description mismatchDescription,
+      Map<dynamic, dynamic> matchState, bool verbose) {
+    if (item is _HasErrors) {
+      var output = item.errors.join('\n- ');
+      mismatchDescription.add('Has errors:\n- $output');
+    } else {
+      mismatchDescription.add(
+          'The item being matched is not a VmoMatcher, NodeMatcher, or PropertyMatcher');
+    }
+    return mismatchDescription;
+  }
+}
+
+/// Matcher that asserts a VmoMatcher, NodeMatcher, or PropertyMatcher
+/// has no recorded errors.
+const Matcher hasNoErrors = _HasNoErrorsMatcher();
+
+String _utf8ToString(ByteData bytes) {
+  return utf8.decode(bytes.buffer
+      .asUint8ClampedList(bytes.offsetInBytes, bytes.lengthInBytes));
+}
diff --git a/public/dart/fuchsia_inspect/lib/src/testing/util.dart b/public/dart/fuchsia_inspect/lib/src/testing/util.dart
new file mode 100644
index 0000000..e03a660
--- /dev/null
+++ b/public/dart/fuchsia_inspect/lib/src/testing/util.dart
@@ -0,0 +1,58 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// ignore_for_file: implementation_imports
+
+import 'dart:typed_data';
+
+import 'package:zircon/zircon.dart';
+import 'package:fuchsia_inspect/src/vmo/vmo_holder.dart';
+
+/// A VmoHolder that simply wraps some ByteData.
+class FakeVmoHolder implements VmoHolder {
+  /// The memory contents of this "VMO".
+  final ByteData bytes;
+
+  @override
+  Vmo get vmo => null;
+
+  /// Size of the "VMO".
+  @override
+  final int size;
+
+  /// Wraps a [FakeVmoHolder] around the given data.
+  FakeVmoHolder.usingData(this.bytes) : size = bytes.lengthInBytes;
+
+  @override
+  void beginWork() {}
+
+  @override
+  void commit() {}
+
+  /// Writes to the "VMO".
+  @override
+  void write(int offset, ByteData data) {}
+
+  /// Reads from the "VMO".
+  @override
+  ByteData read(int offset, int size) {
+    var reading = ByteData(size);
+    reading.buffer
+        .asUint8List()
+        .setAll(0, bytes.buffer.asUint8List(offset, size));
+    return reading;
+  }
+
+  /// Writes int64 to VMO.
+  @override
+  void writeInt64(int offset, int value) {}
+
+  /// Writes int64 directly to VMO for immediate visibility.
+  @override
+  void writeInt64Direct(int offset, int value) {}
+
+  /// Reads int64 from VMO.
+  @override
+  int readInt64(int offset) => bytes.getInt64(offset, Endian.little);
+}
diff --git a/public/dart/fuchsia_inspect/lib/testing.dart b/public/dart/fuchsia_inspect/lib/testing.dart
new file mode 100644
index 0000000..f17acca
--- /dev/null
+++ b/public/dart/fuchsia_inspect/lib/testing.dart
@@ -0,0 +1,9 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Testing matchers for Inspect Dart.
+export 'src/testing/matcher.dart';
+
+// Utility classes, like FakeVmoHolder.
+export 'src/testing/util.dart';