[dart][inspect] Example app with tests

fx set core.chromebook-x64 --with '//topaz/bundles:buildbot' --with //bundles:kitchen_sink --with //topaz/public/dart/fuchsia_inspect/examples/inspect_mod:inspect_mod --with //topaz/public/dart/fuchsia_inspect/examples/inspect_mod:inspect_mod_test

fx run-host-tests fuchsia_inspect_package_unittests

fx run-test inspect_mod_test

Test: see above

CF-597 #progress

Change-Id: Idae642d35dd4bccf7c20512700fb32e01d80d9f6
diff --git a/packages/examples/BUILD.gn b/packages/examples/BUILD.gn
index 9b563dc..0abfc71 100644
--- a/packages/examples/BUILD.gn
+++ b/packages/examples/BUILD.gn
@@ -59,6 +59,8 @@
     "//topaz/examples/test/driver_example_mod:driver_example_mod_tests",
     "//topaz/public/dart/fuchsia_modular/examples/slider_mod",
     "//topaz/public/dart/fuchsia_modular/examples/slider_mod:slider_mod_tests",
+    "//topaz/public/dart/fuchsia_inspect/examples/inspect_mod",
+    "//topaz/public/dart/fuchsia_inspect/examples/inspect_mod:inspect_mod_test",
   ]
 }
 
diff --git a/public/dart/fuchsia_inspect/BUILD.gn b/public/dart/fuchsia_inspect/BUILD.gn
index ddc149e..dde9b6a 100644
--- a/public/dart/fuchsia_inspect/BUILD.gn
+++ b/public/dart/fuchsia_inspect/BUILD.gn
@@ -30,7 +30,6 @@
   deps = [
     "//third_party/dart-pkg/pub/meta",
     "//topaz/public/dart/fuchsia_services",
-    "//topaz/public/dart/fuchsia_vfs",
     "//topaz/public/dart/zircon",
   ]
 }
