Adding sync tests with mocked SyncBase (wrapper)

Change-Id: If18992dfda2eceb191c62fd7d19583cb69e6b402
diff --git a/mocks/google-maps.js b/mocks/google-maps.js
index 6318d52..65639be 100644
--- a/mocks/google-maps.js
+++ b/mocks/google-maps.js
@@ -5,6 +5,39 @@
 var $ = require('../src/util/jquery');
 var defineClass = require('../src/util/define-class');
 
+var maps;
+
+var PLACES = {
+  GATEWAY_ARCH: {
+    coords: {
+      latitude: 38.6,
+      longitude: -90.2
+    },
+    placeId: '5TL0U15'
+  },
+  GRAND_CANYON: {
+    coords: {
+      latitude: 36.1,
+      longitude: -112.1
+    },
+    placeId: '6R4NDC4NY0N'
+  },
+  GOLDEN_GATE: {
+    coords: {
+      latitude: 37.8,
+      longitude: -122.5
+    },
+    placeId: '60LD3N64T3'
+  },
+  SPACE_NEEDLE: {
+    coords: {
+      latitude: 47.6,
+      longitude: -122.3
+    },
+    placeId: '5P4C3N33DL3'
+  }
+};
+
 var ControlPosition = {
   LEFT_CENTER: 'lc',
   LEFT_TOP: 'lt',
@@ -12,16 +45,80 @@
   TOP_LEFT: 'tl'
 };
 
-var ControlPanel = defineClass({
-  init: function(parent) {
-    this.$ = $('<div>');
-    this.$.appendTo(parent);
-  },
+var GeocoderStatus = {
+  OK: 0,
+  ERROR: 1
+};
 
+var PlacesServiceStatus = {
+  OK: 0,
+  ZERO_RESULTS: 1
+};
+
+var TravelMode = {
+  DRIVING: 0
+};
+
+var ControlPanel = defineClass({
   publics: {
     push: function(child) {
       this.$.append(child);
     }
+  },
+
+  init: function(parent) {
+    this.$ = $('<div>');
+    this.$.appendTo(parent);
+  }
+});
+
+var DirectionsRenderer = defineClass({
+  publics: {
+    setMap: function(){},
+    toString: function() { return 'mock DirectionsRenderer'; }
+  }
+});
+
+var DirectionsService = defineClass({
+  publics: {
+    route: function(){},
+    toString: function() { return 'mock DirectionsService'; }
+  }
+});
+
+function geoResolver(location) {
+  var result;
+  $.each(maps.places.corpus, function() {
+    if (location.lat() === this.coords.latitude &&
+        location.lng() === this.coords.longitude) {
+      result = placeResult(this);
+      return false;
+    }
+  });
+  return result;
+}
+
+var Geocoder = defineClass({
+  publics: {
+    geocode: function(request, callback) {
+      var self = this;
+      process.nextTick(function() {
+        var results = [];
+
+        var output = self.resolver(request.location);
+        if (output) {
+          results.push(output);
+        }
+
+        callback(results, output? GeocoderStatus.OK : GeocoderStatus.ERROR);
+      });
+    },
+
+    toString: function() { return 'mock Geocoder'; }
+  },
+
+  init: function(resolver) {
+    this.resolver = resolver || geoResolver;
   }
 });
 
@@ -40,9 +137,47 @@
   }
 });
 
+var LatLng = defineClass({
+  publics: {
+    lat: function() {
+      return this.latitude;
+    },
+
+    lng: function() {
+      return this.longitude;
+    },
+
+    toString: function() { return 'mock LatLng (' +
+      this.lat() + ', ' + this.lng() + ')'; }
+  },
+
+  init: function(lat, lng) {
+    this.latitude = lat;
+    this.longitude = lng;
+  }
+});
+
+var LatLngBounds = defineClass({
+  publics: {
+    contains: function(){},
+    extend: function(){},
+    toSpan: function(){},
+
+    toString: function() { return 'mock LatLngBounds'; }
+  }
+});
+
 var Map = defineClass({
   publics: {
-    getBounds: function(){},
+    getBounds: function(){
+      return new LatLngBounds();
+    },
+
+    setCenter: function(){},
+    panTo: function(){},
+    fitBounds: function(){},
+
+    setOptions: function(){},
 
     registerInfoWindow: function(wnd) {
       this.infoWindows.push(wnd);
@@ -76,6 +211,10 @@
     });
 
     this.infoWindows = [];
+
+    if (maps.onNewMap) {
+      maps.onNewMap(this.ifc);
+    }
   }
 });
 
