Merge pull request #12 from DrMarcII/dom_model

Add dom_model and allow multiplexor to use it so that multiple connec…
diff --git a/bin/multiplex.dart b/bin/multiplex.dart
index 56899ed..2ea011c 100644
--- a/bin/multiplex.dart
+++ b/bin/multiplex.dart
@@ -3,21 +3,25 @@
 
 library wip.multiplex;
 
-import 'dart:async';
+import 'dart:async' show Future;
 import 'dart:convert' show JSON;
 import 'dart:io' show HttpClientResponse, HttpServer, InternetAddress, stderr;
 
-import 'package:args/args.dart';
-import 'package:logging/logging.dart';
+import 'package:args/args.dart' show ArgParser;
+import 'package:logging/logging.dart'
+    show hierarchicalLoggingEnabled, Level, Logger, LogRecord;
 import 'package:shelf/shelf.dart' as shelf;
 import 'package:shelf/shelf_io.dart' as io;
 import 'package:shelf_web_socket/shelf_web_socket.dart' as ws;
-import 'package:webkit_inspection_protocol/forwarder.dart';
-import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart';
+import 'package:webkit_inspection_protocol/dom_model.dart' show WipDomModel;
+import 'package:webkit_inspection_protocol/forwarder.dart' show WipForwarder;
+import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart'
+    show ChromeConnection, ChromeTab, WipConnection;
 
 main(List<String> argv) async {
   var args = (new ArgParser()
     ..addFlag('verbose', abbr: 'v', defaultsTo: false, negatable: false)
+    ..addFlag('model_dom', defaultsTo: true, negatable: true)
     ..addOption('chrome_host', defaultsTo: 'localhost')
     ..addOption('chrome_port', defaultsTo: '9222')
     ..addOption('listen_port', defaultsTo: '9223')).parse(argv);
@@ -36,7 +40,7 @@
 
   var cr =
       new ChromeConnection(args['chrome_host'], int.parse(args['chrome_port']));
-  new Server(int.parse(args['listen_port']), cr);
+  new Server(int.parse(args['listen_port']), cr, modelDom: args['model_dom']);
 }
 
 class Server {
@@ -45,10 +49,12 @@
   Future<HttpServer> _server;
   final ChromeConnection chrome;
   final int port;
+  final bool modelDom;
 
   final _connections = <String, Future<WipConnection>>{};
+  final _modelDoms = <String, WipDomModel>{};
 
-  Server(this.port, this.chrome) {
+  Server(this.port, this.chrome, {this.modelDom}) {
     _server = io.serve(_handler, InternetAddress.ANY_IP_V4, port);
   }
 
@@ -130,6 +136,7 @@
   Future<shelf.Response> _forward(shelf.Request request) async {
     _log.info('forwarding: ${request.url}');
     var dtResp = await chrome.getUrl(request.url.path);
+
     if (dtResp.statusCode == 200) {
       return new shelf.Response.ok(dtResp,
           headers: {'Content-Type': dtResp.headers.contentType.toString()});
@@ -151,9 +158,16 @@
         var tab = await chrome.getTab((tab) => tab.id == path[2]);
         return WipConnection.connect(tab.webSocketDebuggerUrl);
       });
-      var forwarder = new ChromeForwarder(debugger, ws);
+      var dom;
+      if (modelDom) {
+        dom = await _modelDoms.putIfAbsent(path[2], () {
+          return new WipDomModel(debugger.dom);
+        });
+      }
+      var forwarder = new WipForwarder(debugger, ws, domModel: dom);
       debugger.onClose.listen((_) {
         _connections.remove(path[2]);
+        _modelDoms.remove(path[2]);
         forwarder.stop();
       });
     })(request);