@@ -40,8 +39,8 @@
 
 dart_test("fuchsia_inspect_package_unittests") {
   sources = [
-    "inspect/inspect_test.dart",
     "inspect/internal/inspect_impl_test.dart",
+    "inspect/inspect_test.dart",
     "inspect/node_test.dart",
     "inspect/property_test.dart",
     "integration/writer.dart",
diff --git a/public/dart/fuchsia_inspect/examples/inspect_mod/BUILD.gn b/public/dart/fuchsia_inspect/examples/inspect_mod/BUILD.gn
index e0e55e8..46ecfc0 100644
--- a/public/dart/fuchsia_inspect/examples/inspect_mod/BUILD.gn
+++ b/public/dart/fuchsia_inspect/examples/inspect_mod/BUILD.gn
@@ -3,6 +3,7 @@
 # found in the LICENSE file.
 
 import("//topaz/runtime/flutter_runner/flutter_app.gni")
+import("//topaz/runtime/dart/dart_fuchsia_test.gni")
 
 flutter_app("inspect_mod") {
   main_dart = "lib/main.dart"
@@ -10,6 +11,8 @@
 
   fuchsia_package_name = "inspect_mod"
 
+  flutter_driver_extendable = true
+
   meta = [
     {
       path = rebase_path("meta/inspect_mod.cmx")
@@ -25,3 +28,38 @@
     "//topaz/public/dart/fuchsia_modular",
   ]
 }
+
+dart_fuchsia_test("inspect_mod_test") {
+  sources = [
+    "inspect_mod_test.dart",
+    "util.dart",
+    "vmo_reader.dart"
+  ]
+
+  deps = [
+    "//sdk/fidl/fuchsia.modular.testing",
+    "//sdk/fidl/fuchsia.sys",
+    "//third_party/dart-pkg/git/flutter/packages/flutter_driver",
+    "//third_party/dart-pkg/pub/test",
+    "//topaz/public/dart/fuchsia_inspect",
+    "//topaz/public/dart/fuchsia_services",
+  ]
+
+  meta = [
+    {
+      path = rebase_path("meta/inspect_mod_test.cmx")
+      dest = "inspect_mod_test.cmx"
+    },
+  ]
+
+  environments = []
+
+  # Flutter driver is only available in debug builds, so don't try to run in
+  # release CI/CQ.
+  if (is_debug) {
+    environments += [
+      nuc_env,
+      vim2_env,
+    ]
+  }
+}
diff --git a/public/dart/fuchsia_inspect/examples/inspect_mod/lib/src/inspect_example_app.dart b/public/dart/fuchsia_inspect/examples/inspect_mod/lib/src/inspect_example_app.dart
index 0d6973b..ad1d6ef 100644
--- a/public/dart/fuchsia_inspect/examples/inspect_mod/lib/src/inspect_example_app.dart
+++ b/public/dart/fuchsia_inspect/examples/inspect_mod/lib/src/inspect_example_app.dart
@@ -2,11 +2,21 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
+import 'dart:async';
+import 'dart:developer' show Timeline;
+import 'dart:typed_data';
+
+import 'package:async/async.dart';
 import 'package:flutter/material.dart';
 import 'package:fuchsia_inspect/inspect.dart' as inspect;
+import 'package:fuchsia_logger/logger.dart';
 
 /// A Flutter app that demonstrates usage of the [Inspect] API.
 class InspectExampleApp extends StatelessWidget {
+  /// Call InspectExampleApp.stateBloc.updateValue('new state') to display and
+  /// key-publish 'new state'.
+  static final StateBloc stateBloc = StateBloc();
+
   static const _appColor = Colors.blue;
 
   final inspect.Node _inspectNode;
@@ -30,7 +40,48 @@
 
   /// Initializes the [Inspect] properties for this widget.
   void _initProperties() {
-    _inspectNode.stringProperty('app-color').setValue('$_appColor');
+    _inspectNode.stringProperty('greeting').setValue('Hello World');
+    _inspectNode.doubleProperty('double down')
+      ..setValue(1.23)
+      ..add(2);
+    _inspectNode.intProperty('interesting')
+      ..setValue(123)
+      ..subtract(5);
+    _inspectNode
+        .byteDataProperty('bytes')
+        .setValue(ByteData(4)..setUint32(0, 0x01020304));
+  }
+}
+
+/// The [StateBloc] provides actions and streams associated with
+/// the agent that displays state on-screen and exports state keys for test.
+class StateBloc {
+  final _valueController = StreamController<String>.broadcast();
+  String _lastKnownValue = 'Program has started';
+
+  Stream<String> get valueStream => _valueController.stream;
+  String get currentValue => _lastKnownValue;
+
+  void updateValue(String newState) {
+    _lastKnownValue = newState;
+    _valueController.add(newState);
+  }
+
+  void dispose() {
+    _valueController.close();
+  }
+}
+
+class _AnswerFinder {
+  static final _funnel = StreamController<int>();
+  static final _faucet = StreamQueue<int>(_funnel.stream);
+
+  Future<int> getTheAnswer() async {
+    return await _faucet.next;
+  }
+
+  void takeAHint(int n) async {
+    _funnel.add(n);
   }
 }
 
@@ -54,7 +105,15 @@
     Colors.orange,
   ];
 
+  // Helpers to demo tree building and deletion
   final inspect.Node _inspectNode;
+  inspect.Node _subtree;
+  int _id = 0;
+
+  // Helpers to demo auto-deletion lifecycle
+  final _answerFinder = _AnswerFinder();
+  int _nextHint = 40;
+  String _answer = 'No answer requested yet';
 
   /// A property that tracks [_counter].
   final inspect.IntProperty _counterProperty;
@@ -80,8 +139,11 @@
       // to the new value:
       //
       //     _counterProperty.setValue(_counter);
-      _counterProperty.add(1);
+      Timeline.timeSync('Inc counter', () {
+        _counterProperty.add(1);
+      });
     });
+    InspectExampleApp.stateBloc.updateValue('Counter was incremented');
   }
 
   void _decrementCounter() {
@@ -89,6 +151,7 @@
       _counter--;
       _counterProperty.subtract(1);
     });
+    InspectExampleApp.stateBloc.updateValue('Counter was decremented');
   }
 
   /// Increments through the possible [_colors].
@@ -98,22 +161,75 @@
     setState(() {
       _colorIndex++;
 
-      if (_colorIndex >= _colors.length) {
-        _colorIndex = 0;
-
-        // Contrived example of removing an Inspect property:
-        // Once we've looped through the colors once, delete the  to.
-        //
-        // A more realistic example would be if something were being removed
-        // from the UI, but this is intended to be super simple.
-        _backgroundProperty.delete();
-        // Setting _backgroundProperty to null is optional; it's fine to
-        // call setValue() on a deleted property - it will just have no effect.
-        _backgroundProperty = null;
-      }
+      _colorIndex %= _colors.length;
 
       _backgroundProperty?.setValue('$_backgroundColor');
     });