@@ -92,47 +231,106 @@
     },
 
     setMap: function(map) {
-      this.map = map;
+      var old = this.map;
+      if (old !== map) {
+        this.map = map;
+        this.onMapChange(map, old);
+      }
     },
 
     getMap: function() {
       return this.map;
     },
 
+    getPlace: function() {
+      return this.place;
+    },
+
     setTitle: function(){},
 
     toString: function() { return 'mock Marker'; }
   },
 
   events: {
-    click: 'public'
+    click: 'public',
+    onMapChange: ''
   },
 
   init: function(opts) {
     $.extend(this, opts);
+
+    if (maps.onNewMarker) {
+      maps.onNewMarker(this.ifc);
+    }
+  }
+});
+
+function placeResult(data) {
+  return {
+    geometry: {
+      location: new LatLng(data.coords.latitude, data.coords.longitude)
+    },
+    'place_id': data.placeId
+  };
+}
+
+var PlacesService = defineClass({
+  publics: {
+    getDetails: function(request, callback){
+      $.each(maps.places.corpus, function() {
+        if (request.placeId === this.placeId) {
+          callback(placeResult(this), PlacesServiceStatus.OK);
+          return false;
+        }
+      });
+
+      callback(null, PlacesServiceStatus.ZERO_RESULTS);
+    },
+
+    toString: function() { return 'mock PlacesService'; }
   }
 });
 
 var SearchBox = defineClass({
   publics: {
     setBounds: function(){},
+
+    getPlaces: function() {
+      return this.places;
+    },
+
     toString: function() { return 'mock SearchBox'; }
   },
 
+  privates: {
+    mockResults: function(places) {
+      this.places = places.map(placeResult);
+      this['places_changed']();
+    }
+  },
+
   events: {
     'places_changed': 'public'
+  },
+
+  init: function(input) {
+    $(input).data('mockResults', this.mockResults);
   }
 });
 