diff --git a/lib/dom_model.dart b/lib/dom_model.dart
new file mode 100644
index 0000000..0ddea83
--- /dev/null
+++ b/lib/dom_model.dart
@@ -0,0 +1,333 @@
+// Copyright 2015 Google. All rights reserved. Use of this source code is
+// governed by a BSD-style license that can be found in the LICENSE file.
+
+library wip.dom_model;
+
+import 'dart:async' show EventSink, Future, Stream, StreamTransformer;
+import 'dart:collection' show UnmodifiableListView, UnmodifiableMapView;
+import 'dart:mirrors' show reflect;
+
+import 'package:logging/logging.dart' show Logger;
+
+import 'webkit_inspection_protocol.dart'
+    show
+        AttributeModifiedEvent,
+        AttributeRemovedEvent,
+        CharacterDataModifiedEvent,
+        ChildNodeCountUpdatedEvent,
+        ChildNodeInsertedEvent,
+        ChildNodeRemovedEvent,
+        DocumentUpdatedEvent,
+        Node,
+        SetChildNodesEvent,
+        WipDom,
+        WipEvent;
+
+/// Implementation of WipDom that maintains and updates a model of the DOM
+/// based on incoming events.
+class WipDomModel implements WipDom {
+  static final _log = new Logger('WipDomModel');
+
+  final WipDom _dom;
+
+  Map<int, _Node> _nodeCache = {};
+  Future<_Node> _root;
+
+  Stream<AttributeModifiedEvent> onAttributeModified;
+  Stream<AttributeRemovedEvent> onAttributeRemoved;
+  Stream<CharacterDataModifiedEvent> onCharacterDataModified;
+  Stream<ChildNodeCountUpdatedEvent> onChildNodeCountUpdated;
+  Stream<ChildNodeInsertedEvent> onChildNodeInserted;
+  Stream<ChildNodeRemovedEvent> onChildNodeRemoved;
+  Stream<DocumentUpdatedEvent> onDocumentUpdated;
+  Stream<SetChildNodesEvent> onSetChildNodes;
+
+  WipDomModel(this._dom) {
+    onAttributeModified = new StreamTransformer.fromHandlers(
+        handleData: _onAttributeModified).bind(_dom.onAttributeModified)
+      ..listen(_logEvent);
+    onAttributeRemoved = new StreamTransformer.fromHandlers(
+        handleData: _onAttributeRemoved).bind(_dom.onAttributeRemoved)
+      ..listen(_logEvent);
+    onCharacterDataModified = new StreamTransformer.fromHandlers(
+        handleData: _onCharacterDataModified).bind(_dom.onCharacterDataModified)
+      ..listen(_logEvent);
+    onChildNodeCountUpdated = new StreamTransformer.fromHandlers(
+        handleData: _onChildNodeCountUpdated).bind(_dom.onChildNodeCountUpdated)
+      ..listen(_logEvent);
+    onChildNodeInserted = new StreamTransformer.fromHandlers(
+        handleData: _onChildNodeInserted).bind(_dom.onChildNodeInserted)
+      ..listen(_logEvent);
+    onChildNodeRemoved = new StreamTransformer.fromHandlers(
+        handleData: _onChildNodeRemoved).bind(_dom.onChildNodeRemoved)
+      ..listen(_logEvent);
+    onDocumentUpdated = new StreamTransformer.fromHandlers(
+        handleData: _onDocumentUpdated).bind(_dom.onDocumentUpdated)
+      ..listen(_logEvent);
+    onSetChildNodes = new StreamTransformer.fromHandlers(
+        handleData: _onSetChildNodes).bind(_dom.onSetChildNodes)
+      ..listen(_logEvent);
+  }
+
+  _logEvent(WipEvent event) {
+    _log.finest('Event $event');
+  }
+
+  _onAttributeModified(
+      AttributeModifiedEvent event, EventSink<AttributeModifiedEvent> sink) {
+    var node = _getOrCreateNode(event.nodeId);
+    node._attributes[event.name] = event.value;
+    sink.add(event);
+  }
+
+  _onAttributeRemoved(
+      AttributeRemovedEvent event, EventSink<AttributeRemovedEvent> sink) {
+    var node = _getOrCreateNode(event.nodeId);
+    node._attributes.remove(event.name);
+    sink.add(event);
+  }
+
+  _onCharacterDataModified(CharacterDataModifiedEvent event,
+      EventSink<CharacterDataModifiedEvent> sink) {
+    var node = _getOrCreateNode(event.nodeId);
+    node._nodeValue = event.characterData;
+    sink.add(event);
+  }
+
+  _onChildNodeCountUpdated(ChildNodeCountUpdatedEvent event,
+      EventSink<ChildNodeCountUpdatedEvent> sink) {
+    var node = _getOrCreateNode(event.nodeId);
+    node._childNodeCount = event.childNodeCount;
+    sink.add(event);
+  }
+
+  _onChildNodeInserted(
+      ChildNodeInsertedEvent event, EventSink<ChildNodeInsertedEvent> sink) {
+    var parent = _getOrCreateNode(event.parentNodeId);
+    int index = 0;
+    if (event.previousNodeId != null) {
+      index =
+          parent._children.indexOf(_getOrCreateNode(event.previousNodeId)) + 1;
+    }
+    var node = _getOrCreateNodeFromNode(event.node);
+    parent._children.insert(index, node);
+    parent._childNodeCount = parent._children.length;
+    sink.add(event);
+  }
+
+  _onChildNodeRemoved(
+      ChildNodeRemovedEvent event, EventSink<ChildNodeRemovedEvent> sink) {
+    var parent = _getOrCreateNode(event.parentNodeId);
+    var node = _nodeCache.remove(event.nodeId);
+    parent._children.remove(node);
+    parent._childNodeCount = parent._children.length;
+    sink.add(event);
+  }
+
+  _onDocumentUpdated(
+      DocumentUpdatedEvent event, EventSink<DocumentUpdatedEvent> sink) {
+    _nodeCache.clear();
+    _root = null;
+    sink.add(event);
+  }
+
+  _onSetChildNodes(
+      SetChildNodesEvent event, EventSink<SetChildNodesEvent> sink) {
+    var parent = _getOrCreateNode(event.nodeId);
+    parent._children =
+        event.nodes.map(_getOrCreateNodeFromNode).toList(growable: true);
+    parent._childNodeCount = parent._children.length;
+    sink.add(event);
+  }
+
+  @override
+  Future<Map<String, String>> getAttributes(int nodeId) async {
+    Map<String, String> attributes = await _dom.getAttributes(nodeId);
+    var node = _getOrCreateNode(nodeId);
+    node._attributes = new Map.from(attributes);
+    return attributes;
+  }
+
+  /// Unlike the standard [WipDom.getDocument] call, this will not
+  /// reset the internal state of the debugger remote end when called
+  /// multiple times on the same page.
+  @override
+  Future<Node> getDocument() {
+    if (_root == null) {
+      _root = _dom.getDocument().then((n) => _getOrCreateNodeFromNode(n));
+    }
+    return _root;
+  }
+
+  _Node _getOrCreateNode(int nodeId) =>
+      _nodeCache.putIfAbsent(nodeId, () => new _Node(nodeId));
+
+  _Node _getOrCreateNodeFromNode(Node src) {
+    try {
+      var node = _getOrCreateNode(src.nodeId);
+      if (src.attributes != null) {
+        node._attributes = new Map.from(src.attributes);
+      }
+      if (src.children != null) {
+        node._children =
+            src.children.map(_getOrCreateNodeFromNode).toList(growable: true);
+      }
+      node._childNodeCount = src.childNodeCount;
+      if (src.contentDocument != null) {
+        node._contentDocument = _getOrCreateNodeFromNode(src.contentDocument);
+      }
+      node._documentUrl = src.documentUrl;
+      node._internalSubset = src.internalSubset;
+      node._localName = src.localName;
+      node._name = src.name;
+      node._nodeName = src.nodeName;
+      node._nodeType = src.nodeType;
+      node._nodeValue = src.nodeValue;
+      node._publicId = src.publicId;
+      node._systemId = src.systemId;
+      node._value = src.value;
+      node._xmlVersion = src.xmlVersion;
+      return node;
+    } catch (e, s) {
+      _log.severe('Error parsing: $src.nodeId', e, s);
+      rethrow;
+    }
+  }
+
+  noSuchMethod(Invocation invocation) => reflect(_dom).delegate(invocation);
+}
+
+class _Node implements Node {
+  Map<String, String> _attributes;
+  @override
+  Map<String, String> get attributes =>
+      _attributes != null ? new UnmodifiableMapView(_attributes) : null;
+
+  int _childNodeCount;
+  @override
+  int get childNodeCount => _childNodeCount;
+
+  List<_Node> _children;
+  @override
+  List<Node> get children =>
+      _children != null ? new UnmodifiableListView(_children) : null;
+
+  _Node _contentDocument;
+  @override
+  Node get contentDocument => _contentDocument;
+
+  String _documentUrl;
+  @override
+  String get documentUrl => _documentUrl;
+
+  String _internalSubset;
+  @override
+  String get internalSubset => _internalSubset;
+
+  String _localName;
+  @override
+  String get localName => _localName;
+
+  String _name;
+  @override
+  String get name => _name;
+
+  @override
+  final int nodeId;
+
+  String _nodeName;
+  @override
+  String get nodeName => _nodeName;
+
+  int _nodeType;
+  @override
+  int get nodeType => _nodeType;
+
+  String _nodeValue;
+  @override
+  String get nodeValue => _nodeValue;
+
+  String _publicId;
+  @override
+  String get publicId => _publicId;
+
+  String _systemId;
+  @override
+  String get systemId => _systemId;
+
+  String _value;
+  @override
+  String get value => _value;
+
+  String _xmlVersion;
+  @override
+  String get xmlVersion => _xmlVersion;
+
+  _Node(this.nodeId);
+
+  Map toJson() => _toJsonInternal(new Set());
+
+  Map _toJsonInternal(Set visited) {
+    var map = {
+      'localName': localName,
+      'nodeId': nodeId,
+      'nodeName': nodeName,
+      'nodeType': nodeType,
+      'nodeValue': nodeValue
+    };
+    if (visited.add(nodeId)) {
+      if (attributes != null && attributes.isNotEmpty) {
+        map['attributes'] = flattenAttributesMap(attributes);
+      }
+      if (childNodeCount != null) {
+        map['childNodeCount'] = childNodeCount;
+      }
+      if (children != null && children.isNotEmpty) {
+        var newChildren = [];
+        children.forEach((child) {
+          if (child != null) {
+            newChildren.add(child._toJsonInternal(visited));
+          }
+        });
+        map['children'] = newChildren;
+      }
+      if (_contentDocument != null) {
+        map['contentDocument'] = _contentDocument._toJsonInternal(visited);
+      }
+      if (documentUrl != null) {
+        map['documentUrl'] = documentUrl;
+      }
+      if (internalSubset != null) {
+        map['internalSubset'] = internalSubset;
+      }
+      if (name != null) {
+        map['name'] = name;
+      }
+      if (publicId != null) {
+        map['publicId'] = publicId;
+      }
+      if (systemId != null) {
+        map['systemId'] = systemId;
+      }
+      if (value != null) {
+        map['value'] = value;
+      }
+      if (xmlVersion != null) {
+        map['xmlVersion'] = xmlVersion;
+      }
+    }
+    return map;
+  }
+}
+
+List<String> flattenAttributesMap(Map<String, String> attributes) {
+  var result = <String>[];
+  attributes.forEach((k, v) {
+    if (k != null) {
+      result
+        ..add(k)
+        ..add(v);
+    }
+  });
+  return result;
+}
diff --git a/lib/forwarder.dart b/lib/forwarder.dart
index 8dc158d..cb165b6 100644
--- a/lib/forwarder.dart
+++ b/lib/forwarder.dart
@@ -3,35 +3,45 @@
 
 library crmux.forwarder;
 