+    InspectExampleApp.stateBloc.updateValue('Color was changed');
+  }
+
+  void _makeTree() {
+    _subtree = _inspectNode.child('I think that I shall never see')
+      ..intProperty('int$_id').setValue(_id++);
+    InspectExampleApp.stateBloc.updateValue('Tree was made');
+  }
+
+  void _addToTree() {
+    _subtree?.intProperty('int$_id')?.setValue(_id++);
+    InspectExampleApp.stateBloc.updateValue('Tree was grown');
+  }
+
+  void _deleteTree() {
+    _subtree?.delete();
+    InspectExampleApp.stateBloc.updateValue('Tree was deleted');
+  }
+
+  void _giveHint() {
+    _answerFinder.takeAHint(_nextHint++);
+    InspectExampleApp.stateBloc.updateValue('Gave a hint');
+  }
+
+  void _showAnswer() {
+    var answerFuture = _answerFinder.getTheAnswer();
+    var wait = _inspectNode.stringProperty('waiting')..setValue('for a hint');
+    answerFuture.whenComplete(wait.delete);
+    setState(() {
+      _answer = 'Waiting for answer';
+    });
+    InspectExampleApp.stateBloc.updateValue('Waiting for answer');
+    answerFuture.then((answer) {
+      setState(() {
+        _answer = 'Answer is: $answer';
+      });
+      InspectExampleApp.stateBloc.updateValue('Displayed answer');
+    }).catchError(
+      (e, s) {
+        log.info(' * * Hi2 from inspect_mod');
+        setState(() {
+          _answer = 'Something went wrong getting answer:\n$e\n$s';
+        });
+      },
+    );
+  }
+
+  StreamBuilder<String> buildProgramStateWidget() {
+    var stateBloc = InspectExampleApp.stateBloc;
+    return StreamBuilder<String>(
+        stream: stateBloc.valueStream,
+        initialData: stateBloc.currentValue,
+        builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
+          if (snapshot.data == '') {
+            // don't display anything
+            return Offstage();
+          } else {
+            return Container(
+              alignment: Alignment.center,
+              child: Text('State: ${snapshot.data}',
+                  style: Theme.of(context).textTheme.display1),
+              key: Key(snapshot.data),
+            );
+          }
+        });
   }
 
   @override