-module.exports = {
+maps = {
   ControlPosition: ControlPosition,
-  DirectionsService: function(){},
+  DirectionsRenderer: DirectionsRenderer,
+  DirectionsService: DirectionsService,
   DirectionsStatus: {},
-  Geocoder: function(){},
+  Geocoder: Geocoder,
+  GeocoderStatus: GeocoderStatus,
   InfoWindow: InfoWindow,
-  LatLng: function(){},
+  LatLng: LatLng,
+  LatLngBounds: LatLngBounds,
   Map: Map,
   Marker: Marker,
+  TravelMode: TravelMode,
 
   event: {
     addListener: function(instance, eventName, handler){
@@ -149,10 +347,15 @@
   },
 
   places: {
-    PlacesService: function(){},
+    corpus: PLACES,
+
+    PlacesService: PlacesService,
+    PlacesServiceStatus: PlacesServiceStatus,
     SearchBox: SearchBox,
     mockPlaceResult: {
       geometry: {}
     }
   }
-};
\ No newline at end of file
+};
+
+module.exports = maps;
\ No newline at end of file
diff --git a/mocks/navigator.js b/mocks/navigator.js
new file mode 100644
index 0000000..8d2309a
--- /dev/null
+++ b/mocks/navigator.js
@@ -0,0 +1,31 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var defineClass = require('../src/util/define-class');
+
+var MockGeolocation = defineClass({
+  publics: {
+    getCurrentPosition: function(callback) {
+      this.onResolvePosition.add(callback);
+    },
+
+    resolvePosition: function(position) {
+      this.onResolvePosition(position);
+    }
+  },
+
+  events: {
+    onResolvePosition: 'once'
+  }
+});
+
+var MockNavigator = defineClass({
+  constants: [ 'geolocation' ],
+
+  init: function() {
+    this.geolocation = new MockGeolocation();
+  }
+});
+
+module.exports = MockNavigator;
\ No newline at end of file
diff --git a/mocks/syncbase-wrapper.js b/mocks/syncbase-wrapper.js
new file mode 100644
index 0000000..a8368a1
--- /dev/null
+++ b/mocks/syncbase-wrapper.js
@@ -0,0 +1,241 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+require('es6-shim');
+
+var $ = require('../src/util/jquery');
+var defineClass = require('../src/util/define-class');
+
+//All periods are expressed in milliseconds.
+var SYNC_LOOP_PERIOD = 50;
+var WATCH_LOOP_PERIOD = 50;
+
+var syncgroups = {};
+
+function update(a, b) {
+  $.each(a, function(k, v) {
+    if (k !== 'value' && k !== 'version') {
+      var bv = b[k];
+      if (bv) {
+        update(v, bv);
+      } else {
+        b[k] = $.extend(true, {}, v);
+      }
+    }
+  });
+
+  if (a.version > b.version) {
+    b.value = a.value;
+    b.version = a.version;
+  }
+}
+
+function sync(a, b, prefixes) {
+  $.each(prefixes, function() {
+    var suba = recursiveGet(a, this);
+    var subb = recursiveGet(b, this);
+
+    if (suba && subb) {
+      update(suba, subb);
+      update(subb, suba);
+    } else if (!suba) {
+      recursiveCopy(a, this, subb);
+    } else if (!subb) {
+      recursiveCopy(b, this, suba);
+    }
+  });
+}
+
+function syncLoop() {
+  $.each(syncgroups, function() {
+    var prev;
+    this.forEach(function(sb) {
+      if (prev) {
+        sync(prev, sb, this.prefixes);
+      }
+
+      prev = sb;
+    }, this);
+  });
+
+  setTimeout(syncLoop, SYNC_LOOP_PERIOD);
+}
+process.nextTick(syncLoop);
+
+function advanceVersion(node) {
+  if (node.version === undefined) {
+    node.version = 0;
+  } else {
+    node.version++;
+  }
+}
+
+function recursiveCreate(node, key) { //it's recursive in spirit
+  $.each(key, function() {
+    var child = node[this];
+    if (!child) {
+      child = node[this] = {};
+    }
+    node = child;
+  });
+
+  return node;
+}
+
+function recursiveSet(node, key, value) {
+  node = recursiveCreate(node, key);
+
+  node.value = value;
+  advanceVersion(node);
+}
+
+function recursiveCopy(node, key, content) {
+  $.extend(true, recursiveCreate(node, key), content);
+}
+
+function recursiveGet(node, key) {
+  $.each(key, function() {
+    if (!node) {
+      return false;
+    }
+    node = node[this];
+  });
+  return node;
+}
+
+function recursiveDelete(node, key) {
+  if (key) {
+    node = recursiveGet(node, key);
+  }
+
+  if (node) {
+    delete node.value;
+    advanceVersion(node);
+    $.each(node, function(key, value) {
+      if (key !== 'version') {
+        recursiveDelete(value);
+      }
+    });
+  }
+}
+
+function extractData(repo) {
+  var data;
+  $.each(repo, function(k, v) {
+    if (k === 'value') {
+      if (typeof data === 'object') {
+        if (v !== undefined) {
+          data._ = v;
+        }
+      } else {
+        data = v;
+      }
+    } else if (k !== 'version') {
+      var value = extractData(v);
+      if (value !== undefined) {
+        if (data === undefined) {
+          data = {};
+        } else if (typeof data !== 'object') {
+          data = { _: data };
+        }
+        data[k] = value;
+      }
+    }
+  });
+
+  return data;
+}
+
+var MockSyncbaseWrapper = defineClass({
+  statics: {
+    /**
+     * SLA for a write to a mocked Syncbase instance to be reflected by synced
+     * instances. This is actually based on the size of the SyncGroups with the
+     * current mock implementation--roughly n * SYNC_LOOP_SLA--but let's express
+     * it as a constant for simplicity.
+     */
+    SYNC_SLA: 250 //ms
+  },
+
+  publics: {
+    batch: function(fn) {
+      var ops = {
+        put: this.put,
+        delete: this.delete
+      };
+
+      fn.call(ops, ops);
+      return Promise.resolve();
+    },
+
+    put: function(k, v) {
+      recursiveSet(this.repo, k, v);
+      return Promise.resolve();
+    },
+
+    delete: function(k) {
+      recursiveDelete(this.repo, k);
+      return Promise.resolve();
+    },
+
+    getData: function() {
+      return extractData(this.repo) || {};
+    },
+
+    syncGroup: function(sgAdmin, name) {
+      var repo = this.repo;
+
+      var sgp = {
+        buildSpec: function(prefixes) {
+          return prefixes;
+        },
+
+        join: function() {
+          var sgKey = sgAdmin + '$' + name;
+          var sg = syncgroups[sgKey];
+          sg.add(repo);
+          return Promise.resolve(sgp);
+        },
+
+        joinOrCreate: function(spec) {
+          var sgKey = sgAdmin + '$' + name;
+          var sg = syncgroups[sgKey];
+          if (!sg) {
+            sg = syncgroups[sgKey] = new Set();
+          }
+
+          sg.prefixes = spec;
+          sg.add(repo);
+
+          return Promise.resolve(sgp);
+        }
+      };
+
+      return sgp;
+    },
+
+    refresh: function() {
+      this.onUpdate(this.getData());
+    }
+  },
+
+  events: {
+    onError: 'memory',
+    onUpdate: 'memory'
+  },
+
+  init: function() {
+    var self = this;
+
+    this.repo = {};
+
+    function watchLoop() {
+      self.refresh();
+      setTimeout(watchLoop, WATCH_LOOP_PERIOD);
+    }
+    process.nextTick(watchLoop);
+  }
+});
+
+module.exports = MockSyncbaseWrapper;
diff --git a/mocks/vanadium-wrapper.js b/mocks/vanadium-wrapper.js
index fa9eade..ae54acb 100644
--- a/mocks/vanadium-wrapper.js
+++ b/mocks/vanadium-wrapper.js
@@ -2,10 +2,65 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+require('es6-shim');
+
 var Deferred = require('vanadium/src/lib/deferred');
+var defineClass = require('../src/util/define-class');
+var $ = require('../src/util/jquery');
+
+var MockWrapper = defineClass({
+  publics: {
+    getAccountName: function() {
+      return this.accountName;
+    },
+
+    server: function(endpoint, server) {
+      return this.endpointResolver(endpoint, server);
+    },
+
+    syncbase: function(endpoint) {
+      return this.server(endpoint);
+    },
+
+    setPermissions: function() {
+      return Promise.resolve();
+    }
+  },
+
+  events: {
+    onCrash: 'public',
+    onError: 'public'
+  },
+
+  /**
+   * @param provider callback that receives the endpoint name and possibly a
+   *  Vanadium server implementation, and returns a promise to a mock service.
+   *  This callback is called once per server or syncbase call.
+   */
+  init: function(props, endpointResolver) {
+    $.extend(this, props);
+    this.endpointResolver = endpointResolver;
+  }
+});
 
 module.exports = {
-  init: function(){
+  init: function() {
     return new Deferred().promise;
+  },
+
+  newInstance: function() {
+    var wrapper;
+    var init = new Deferred();
+
+    return {
+      finishInit: function(props, endpointResolver) {
+        wrapper = new MockWrapper(props, endpointResolver);
+        init.resolve(wrapper);
+      },
+
+      init: function() {
+        return init.promise;
+      }
+    };
   }
 };
\ No newline at end of file
diff --git a/src/components/map-widget.js b/src/components/map-widget.js
index 40b643e..b89c528 100644
--- a/src/components/map-widget.js
+++ b/src/components/map-widget.js
@@ -390,14 +390,14 @@
       }
     },
 