-import 'dart:async';
-import 'dart:convert';
+import 'dart:async'
+    show
+        Completer,
+        Future,
+        Stream,
+        StreamController,
+        StreamSink,
+        StreamSubscription;
+import 'dart:convert' show JSON;
 
-import 'package:logging/logging.dart';
+import 'package:logging/logging.dart' show Logger;
 
-import 'webkit_inspection_protocol.dart';
+import 'dom_model.dart' show flattenAttributesMap;
+import 'webkit_inspection_protocol.dart'
+    show WipConnection, WipDom, WipError, WipEvent, WipResponse;
 
 /// Forwards a [Stream] to a [WipConnection] and events
 /// from a [WipConnection] to a [StreamSink].
-class ChromeForwarder {
+class WipForwarder {
   static final _log = new Logger('ChromeForwarder');
 
   final Stream _in;
   final StreamSink _out;
   final WipConnection _debugger;
+  final WipDom domModel;
 
   final _subscriptions = <StreamSubscription>[];
 
   final _closedController = new StreamController.broadcast();
 
-  factory ChromeForwarder(WipConnection debugger, Stream stream,
-      [StreamSink sink]) {
+  factory WipForwarder(WipConnection debugger, Stream stream,
+      {StreamSink sink, WipDom domModel}) {
     if (sink == null) {
       sink = stream as StreamSink;
     }
-    return new ChromeForwarder._(debugger, stream, sink);
+    return new WipForwarder._(debugger, stream, sink, domModel);
   }
 
-  ChromeForwarder._(this._debugger, this._in, this._out) {
+  WipForwarder._(this._debugger, this._in, this._out, this.domModel) {
     _subscriptions.add(_in.listen(_onClientDataHandler,
         onError: _onClientErrorHandler, onDone: _onClientDoneHandler));
     _subscriptions.add(_debugger.onNotification.listen(_onDebuggerDataHandler,
@@ -43,13 +53,37 @@
     var response = {'id': json['id']};
     _log.info('Forwarding to debugger: $data');
     try {
-      var resp = await _debugger.sendCommand(json['method'], json['params']);
-      if (resp.result != null) {
-        response['result'] = resp.result;
+      String method = json['method'];
+      Map<String, dynamic> params = json['params'];
+      bool processed = false;
+
+      if (domModel != null) {
+        switch (method) {
+          case 'DOM.getDocument':
+            response['result'] = {'root': (await domModel.getDocument())};
+            processed = true;
+            break;
+          case 'DOM.getAttributes':
+            var attributes = flattenAttributesMap(
+                await domModel.getAttributes(params['nodeId']));
+            response['result'] = {'attributes': attributes};
+            processed = true;
+            break;
+        }
+      }
+      if (!processed) {
+        WipResponse resp = await _debugger.sendCommand(method, params);
+        if (resp.result != null) {
+          response['result'] = resp.result;
+        }
       }
     } on WipError catch (e) {
       response['error'] = e.error;
+    } catch (e, s) {
+      _log.severe(json['id'], e.toString(), s);
+      response['error'] = e.toString();
     }
+    _log.info('forwarding response: $response');
     _out.add(JSON.encode(response));
   }
 
diff --git a/lib/src/console.dart b/lib/src/console.dart
index 1b2e256..df87c3c 100644
--- a/lib/src/console.dart
+++ b/lib/src/console.dart
@@ -29,7 +29,6 @@
   String get text => _message['text'];
   String get level => _message['level'];
   String get url => _message['url'];
-  int get repeatCount => _message.putIfAbsent('repeatCount', () => 1);
 
   Iterable<WipConsoleCallFrame> getStackTrace() {
     if (_message.containsKey('stackTrace')) {
diff --git a/lib/src/dom.dart b/lib/src/dom.dart
index db0bf05..c7c5a49 100644
--- a/lib/src/dom.dart
+++ b/lib/src/dom.dart
@@ -11,12 +11,7 @@
   Future<Map<String, String>> getAttributes(int nodeId) async {
     WipResponse resp =
         await _sendCommand('DOM.getAttributes', {'nodeId': nodeId});
-    var attributes = {};
-    for (int i = 0; i < resp.result['attributes'].length; i += 2) {
-      attributes[resp.result['attributes'][i]] =
-          attributes[resp.result['attributes'][i + 1]];
-    }
-    return new UnmodifiableMapView<String, String>(attributes);
+    return _attributeListToMap(resp.result['attributes']);
   }
 
   Future<Node> getDocument() async =>
@@ -232,6 +227,8 @@
       yield new Node(node);
     }
   }
+
+  String toString() => 'SetChildNodes $nodeId: $nodes';
 }
 
 /// The backend keeps track of which DOM nodes have been sent,
@@ -245,24 +242,20 @@
   var _attributes;
   Map<String, String> get attributes {
     if (_attributes == null && _map.containsKey('attributes')) {
-      var attributes = {};
-      for (int i = 0; i < _map['attributes'].length; i += 2) {
-        attributes[_map['attributes'][i]] =
-            attributes[_map['attributes'][i + 1]];
-      }
-      _attributes = new UnmodifiableMapView<String, String>(attributes);
+      _attributes = _attributeListToMap(_map['attributes']);
     }
     return _attributes;
   }
 
   int get childNodeCount => _map['childNodeCount'];
 
-  Iterable<Node> get children sync* {
-    if (_map.containsKey('children')) {
-      for (var child in _map['children']) {
-        yield new Node(child);
-      }
+  var _children;
+  List<Node> get children {
+    if (_children == null && _map.containsKey('children')) {
+      _children =
+          new UnmodifiableListView(_map['children'].map((c) => new Node(c)));
     }
+    return _children;
   }
 
   Node get contentDocument {
@@ -295,6 +288,8 @@
   String get value => _map['value'];
 
   String get xmlVersion => _map['xmlVersion'];
+
+  String toString() => '$nodeName: $nodeId $attributes';
 }
 
 class Rgba {
@@ -313,3 +308,11 @@
     return json;
   }
 }
+
+Map<String, String> _attributeListToMap(List<String> attrList) {
+  var attributes = {};
+  for (int i = 0; i < attrList.length; i += 2) {
+    attributes[attrList[i]] = attrList[i + 1];
+  }
+  return new UnmodifiableMapView(attributes);
+}
diff --git a/lib/webkit_inspection_protocol.dart b/lib/webkit_inspection_protocol.dart
index 4966fae..7c00e60 100644
--- a/lib/webkit_inspection_protocol.dart
+++ b/lib/webkit_inspection_protocol.dart
@@ -14,12 +14,14 @@
         Stream,
         StreamController,
         StreamTransformer;
-import 'dart:collection' show UnmodifiableMapView;
+import 'dart:collection' show UnmodifiableListView, UnmodifiableMapView;
 import 'dart:convert' show JSON, UTF8;
 import 'dart:io' show HttpClient, HttpClientResponse, WebSocket;
 
 import 'package:logging/logging.dart' show Logger;
 
+import 'package:logging/logging.dart';
+
 part 'src/console.dart';
 part 'src/debugger.dart';
 part 'src/dom.dart';
diff --git a/pubspec.yaml b/pubspec.yaml
index 4ae29a8..657b976 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -6,7 +6,7 @@
 author: Devon Carew <devoncarew@google.com>
 
 environment:
-  sdk: '>=1.9.0 <2.0.0'
+  sdk: '>=1.10.0 <2.0.0'
 dependencies:
   args: '^0.13.1'
   logging: '^0.11.0'
diff --git a/test/data/dom_model_test.html b/test/data/dom_model_test.html
new file mode 100644
index 0000000..6be274a
--- /dev/null
+++ b/test/data/dom_model_test.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html>
+<head lang="en">
+    <meta charset="UTF-8">
+    <title></title>
+</head>
+<body test-attr="test-attr-value">
+    <div>
+        <h1>test</h1>
+    </div>
+    <div></div>
+</body>
+</html>
diff --git a/test/dom_model_test.dart b/test/dom_model_test.dart
new file mode 100644
index 0000000..d514243
--- /dev/null
+++ b/test/dom_model_test.dart
@@ -0,0 +1,129 @@
+// Copyright 2015 Google. 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')
+library wip.console_test;
+
+import 'package:test/test.dart';
+import 'package:webkit_inspection_protocol/dom_model.dart';
+import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart';
+
+import 'test_setup.dart';
+
+main() {
+  group('WipDomModel', () {
+    WipDom dom;
+
+    setUp(() async {
+      dom = new WipDomModel((await navigateToPage('dom_model_test.html')).dom);
+    });
+
+    tearDown(() async {
+      dom = null;
+      await closeConnection();
+    });
+
+    test('maintains model across getDocument calls', () async {
+      var document1 = await dom.getDocument();
+      var document2 = await dom.getDocument();
+      expect(document2.nodeId, document1.nodeId);
+    });
+
+    test('requestChildNodes updates children', () async {
+      Node htmlNode = (await dom.getDocument()).children[1];
+      for (var child in htmlNode.children) {
+        expect(child.children, isNull);
+        await dom.requestChildNodes(child.nodeId);
+      }
+      // wait for children to be updated
+      for (var child in htmlNode.children) {
+        expect(child.children, isNotNull);
+      }
+    });
+
+    test('removing a node updates children', () async {
+      Node bodyNode = (await dom.getDocument()).children[1].children[1];
+      await dom.requestChildNodes(bodyNode.nodeId);
+      var childCount = bodyNode.childNodeCount;
+      await dom.removeNode(bodyNode.children.first.nodeId);
+
+      expect(bodyNode.children, hasLength(childCount - 1));
+      expect(bodyNode.childNodeCount, childCount - 1);
+    });
+
+    test('Moving a node updates children', () async {
+      Node bodyNode = (await dom.getDocument()).children[1].children[1];
+      await dom.requestChildNodes(bodyNode.nodeId);
+      Node div1 = bodyNode.children[0];
+      Node div2 = bodyNode.children[1];
+
+      expect(div1.childNodeCount, 1);
+      expect(div2.childNodeCount, 0);
+
+      await dom.requestChildNodes(div1.nodeId);
+      await dom.requestChildNodes(div2.nodeId);
+
+      await dom.moveTo(div1.children.first.nodeId, div2.nodeId);
+
+      expect(div1.childNodeCount, 0);
+      expect(div2.childNodeCount, 1);
+      expect(div2.children, hasLength(1));
+    });
+
+    test('Setting node value updates value', () async {
+      Node bodyNode = (await dom.getDocument()).children[1].children[1];
+      await dom.requestChildNodes(bodyNode.nodeId);
+
+      Node div1 = bodyNode.children[0];
+      await dom.requestChildNodes(div1.nodeId);
+
+      Node h1 = div1.children[0];
+      await dom.requestChildNodes(h1.nodeId);
+
+      Node text = h1.children[0];
+
+      expect(text.nodeValue, 'test');
+
+      await dom.setNodeValue(text.nodeId, 'some new text');
+
+      expect(text.nodeValue, 'some new text');
+    });
+
+    test('Adding attribute updates attributes', () async {
+      Node bodyNode = (await dom.getDocument()).children[1].children[1];
+      expect(bodyNode.attributes.containsKey('my-attr'), isFalse);
+      await dom.setAttributeValue(bodyNode.nodeId, 'my-attr', 'my-value');
+      expect(bodyNode.attributes['my-attr'], 'my-value');
+    });
+
+    test('Changing attribute updates attributes', () async {
+      Node bodyNode = (await dom.getDocument()).children[1].children[1];
+      expect(bodyNode.attributes['test-attr'], 'test-attr-value');
+      await dom.setAttributeValue(bodyNode.nodeId, 'test-attr', 'my-value');
+      expect(bodyNode.attributes['test-attr'], 'my-value');
+    });
+
+    test('Removing attribute updates attributes', () async {
+      Node bodyNode = (await dom.getDocument()).children[1].children[1];
+      expect(bodyNode.attributes['test-attr'], 'test-attr-value');
+      await dom.removeAttribute(bodyNode.nodeId, 'test-attr');
+      expect(bodyNode.attributes.containsKey('test-attr'), isFalse);
+    });
+
+    test('refreshing resets document', () async {
+      var document1 = await dom.getDocument();
+      await navigateToPage('dom_model_test.html');
+      var document2 = await dom.getDocument();
+      expect(document2.nodeId, isNot(document1.nodeId));
+    });
+
+    test('getting attributes works', () async {
+      Node bodyNode = (await dom.getDocument()).children[1].children[1];
+      var attributes = bodyNode.attributes;
+      var getAttributes = await dom.getAttributes(bodyNode.nodeId);
+
+      expect(getAttributes, attributes);
+      expect(bodyNode.attributes, attributes);
+    });
+  });
+}