@@ -126,14 +242,35 @@
       ),
       backgroundColor: _backgroundColor,
       body: Center(
-        child: Text(
-          'Counter: $_counter.',
-        ),
-      ),
+          child: Column(children: [
+        buildProgramStateWidget(),
+        Text('Counter: $_counter', style: Theme.of(context).textTheme.display2),
+        Text('$_answer', style: Theme.of(context).textTheme.display2),
+      ])),
       persistentFooterButtons: <Widget>[
         FlatButton(
+          onPressed: _giveHint,
+          child: Text('Give hint'),
+        ),
+        FlatButton(
+          onPressed: _showAnswer,
+          child: Text('Get answer'),
+        ),
+        FlatButton(
           onPressed: _changeBackground,
-          child: Text('Change background color'),
+          child: Text('Change color'),
+        ),
+        FlatButton(
+          onPressed: _makeTree,
+          child: Text('Make tree'),
+        ),
+        FlatButton(
+          onPressed: _addToTree,
+          child: Text('Grow tree'),
+        ),
+        FlatButton(
+          onPressed: _deleteTree,
+          child: Text('Delete tree'),
         ),
         FlatButton(
           onPressed: _incrementCounter,
diff --git a/public/dart/fuchsia_inspect/examples/inspect_mod/meta/inspect_mod_test.cmx b/public/dart/fuchsia_inspect/examples/inspect_mod/meta/inspect_mod_test.cmx
new file mode 100644
index 0000000..a5bad84
--- /dev/null
+++ b/public/dart/fuchsia_inspect/examples/inspect_mod/meta/inspect_mod_test.cmx
@@ -0,0 +1,33 @@
+{
+    "facets": {
+        "fuchsia.test": {
+            "injected-services": {
+                "fuchsia.auth.account.AccountManager": "fuchsia-pkg://fuchsia.com/account_manager#meta/account_manager.cmx",
+                "fuchsia.devicesettings.DeviceSettingsManager": "fuchsia-pkg://fuchsia.com/device_settings_manager#meta/device_settings_manager.cmx",
+                "fuchsia.fonts.Provider": "fuchsia-pkg://fuchsia.com/fonts#meta/fonts.cmx",
+                "fuchsia.sysmem.Allocator": "fuchsia-pkg://fuchsia.com/sysmem_connector#meta/sysmem_connector.cmx",
+                "fuchsia.tracelink.Registry": "fuchsia-pkg://fuchsia.com/trace_manager#meta/trace_manager.cmx",
+                "fuchsia.ui.input.ImeService": "fuchsia-pkg://fuchsia.com/ime_service#meta/ime_service.cmx",
+                "fuchsia.ui.policy.Presenter": "fuchsia-pkg://fuchsia.com/root_presenter#meta/root_presenter.cmx",
+                "fuchsia.ui.scenic.Scenic": "fuchsia-pkg://fuchsia.com/scenic#meta/scenic.cmx",
+                "fuchsia.vulkan.loader.Loader": "fuchsia-pkg://fuchsia.com/vulkan_loader#meta/vulkan_loader.cmx"
+            },
+            "system-services": [
+                "fuchsia.net.SocketProvider"
+            ]
+        }
+    },
+    "program": {
+        "data": "data/inspect_mod_test"
+    },
+    "sandbox": {
+        "features": [
+            "shell"
+        ],
+        "services": [
+            "fuchsia.net.SocketProvider",
+            "fuchsia.sys.Launcher",
+            "fuchsia.sys.Environment"
+        ]
+    }
+}
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
new file mode 100644
index 0000000..c6cfbae
--- /dev/null
+++ b/public/dart/fuchsia_inspect/examples/inspect_mod/test/inspect_mod_test.dart
@@ -0,0 +1,229 @@
+// 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:io' as dartio;
+import 'dart:typed_data';
+import 'package:fidl_fuchsia_modular/fidl_async.dart';
+import 'package:fidl_fuchsia_modular_testing/fidl_async.dart';
+import 'package:fidl_fuchsia_sys/fidl_async.dart';
+import 'package:flutter_driver/flutter_driver.dart';
+import 'package:fuchsia_remote_debug_protocol/logging.dart';
+import 'package:fuchsia_services/services.dart';
+import 'package:glob/glob.dart';
+import 'package:test/test.dart';
+import 'util.dart';
+
+import 'vmo_reader.dart' show VmoReader;
+
+const Pattern _testAppName = 'inspect_mod.cmx';
+const _testAppUrl = 'fuchsia-pkg://fuchsia.com/inspect_mod#meta/$_testAppName';
+const _modularTestHarnessURL =
+    'fuchsia-pkg://fuchsia.com/modular_test_harness#meta/modular_test_harness.cmx';
+
+TestHarnessProxy testHarnessProxy = TestHarnessProxy();
+ComponentControllerProxy testHarnessController = ComponentControllerProxy();
+
+// TODO(CF-603) Replace the test-harness / launch-mod boilerplate when possible.
+// Starts Modular TestHarness with dev shells. This should be called from within
+// a try/finally or similar construct that closes the component controller.
+Future<void> _startTestHarness() async {
+  final launcher = LauncherProxy();
+  final incoming = Incoming();
+
+  // launch TestHarness component
+  StartupContext.fromStartupInfo().incoming.connectToService(launcher);
+  await launcher.createComponent(
+      LaunchInfo(
+          url: _modularTestHarnessURL,
+          directoryRequest: incoming.request().passChannel()),
+      testHarnessController.ctrl.request());
+
+  // connect to TestHarness service
+  incoming.connectToService(testHarnessProxy);
+
+  // helper function to convert a map of service to url into list of
+  // [InjectedService]
+  List<InjectedService> _toInjectedServices(Map<String, String> serviceToUrl) {
+    final injectedServices = <InjectedService>[];
+    for (final svcName in serviceToUrl.keys) {
+      injectedServices
+          .add(InjectedService(name: svcName, url: serviceToUrl[svcName]));
+    }
+    return injectedServices;
+  }
+
+  final testHarnessSpec = TestHarnessSpec(
+      envServicesToInherit: ['fuchsia.net.SocketProvider'],
+      envServicesToInject: _toInjectedServices(
+        {
+          'fuchsia.auth.account.AccountManager':
+              'fuchsia-pkg://fuchsia.com/account_manager#meta/account_manager.cmx',
+          'fuchsia.devicesettings.DeviceSettingsManager':
+              'fuchsia-pkg://fuchsia.com/device_settings_manager#meta/device_settings_manager.cmx',
+          'fuchsia.fonts.Provider':
+              'fuchsia-pkg://fuchsia.com/fonts#meta/fonts.cmx',
+          'fuchsia.sysmem.Allocator':
+              'fuchsia-pkg://fuchsia.com/sysmem_connector#meta/sysmem_connector.cmx',
+          'fuchsia.tracelink.Registry':
+              'fuchsia-pkg://fuchsia.com/trace_manager#meta/trace_manager.cmx',
+          'fuchsia.ui.input.ImeService':
+              'fuchsia-pkg://fuchsia.com/ime_service#meta/ime_service.cmx',
+          'fuchsia.ui.policy.Presenter':
+              'fuchsia-pkg://fuchsia.com/root_presenter#meta/root_presenter.cmx',
+          'fuchsia.ui.scenic.Scenic':
+              'fuchsia-pkg://fuchsia.com/scenic#meta/scenic.cmx',
+          'fuchsia.vulkan.loader.Loader':
+              'fuchsia-pkg://fuchsia.com/vulkan_loader#meta/vulkan_loader.cmx'
+        },
+      ));
+
+  // run the test harness which will create an encapsulated test env
+  await testHarnessProxy.run(testHarnessSpec);
+}
+
+Future<void> _launchModUnderTest() async {
+  // get the puppetMaster service from the encapsulated test env
+  final puppetMasterProxy = PuppetMasterProxy();
+  await testHarnessProxy.connectToModularService(
+      ModularService.withPuppetMaster(puppetMasterProxy.ctrl.request()));
+  // use puppetMaster to start a fake story an launch the mod under test
+  final storyPuppetMasterProxy = StoryPuppetMasterProxy();
+  await puppetMasterProxy.controlStory(
+      'fooStoryName', storyPuppetMasterProxy.ctrl.request());
+  await storyPuppetMasterProxy.enqueue(<StoryCommand>[
+    StoryCommand.withAddMod(AddMod(
+        modName: ['inspect_mod'],
+        modNameTransitional: 'root',
+        intent: Intent(action: 'action', handler: _testAppUrl),
+        surfaceRelation: SurfaceRelation()))
+  ]);
+  await storyPuppetMasterProxy.execute();
+}
+
+Future<String> _readInspect() async {
+  String globString =
+      '/hub/r/modular_test_harness_*/*/r/session-*/*/r/*/*/c/flutter*/*/c/$_testAppName/*/out/debug/*';
+  String fileName = (await Glob(globString).list().toList())[0].path;
+  var f = dartio.File(fileName);
+  // WARNING: 1) This read is not atomic.
+  // WARNING: 2) This won't work with VMOs written in C++ and maybe elsewhere.
+  // TODO(CF-603): Use direct VMO read when possible.
+  var vmoBytes = await f.readAsBytes();
+  var vmoData = ByteData(vmoBytes.length);
+  for (int i = 0; i < vmoBytes.length; i++) {
+    vmoData.setUint8(i, vmoBytes[i]);
+  }
+  var vmo = FakeVmoReader.usingData(vmoData);
+  return VmoReader(vmo).toString();
+}
+
+void main() {
+  final controller = ComponentControllerProxy();
+  FlutterDriver driver;
+
+  // The following boilerplate is a one time setup required to make
+  // flutter_driver work in Fuchsia.
+  //
+  // When a module built using Flutter starts up in debug mode, it creates an
+  // instance of the Dart VM, and spawns an Isolate (isolated Dart execution
+  // context) containing your module.
+  setUpAll(() async {
+    Logger.globalLevel = LoggingLevel.all;
+
+    await _startTestHarness();
+    await _launchModUnderTest();
+
+    // Creates an object you can use to search for your mod on the machine
+    driver = await FlutterDriver.connect(
+        fuchsiaModuleTarget: _testAppName,
+        printCommunication: true,
+        logCommunicationToFile: false);
+  });
+
+  tearDownAll(() async {
+    await driver?.close();
+    controller.ctrl.close();
+
+    testHarnessProxy.ctrl.close();
+    testHarnessController.ctrl.close();
+  });
+
+  Future<String> tapAndWait(String buttonName, String nextState) async {
+    await driver.tap(find.text(buttonName));
+    await driver.waitFor(find.byValueKey(nextState));
+    return await _readInspect();
+  }
+
+  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();
+/* Expected "inspect" contents (order not guaranteed):
+<> Node: "root"
+<> >> IntProperty "interesting": 118
+<> >> DoubleProperty "double down": 3.23
+<> >> ByteDataProperty  "bytes": 01 02 03 04
+<> >> StringProperty "greeting": "Hello World"
+<> >> Node: "home-page"
+<> >> >> IntProperty "counter": 0
+<> >> >> StringProperty "background-color": "Color(0xffffffff)"
+<> >> >> StringProperty "title": "Hello Inspect!"
+*/
+    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));
+
+    // 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));
+
+    // 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));
+
+    var preTreeInspect = inspect;
+    inspect = await tapAndWait('Make tree', 'Tree was made');
+    expect(
+        '<> >> >> Node: "I think that I shall nev"\n'
+        '<> >> >> >> IntProperty "int0": 0',
+        isIn(inspect));
+
+    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');
+    expect(
+        '<> >> >> Node: "I think that I shall nev"\n'
+        '<> >> >> >> IntProperty "int3": 3',
+        isIn(inspect));
+
+    inspect = await tapAndWait('Get answer', 'Waiting for answer');
+    expect('>> StringProperty "waiting": "for a hint"', isIn(inspect));
+
+    inspect = await tapAndWait('Give hint', 'Displayed answer');
+    expect('>> StringProperty "waiting": "for a hint"', isNot(isIn(inspect)));
+
+    inspect = await tapAndWait('Change color', 'Color was changed');
+    expect('<> >> >> StringProperty "background-color": "Color(0xffffffff)"',
+        isNot(isIn(inspect)));
+    expect('<> >> >> StringProperty "background-color": "', isIn(inspect));
+  });
+}
diff --git a/public/dart/fuchsia_inspect/examples/inspect_mod/test/util.dart b/public/dart/fuchsia_inspect/examples/inspect_mod/test/util.dart
new file mode 100644
index 0000000..e2d407d
--- /dev/null
+++ b/public/dart/fuchsia_inspect/examples/inspect_mod/test/util.dart
@@ -0,0 +1,57 @@
+// 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';
+
+class FakeVmoReader 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 [FakeVmoReader] around the given data.
+  FakeVmoReader.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/examples/inspect_mod/test/vmo_reader.dart b/public/dart/fuchsia_inspect/examples/inspect_mod/test/vmo_reader.dart
new file mode 100644
index 0000000..1965196
--- /dev/null
+++ b/public/dart/fuchsia_inspect/examples/inspect_mod/test/vmo_reader.dart
@@ -0,0 +1,199 @@
+// 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, avoid_relative_lib_imports
+
+import 'dart:convert' show utf8;
+import 'dart:math' show min, max;
+import 'dart:typed_data';
+
+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';
+import 'package:fuchsia_inspect/src/vmo/vmo_writer.dart';
+
+String _utf8ToString(ByteData bytes) {
+  return utf8.decode(bytes.buffer
+      .asUint8ClampedList(bytes.offsetInBytes, bytes.lengthInBytes));
+}
+
+class _Metric {
+  num value;
+
+  _INode parent;
+
+  String _name;
+
+  String get name => _name;
+
+  BlockType _type;
+
+  BlockType get type => _type;
+
+  _Metric(Block metric) {
+    var nameBlock = Block.read(metric.vmo, metric.nameIndex);
+    _name = _utf8ToString(nameBlock.nameUtf8);
+    _type = metric.type;
+    switch (metric.type) {
+      case BlockType.doubleValue:
+        value = metric.doubleValue;
+        break;
+      case BlockType.intValue:
+        value = metric.intValue;
+        break;
+      case BlockType.uintValue:
+        throw StateError('Dart does not expect uint metrics.');
+      default:
+        throw StateError('Type was ${metric.type.name} not numeric.');
+    }
+  }
+
+  @override
+  String toString() =>
+      '${_type == BlockType.intValue ? 'Int' : 'Double'}Property "$_name": $value';
+}
+
+class _Property {
+  _INode parent;
+
+  String _name;
+
+  String get name => _name;
+
+  final int bytesToPrint = 20;
+
+  bool isString;
+
+  int payloadLength;
+
+  ByteData bytes;
+
+  _Property(Block property) {
+    var nameBlock = Block.read(property.vmo, property.nameIndex);
+    _name = _utf8ToString(nameBlock.nameUtf8);
+    isString = property.propertyFlags == propertyUtf8Flag;
+    payloadLength = property.propertyTotalLength;
+    bytes = ByteData(payloadLength);
+    int amountCopied = 0;
+    for (Block extentBlock =
+            Block.read(property.vmo, 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 == invalidIndex) {
+        break;
+      }
+    }
+  }
+
+  @override
+  String toString() {
+    if (isString) {
+      return 'StringProperty "$name": "${_utf8ToString(bytes)}"';
+    } else {
+      var buffer = StringBuffer('ByteDataProperty  "$name":');
+      for (int i = 0; i < bytesToPrint && i < payloadLength; i++) {
+        buffer.write(' ${bytes.getUint8(i).toRadixString(16).padLeft(2, '0')}');
+      }
+      return buffer.toString();
+    }
+  }
+}
+
+class _INode {
+  _INode parent;
+
+  List<_INode> children = <_INode>[];
+
+  List<_Metric> metrics = <_Metric>[];
+
+  List<_Property> properties = <_Property>[];
+
+  String _name;
+
+  String get name => _name;
+
+  void setFrom(Block block) {
+    var nameBlock = Block.read(block.vmo, block.nameIndex);
+    _name = _utf8ToString(nameBlock.nameUtf8);
+  }
+
+  @override
+  String toString() => 'Node: "$_name"';
+
+  String treeToString(String indentStep, [String currentIndent = '']) {
+    var buffer = StringBuffer()
+      ..write(currentIndent)
+      ..writeln(this);
+    String nextIndent = '$currentIndent$indentStep';
+    for (var metric in metrics) {
+      buffer
+        ..write(nextIndent)
+        ..writeln(metric);
+    }
+    for (var property in properties) {
+      buffer
+        ..write(nextIndent)
+        ..writeln(property);
+    }
+    for (var node in children) {
+      buffer.write(node.treeToString(indentStep, nextIndent));
+    }
+    return buffer.toString();
+  }
+}
+
+class _NodeTree {
+  final _nodes = <int, _INode>{};
+  final VmoHolder vmo;
+
+  _NodeTree(this.vmo);
+
+  _INode node(int index) {
+    if (!_nodes.containsKey(index)) {
+      _nodes[index] = _INode();
+    }
+    return _nodes[index];
+  }
+}
+
+class VmoReader {
+  final _NodeTree _nodes;
+
+  VmoReader(VmoHolder vmo) : _nodes = _NodeTree(vmo) {
+    for (int readPosBytes = 0; readPosBytes < vmo.size;) {
+      var block = Block.read(vmo, readPosBytes ~/ bytesPerIndex);
+      switch (block.type) {
+        case BlockType.nodeValue:
+          _INode parentNode = _nodes.node(block.parentIndex);
+          _INode node = _nodes.node(block.index)
+            ..setFrom(block)
+            ..parent = parentNode;
+          parentNode.children.add(node);
+          break;
+        case BlockType.propertyValue:
+          _INode parentNode = _nodes.node(block.parentIndex);
+          var property = _Property(block);
+          property.parent = parentNode;
+          parentNode.properties.add(property);
+          break;
+        case BlockType.intValue:
+        case BlockType.doubleValue:
+          _INode parentNode = _nodes.node(block.parentIndex);
+          var metric = _Metric(block);
+          metric.parent = parentNode;
+          parentNode.metrics.add(metric);
+          break;
+      }
+      readPosBytes += max(block.size, 16);
+    }
+  }
+
+  _INode get _rootNode => _nodes.node(rootNodeIndex);
+
+  @override
+  String toString() => _rootNode.treeToString('>> ', '<> ');
+}
diff --git a/public/dart/fuchsia_inspect/lib/src/vmo/block.dart b/public/dart/fuchsia_inspect/lib/src/vmo/block.dart
index b959501..43911aa 100644
--- a/public/dart/fuchsia_inspect/lib/src/vmo/block.dart
+++ b/public/dart/fuchsia_inspect/lib/src/vmo/block.dart
@@ -25,6 +25,10 @@
   /// Index of the block within the VMO
   final int index;
 
+  /// The VMO this Block lives inside.
+  @visibleForTesting
+  VmoHolder get vmo => _vmo;
+
   /// Initializes an empty [BlockType.reserved] block that isn't in the VMO yet.
   Block.create(this._vmo, this.index) {
     _header