-    centerOnCurrentLocation: function() {
+    centerOnCurrentLocation: function(navigator) {
       var self = this;
       var maps = this.maps;
       var map = this.map;
 
       // https://developers.google.com/maps/documentation/javascript/examples/map-geolocation
-      if (global.navigator && global.navigator.geolocation) {
-        global.navigator.geolocation.getCurrentPosition(function(position) {
+      if (navigator && navigator.geolocation) {
+        navigator.geolocation.getCurrentPosition(function(position) {
           var latLng = new maps.LatLng(
             position.coords.latitude, position.coords.longitude);
           map.setCenter(latLng);
@@ -496,9 +496,11 @@
     var self = this;
 
     var maps = opts.maps || global.google.maps;
+    var navigator = opts.navigator || global.navigator;
+
     this.maps = maps;
     this.navigator = opts.navigator || global.navigator;
-    this.geocoder = new maps.Geocoder();
+    this.geocoder = opts.geocoder || new maps.Geocoder();
     this.directionsService = new maps.DirectionsService();
 
     this.$ = $('<div>').addClass('map-canvas');
@@ -522,7 +524,7 @@
       self.onBoundsChange(map.getBounds());
     });
 
-    this.centerOnCurrentLocation();
+    this.centerOnCurrentLocation(navigator);
   }
 });
 
diff --git a/src/travel.js b/src/travel.js
index 7dc275d..5ce1c2e 100644
--- a/src/travel.js
+++ b/src/travel.js
@@ -80,13 +80,19 @@
 var Travel = defineClass({
   publics: {
     dump: function() {
-      this.sync.getData().then(function(data) {
+      return this.sync.getData().then(function(data) {
         debug.log(data);
+        return data;
       }, function(err) {
         console.error(err);
+        throw err;
       });
     },
 
+    status: function() {
+      return this.sync.status;
+    },
+
     error: function (err) {
       this.messages.push(Message.error(err));
     },
@@ -99,6 +105,10 @@
       }));
     },
 
+    getActiveTripId: function() {
+      return this.sync.getActiveTripId();
+    },
+
     invite: function(recipient) {
       var self = this;
 
@@ -553,7 +563,7 @@
         op: function() {
           this.messages.push(new Message({
             type: Message.INFO,
-            html: strings.status(JSON.stringify(this.sync.status, null, 2))
+            html: strings.status(JSON.stringify(this.status(), null, 2))
           }));
         }
       }
diff --git a/src/travelsync.js b/src/travelsync.js
index d2d363e..23367c0 100644
--- a/src/travelsync.js
+++ b/src/travelsync.js
@@ -195,19 +195,29 @@
       }
     }),
 
+    manageWrite: function(promise) {
+      var writes = this.writes;
+      writes.add(promise);
+      promise.then(function() {
+        writes.delete(promise);
+      }, function() {
+        writes.delete(promise);
+      });
+    },
+
     batch: function(fn) {
-      this.startSyncbase.then(function(syncbase) {
+      this.manageWrite(this.startSyncbase.then(function(syncbase) {
         return syncbase.batch(fn);
-      }).catch(this.onError);
+      }).catch(this.onError));
     },
 
     nonBatched: function(fn) {
       var self = this; //not really necessary but semantically correct
       var fnArgs = Array.prototype.slice.call(arguments, 1);
-      this.startSyncbase.then(function(syncbase) {
+      this.manageWrite(this.startSyncbase.then(function(syncbase) {
         fnArgs.splice(0, 0, syncbase);
         return fn.apply(self, fnArgs);
-      }).catch(this.onError);
+      }).catch(this.onError));
     },
 
     handleDestinationAdd: function (destination) {
@@ -316,7 +326,15 @@
     },
 
     unmarshal: function(x) {
-      return x && JSON.parse(x);
+      if (!x) {
+        return x;
+      }
+
+      if (typeof x === 'object') {
+        throw new TypeError('Unexpected persisted object ' + JSON.stringify(x));
+      }
+
+      return JSON.parse(x);
     },
 
     truncateDestinations: function(targetLength) {
@@ -440,10 +458,6 @@
         });
 
         if (this.destRecords.length > ids.length) {
-          /* TODO(rosswang): There is an edge case where this happens due to
-           * user interaction even though normally pulls are blocked while
-           * writes are outstanding. This can probably also happen on startup.
-           * Debug this or better yet make it go away. */
           this.truncateDestinations(ids.length);
         }
       }
@@ -604,7 +618,13 @@
     },
 
     processUpdates: function(data) {
-      this.processTrips(data.user && data.user.tripMetadata, data.trips);
+      /* Although SyncbaseWrapper gates on something similar, we may block on
+       * SyncBase initialization and don't want initial pulls overwriting local
+       * updates queued for writing. We could actually do it here only, but
+       * having it in SyncbaseWrapper as well is semantically correct. */
+      if (!this.writes.size) {
+        this.processTrips(data.user && data.user.tripMetadata, data.trips);
+      }
     },
 
     hasValidUpstream: function() {
@@ -720,6 +740,7 @@
     this.destRecords = [];
     this.status = {};
     this.joinedTrips = new Set();
+    this.writes = new Set();
 
     this.server = new vdlTravel.TravelSync();
     var startRpc = prereqs.then(this.serve);
diff --git a/src/util/define-class.js b/src/util/define-class.js
index eede6b6..0873408 100644
--- a/src/util/define-class.js
+++ b/src/util/define-class.js
@@ -78,14 +78,14 @@
       $.extend(ifc, def.statics);
     }
 
-    if (def.init) {
-      def.init.apply(pthis, arguments);
-    }
-
     if (def.publics) {
       polyBind(ifc, pthis, def.publics, true);
     }
 
+    if (def.init) {
+      def.init.apply(pthis, arguments);
+    }
+
     if (def.constants) {
       $.each(def.constants, function(i, constant) {
         ifc[constant] = pthis[constant];
diff --git a/src/vanadium-wrapper/index.js b/src/vanadium-wrapper/index.js
index e136879..06962dd 100644
--- a/src/vanadium-wrapper/index.js
+++ b/src/vanadium-wrapper/index.js
@@ -7,6 +7,7 @@
 
 var SyncbaseWrapper = require('./syncbase-wrapper');
 
+//ms
 var NAME_TTL = 5000;
 var NAME_REFRESH = 2500;
 
diff --git a/src/vanadium-wrapper/syncbase-wrapper.js b/src/vanadium-wrapper/syncbase-wrapper.js
index 9b7664e..382bd83 100644
--- a/src/vanadium-wrapper/syncbase-wrapper.js
+++ b/src/vanadium-wrapper/syncbase-wrapper.js
@@ -384,13 +384,13 @@
 
     // Start the watch loop to periodically poll for changes from sync.
     // TODO(rosswang): Remove this once we have client watch.
-    this.watchLoop = function() {
+    function watchLoop() {
       if (!self.pull.current) {
         self.refresh().catch(self.onError);
       }
-      setTimeout(self.watchLoop, 500);
-    };
-    process.nextTick(self.watchLoop);
+      setTimeout(watchLoop, 500);
+    }
+    process.nextTick(watchLoop);
   }
 });
 
diff --git a/test/travel.js b/test/travel.js
index ab8bd84..01ee827 100644
--- a/test/travel.js
+++ b/test/travel.js
@@ -2,29 +2,54 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+require('es6-shim');
+
 var test = require('tape');
 
+var uuid = require('uuid');
+
 var $ = require('../src/util/jquery');
 var Travel = require('../src/travel');
 
 var mockMaps = require('../mocks/google-maps');
+var MockNavigator = require('../mocks/navigator');
+var MockSyncbaseWrapper = require('../mocks/syncbase-wrapper');
 var mockVanadiumWrapper = require('../mocks/vanadium-wrapper');
 
+var PLACES = mockMaps.places.corpus;
+
+//All SLAs are expressed in milliseconds.
+var UI_SLA = 50;
+/**
+ * Syncbase doesn't yet provide us any notification that the first sync after
+ * joining the initial sync groups has happened. This SLA is currently based on
+ * a similar timeout in the Travel app, though in the future if that logic gets
+ * smarter we can shrink it to the sync SLA.
+ *
+ * Set to 2500 for real testing, 250 for watch.
+ */
+var STABLE_SLA = 2500;
+var SYNC_SLA = MockSyncbaseWrapper.SYNC_SLA;
+
 function cleanDom() {
   $('body').empty();
 }
 
-test('domRoot', function(t) {
+function newDomRoot() {
   var $root = $('<div>');
-  var root = $root[0];
   $('body').append($root);
+  return $root;
+}
+
+test('domRoot', function(t) {
+  var $root = newDomRoot();
 
   /* jshint -W031 */ //top-level application
   new Travel({
     maps: mockMaps,
     vanadiumWrapper: mockVanadiumWrapper,
-    domRoot: root,
-    syncbase: 'dummy'
+    syncbase: 'dummy',
+    domRoot: $root[0]
   });
   /* jshint +W031 */
 
@@ -54,4 +79,347 @@
   t.equals($($messageItems[1]).text(), 'Test message.',
     'message displays message text');
   t.end();
-});
\ No newline at end of file
+  cleanDom();
+});
+
+//TODO(rosswang): find a better way. If we settle on this, restore afterwards
+function failOnError(t) {
+  console.error = function(err) {
+    t.error(err);
+  };
+}
+
+function handleMarkerMapSet(map, old) {
+  if (map) {
+    map.markers.add(this);
+  }
+  if (old) {
+    old.markers.delete(this);
+  }
+}
+
+mockMaps.onNewMarker = function(marker) {
+  marker.onMapChange.add(handleMarkerMapSet);
+  handleMarkerMapSet.call(marker, marker.getMap());
+};
+
+function startInstance(t, testCase, opts, user) {
+  return new Promise(function(resolve, reject) {
+    testCase.$domRoot = newDomRoot();
+
+    mockMaps.onNewMap = function(map) {
+      testCase.map = map;
+      map.markers = new Set();
+    };
+
+    var vanadiumWrapper = mockVanadiumWrapper.newInstance();
+    var syncbase = uuid.v4();
+
+    var travel = testCase.travel = new Travel($.extend({
+      maps: mockMaps,
+      vanadiumWrapper: vanadiumWrapper,
+      syncbase: syncbase,
+      domRoot: testCase.$domRoot[0]
+    }, opts));
+
+    var syncbaseStarted;
+
+    vanadiumWrapper.finishInit({
+      accountName: 'dev.v.io/u/' + user + '@foogle.com/chrome'
+    }, function(endpoint) {
+      if (endpoint === syncbase) {
+        syncbaseStarted = true;
+        return Promise.resolve(new MockSyncbaseWrapper());
+      } else {
+        return Promise.resolve();
+      }
+    });
+
+    setTimeout(afterSInit, UI_SLA);
+
+    function afterSInit() {
+      t.assert(syncbaseStarted, 'syncbase started');
+
+      var $messages = $('.messages ul').children();
+      t.equals($($messages[0]).text(), 'Connected to all services.',
+        'all services connected');
+
+      resolve(travel);
+    }
+  });
+}
+
+function startWithGeo(t, testCase, user, origin) {
+  return new Promise(function(resolve, reject) {
+    var mockNavigator = new MockNavigator();
+
+    var travel = startInstance(t, testCase, { navigator: mockNavigator }, user)
+    .then(function() {
+      mockNavigator.geolocation.resolvePosition({
+        coords: origin.coords
+      });
+
+      setTimeout(afterLocate, UI_SLA);
+    }).catch(reject);
+
+    function afterLocate() {
+      resolve(travel);
+    }
+  });
+}
+
+var instances = {
+  alice: {
+    d1: {}, //desktop 1
+    d2: {},
+    d3: {}
+  },
+
+  bob: {
+    d1: {},
+    d2: {}
+  }
+};
+
+var ad1 = instances.alice.d1;
+var ad2 = instances.alice.d2;
+var ad3 = instances.alice.d3;
+var bd1 = instances.bob.d1;
+var bd2 = instances.bob.d2;
+
+test('startup', function(t) {
+  failOnError(t);
+
+  timeoutify(startWithGeo(t, ad1, 'alice', PLACES.GOLDEN_GATE)
+  .then(function() {
+    t.equal(ad1.map.markers.size, 1, 'one marker');
+
+    t.equal(ad1.map.markers.values().next().value.getPlace().placeId,
+      PLACES.GOLDEN_GATE.placeId, 'marker set to current location');
+    t.comment('waiting to verify stable state');
+  }), t, afterStable, STABLE_SLA);
+
+  function afterStable() {
+    t.end();
+  }
+});
+
+function timeoutify(promise, t, callback, delay) {
+  promise.then(function() {
+    setTimeout(callback, delay);
+  }, function(err) {
+    t.error(err);
+    t.end();
+  });
+}
+
+function simplifyPlace(p) {
+  return {
+    lat: p.location.lat(),
+    lng: p.location.lng(),
+    id: p.placeId
+  };
+}
+
+function assertSameSingletonMarkers(t, instanceA, instanceB) {
+  var p1 = instanceA.map.markers.values().next().value.getPlace();
+  var p2 = instanceB.map.markers.values().next().value.getPlace();
+  t.deepEqual(simplifyPlace(p2), simplifyPlace(p1), 'markers synced');
+}
+
+test('two devices', function(t) {
+  failOnError(t);
+
+  timeoutify(startWithGeo(t, ad2, 'alice', PLACES.SPACE_NEEDLE),
+    t, afterSync, SYNC_SLA);
+
+  function afterSync() {
+    t.equal(ad2.map.markers.size, 1, 'still 1 marker after sync');
+    assertSameSingletonMarkers(t, ad1, ad2);
+    t.equal(ad2.travel.getActiveTripId(), ad1.travel.getActiveTripId(),
+      'trips synced');
+    t.end();
+  }
+});
+
+function addDestination(t, instance, data) {
+  return new Promise(function(resolve, reject) {
+    var oldMarkerCount = instance.map.markers.size;
+
+    instance.$domRoot.find('.mini-search .add-bn').click();
+    setTimeout(afterClick, UI_SLA);
+
+    function afterClick() {
+      var $inputs = instance.$domRoot.find('.mini-search input');
+      var $focused = $inputs.filter(':focus');
+      t.ok($focused.length, 'mini-search input focused');
+
+      /* Actually, the wrong input will be focused because the code focuses on
+       * the :visible one, which requires CSS that we're not importing at test
+       * time. */
+      $inputs.data('mockResults')([data]);
+
+      t.equal(instance.map.markers.size, oldMarkerCount + 1, 'new marker');
+
+      resolve();
+    }
+  });
+}
+
+test('new destination', function(t) {
+  failOnError(t);
+
+  timeoutify(addDestination(t, ad1, PLACES.GATEWAY_ARCH),
+    t, afterSync, SYNC_SLA);
+
+  function afterSync() {
+    t.equal(ad2.map.markers.size, 2, 'new marker on synced instance');
+
+    t.end();
+  }
+});
+
+test('third device (established trip on other two)', function(t) {
+  failOnError(t);
+
+  timeoutify(startInstance(t, ad3, {}, 'alice').then(function() {
+    t.comment('waiting to verify stable state');
+  }), t, afterSync, STABLE_SLA);
+
+  function afterSync() {
+    t.equal(ad3.map.markers.size, 2, 'two markers on synced instance');
+    t.end();
+  }
+});
+
+test('new user', function(t) {
+  failOnError(t);
+
+  timeoutify(Promise.all([
+    startWithGeo(t, bd1, 'bob', PLACES.GOLDEN_GATE),
+    startWithGeo(t, bd2, 'bob', PLACES.SPACE_NEEDLE)
+  ]), t, afterSync, SYNC_SLA);
+
+  function afterSync() {
+    t.equal(bd1.map.markers.size, 1, 'one marker (no sync with Alice)');
+    assertSameSingletonMarkers(t, bd1, bd2);
+    t.end();
+  }
+});
+
+function getMessage(instance, index) {
+  var $messageItems = instance.$domRoot.find('.messages ul').children();
+  if (index < 0) {
+    index = $messageItems.length + index;
+  }
+  return $($messageItems[index]);
+}
+
+function invite(senderInstance, recipientUser) {
+  senderInstance.$domRoot.find('.send input')
+    .prop('value', '/invite ' + recipientUser + '@foogle.com')
+    .trigger(new $.Event('keydown', { which: 13 }));
+}
+
+test('join established trip', function(t) {
+  failOnError(t);
+
+  invite(ad2, 'bob');
+
+  t.equal(getMessage(ad2, 1).text(),
+    'Inviting bob@foogle.com to join the trip...',
+    'local invite message');
+
+  setTimeout(afterInvite1, SYNC_SLA);
+
+  var $invite;
+
+  function afterInvite1() {
+    $.each(instances.alice, function() {
+      t.equal(getMessage(this, -1).find('.text').text(),
+        'alice@foogle.com invited bob@foogle.com to join the trip.',
+        'trip invite message');
+    });
+
+    $.each(instances.bob, function() {
+      t.equal(getMessage(this, -1).find('.text').text(),
+        'alice@foogle.com has invited you to join a trip. Accept / Decline',
+        'recipient invite message');
+    });
+
+    t.equal(bd1.map.markers.size, 1, 'still no sync with Alice');
+
+    $invite = getMessage(bd1, -1);
+    $invite.find('a[name=decline]').click();
+
+    setTimeout(afterDecline, UI_SLA);
+  }
+
+  function afterDecline() {
+    t.equal($invite.text(),
+      'Declined invite from alice@foogle.com to join a trip.',
+      'local decline message');
+
+    setTimeout(afterDeclineSync, SYNC_SLA);
+  }
+
+  function afterDeclineSync() {
+    t.equal(getMessage(bd2, -1).text(),
+      'alice@foogle.com has invited you to join a trip. (Expired)',
+      'user decline message');
+
+    invite(ad2, 'bob');
+
+    setTimeout(afterInvite2, SYNC_SLA);
+  }
+
+  function afterInvite2() {
+    $invite = getMessage(bd2, 2);
+    $invite.find('a[name=accept]').click();
+
+    setTimeout(afterAccept, UI_SLA);
+  }
+
+  function afterAccept() {
+    t.equal($invite.text(),
+      'Accepted invite from alice@foogle.com to join a trip.',
+      'local accept message');
+
+    setTimeout(afterAcceptSync, SYNC_SLA);
+  }
+
+  function afterAcceptSync() {
+    t.equal(getMessage(bd1, 2).text(),
+      'alice@foogle.com has invited you to join a trip. (Expired)',
+      'user accept message');
+
+    $.each(instances.bob, function() {
+      t.equal(this.map.markers.size, 2, 'synced with Alice');
+    });
+
+    t.end();
+  }
+});
+
+test('new destination from collaborator', function(t) {
+  failOnError(t);
+
+  timeoutify(addDestination(t, bd1, PLACES.GRAND_CANYON),
+    t, afterSync, SYNC_SLA);
+
+  function afterSync() {
+    $.each(['alice', 'bob'], function() {
+      $.each(instances[this], function() {
+        t.equal(this.map.markers.size, 3,
+          'destination added to all synced instances');
+      });
+    });
+
+    t.end();
+  }
+});
+
+test('teardown', function(t) {
+  t.end();
+  process.exit(); //required to terminate timeouts
+});
diff --git a/test/util/define-class.js b/test/util/define-class.js
index e3eeeac..980d654 100644
--- a/test/util/define-class.js
+++ b/test/util/define-class.js
@@ -326,3 +326,24 @@
 
   t.end();
 });
+
+test('constructor ifc', function(t) {
+  var TestClass = defineClass({
+    publics: {
+      getValue: function() {
+        return 'hi';
+      }
+    },
+
+    init: function() {
+      t.equal(this.ifc.getValue(), 'hi',
+        'public methods bound before constructor');
+    }
+  });
+
+  /* jshint -W031 */ //testing in-constructor visibility
+  new TestClass();
+  /* jshint +W031 */
+
+  t.end();
+});