From 8705c4fe35dc8d7eead0ac4778fda528c697a0ec Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Wed, 18 Apr 2018 16:27:13 +0100 Subject: [PATCH 01/45] Fix Doc.prototype.destroy The problem was that unsubscribe re-added the doc to the connection. Now the doc is removed from the connection after unsubscribe. Additionally, we're no longer waiting for the unsubscribe response before executing the callback. It is consistent with Query, unsubscribe can't fail anyway and the subscribed state is updated synchronously on the client side. --- lib/client/doc.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/client/doc.js b/lib/client/doc.js index 05e17976d..bf128eb51 100644 --- a/lib/client/doc.js +++ b/lib/client/doc.js @@ -104,10 +104,8 @@ emitter.mixin(Doc); Doc.prototype.destroy = function(callback) { var doc = this; doc.whenNothingPending(function() { + if (doc.wantSubscribe) doc.unsubscribe(); doc.connection._destroyDoc(doc); - if (doc.wantSubscribe) { - return doc.unsubscribe(callback); - } if (callback) callback(); }); }; From af84be65f208856e0933d627464b79ab053a6dc0 Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Wed, 18 Apr 2018 16:36:58 +0100 Subject: [PATCH 02/45] Update tested nodejs versions in .travis.yml See See https://github.com/nodejs/Release --- .travis.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.travis.yml b/.travis.yml index 1b9165051..66e0be28c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,9 @@ language: node_js node_js: - - 6 - - 5 - - 4 - - 0.10 + - "9" + - "8" + - "6" + - "4" script: "npm run jshint && npm run test-cover" # Send coverage data to Coveralls after_script: "cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js" From 09edf920eedf6d5116efb2271d693a9a59da1517 Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Thu, 19 Apr 2018 12:37:24 +0100 Subject: [PATCH 03/45] Add a test --- lib/client/doc.js | 4 +++- test/client/subscribe.js | 7 +++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/client/doc.js b/lib/client/doc.js index bf128eb51..33621cb9c 100644 --- a/lib/client/doc.js +++ b/lib/client/doc.js @@ -104,7 +104,9 @@ emitter.mixin(Doc); Doc.prototype.destroy = function(callback) { var doc = this; doc.whenNothingPending(function() { - if (doc.wantSubscribe) doc.unsubscribe(); + if (doc.wantSubscribe) { + doc.unsubscribe(); + } doc.connection._destroyDoc(doc); if (callback) callback(); }); diff --git a/test/client/subscribe.js b/test/client/subscribe.js index 567031d0a..db2bea1b2 100644 --- a/test/client/subscribe.js +++ b/test/client/subscribe.js @@ -405,8 +405,10 @@ describe('client subscribe', function() { }); it('doc destroy stops op updates', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); + var connection1 = this.backend.connect(); + var connection2 = this.backend.connect(); + var doc = connection1.get('dogs', 'fido'); + var doc2 = connection2.get('dogs', 'fido'); doc.create({age: 3}, function(err) { if (err) return done(err); doc2.subscribe(function(err) { @@ -416,6 +418,7 @@ describe('client subscribe', function() { }); doc2.destroy(function(err) { if (err) return done(err); + expect(connection2.getExisting('dogs', 'fido')).equal(undefined); doc.submitOp({p: ['age'], na: 1}, done); }); }); From 9121bafe48e6a43dfdf29ee7ef9088b30a9b2a08 Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Wed, 18 Apr 2018 16:36:58 +0100 Subject: [PATCH 04/45] Update tested nodejs versions in .travis.yml See See https://github.com/nodejs/Release --- .travis.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.travis.yml b/.travis.yml index 1b9165051..66e0be28c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,9 @@ language: node_js node_js: - - 6 - - 5 - - 4 - - 0.10 + - "9" + - "8" + - "6" + - "4" script: "npm run jshint && npm run test-cover" # Send coverage data to Coveralls after_script: "cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js" From 4dbefd14464f234cdc4cb7ee35d3b83ae534cbb2 Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Tue, 17 Oct 2017 14:15:42 +0100 Subject: [PATCH 05/45] Add .editorconfig --- .editorconfig | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..e29f5e504 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = LF +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true From 2ef8181081b168ce773fca51db248fab518dbe0f Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Mon, 23 Apr 2018 14:51:13 +0100 Subject: [PATCH 06/45] Update mocha I had to add the --exit flag workaround to mocha.opts to make it exit when tests are done. A better long-term solution would be to ensure that nothing keeps node running after all tests are done, see https://boneskull.com/mocha-v4-nears-release/#mochawontforceexit. --- package.json | 2 +- test/mocha.opts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 47684ed71..5f51224c8 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "expect.js": "^0.3.1", "istanbul": "^0.4.2", "jshint": "^2.9.2", - "mocha": "^3.2.0", + "mocha": "^5.1.1", "sharedb-mingo-memory": "^1.0.0-beta" }, "scripts": { diff --git a/test/mocha.opts b/test/mocha.opts index 34f904192..7ca4707b0 100644 --- a/test/mocha.opts +++ b/test/mocha.opts @@ -1,3 +1,4 @@ --reporter spec --check-leaks --recursive +--exit From 6b687db2744156665608b86f5ed9d59470a28292 Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Wed, 18 Apr 2018 16:27:13 +0100 Subject: [PATCH 07/45] Fix Doc.prototype.destroy The problem was that unsubscribe re-added the doc to the connection. Now the doc is removed from the connection after unsubscribe. Additionally, we're no longer waiting for the unsubscribe response before executing the callback. It is consistent with Query, unsubscribe can't fail anyway and the subscribed state is updated synchronously on the client side. --- lib/client/doc.js | 4 ++-- test/client/subscribe.js | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/client/doc.js b/lib/client/doc.js index 05e17976d..33621cb9c 100644 --- a/lib/client/doc.js +++ b/lib/client/doc.js @@ -104,10 +104,10 @@ emitter.mixin(Doc); Doc.prototype.destroy = function(callback) { var doc = this; doc.whenNothingPending(function() { - doc.connection._destroyDoc(doc); if (doc.wantSubscribe) { - return doc.unsubscribe(callback); + doc.unsubscribe(); } + doc.connection._destroyDoc(doc); if (callback) callback(); }); }; diff --git a/test/client/subscribe.js b/test/client/subscribe.js index 567031d0a..db2bea1b2 100644 --- a/test/client/subscribe.js +++ b/test/client/subscribe.js @@ -405,8 +405,10 @@ describe('client subscribe', function() { }); it('doc destroy stops op updates', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); + var connection1 = this.backend.connect(); + var connection2 = this.backend.connect(); + var doc = connection1.get('dogs', 'fido'); + var doc2 = connection2.get('dogs', 'fido'); doc.create({age: 3}, function(err) { if (err) return done(err); doc2.subscribe(function(err) { @@ -416,6 +418,7 @@ describe('client subscribe', function() { }); doc2.destroy(function(err) { if (err) return done(err); + expect(connection2.getExisting('dogs', 'fido')).equal(undefined); doc.submitOp({p: ['age'], na: 1}, done); }); }); From 1489e36c1e4179b76ba505c8558e6b5bf4619034 Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Tue, 24 Apr 2018 13:49:59 +0100 Subject: [PATCH 08/45] Fix hasWritePending in op's callback --- lib/client/doc.js | 5 +++-- test/client/submit.js | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/lib/client/doc.js b/lib/client/doc.js index 33621cb9c..a7d1d845e 100644 --- a/lib/client/doc.js +++ b/lib/client/doc.js @@ -888,9 +888,10 @@ Doc.prototype._hardRollback = function(err) { }; Doc.prototype._clearInflightOp = function(err) { - var called = callEach(this.inflightOp.callbacks, err); - + var callbacks = this.inflightOp && this.inflightOp.callbacks; this.inflightOp = null; + var called = callbacks && callEach(callbacks, err); + this.flush(); this._emitNothingPending(); diff --git a/test/client/submit.js b/test/client/submit.js index 4e508e66e..4334b57e8 100644 --- a/test/client/submit.js +++ b/test/client/submit.js @@ -1044,6 +1044,39 @@ describe('client submit', function() { }); }); + it('hasWritePending is false when create\'s callback is executed', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { + if (err) return done(err); + expect(doc.hasWritePending()).equal(false); + done(); + }); + }); + + it('hasWritePending is false when submimtOp\'s callback is executed', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { + if (err) return done(err); + doc.submitOp({p: ['age'], na: 2}, function(err) { + if (err) return done(err); + expect(doc.hasWritePending()).equal(false); + done(); + }); + }); + }); + + it('hasWritePending is false when del\'s callback is executed', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { + if (err) return done(err); + doc.del(function(err) { + if (err) return done(err); + expect(doc.hasWritePending()).equal(false); + done(); + }); + }); + }); + describe('type.deserialize', function() { it('can create a new doc', function(done) { var doc = this.backend.connect().get('dogs', 'fido'); From a4499a539cc6961f26174126a8f5d00cc251b757 Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Mon, 16 Apr 2018 13:30:49 +0100 Subject: [PATCH 09/45] Implement ephemeral "presence" data sync --- README.md | 19 + lib/agent.js | 48 ++ lib/backend.js | 5 + lib/client/connection.js | 22 + lib/client/doc.js | 384 +++++++++- test/client/presence-type.js | 82 +++ test/client/presence.js | 1277 ++++++++++++++++++++++++++++++++++ test/util.js | 6 + 8 files changed, 1832 insertions(+), 11 deletions(-) create mode 100644 test/client/presence-type.js create mode 100644 test/client/presence.js diff --git a/README.md b/README.md index 69770ed33..3cbdea6e8 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ tracker](https://github.com/share/sharedb/issues). - Realtime synchronization of any JSON document - Concurrent multi-user collaboration +- Realtime synchronization of any ephemeral "presence" data - Synchronous editing API with asynchronous eventual consistency - Realtime query subscriptions - Simple integration with any database - [MongoDB](https://github.com/share/sharedb-mongo), [PostgresQL](https://github.com/share/sharedb-postgres) (experimental) @@ -57,6 +58,10 @@ initial data. Then you can submit editing operations on the document (using OT). Finally you can delete the document with a delete operation. By default, ShareDB stores all operations forever - nothing is truly deleted. +## User presence synchronization + +Presence data represents a user and is automatically synchronized between all clients subscribed to the same document. Its format is defined by the document's [OT Type](https://github.com/ottypes/docs), for example it may contain a user ID and a cursor position in a text document. All clients can modify their own presence data and receive a read-only version of other client's data. Presence data is automatically cleared when a client unsubscribes from the document or disconnects. It is also automatically transformed against applied operations, so that it still makes sense in the context of a modified document, for example a cursor position may be automatically advanced when a user types at the beginning of a text document. + ## Server API ### Initialization @@ -221,6 +226,9 @@ Unique document ID `doc.data` _(Object)_ Document contents. Available after document is fetched or subscribed to. +`doc.presence` _(Object)_ +Each property under `doc.presence` contains presence data shared by a client subscribed to this document. The property name is an empty string for this client's data and connection IDs for other clients' data. + `doc.fetch(function(err) {...})` Populate the fields on `doc` with a snapshot of the document from the server. @@ -250,6 +258,9 @@ An operation was applied to the data. `source` will be `false` for ops received `doc.on('del', function(data, source) {...})` The document was deleted. Document contents before deletion are passed in as an argument. `source` will be `false` for ops received from the server and defaults to `true` for ops generated locally. +`doc.on('presence', function(srcList) {...})` +Presence data has changed. `srcList` is an Array of `doc.presence` property names for which values have changed. + `doc.on('error', function(err) {...})` There was an error fetching the document or applying an operation. @@ -283,6 +294,11 @@ Invokes the given callback function after Note that `whenNothingPending` does NOT wait for pending `model.query()` calls. +`doc.submitPresence(presenceData[, function(err) {...}])` +Set local presence data and publish it for other clients. +`presenceData` structure depends on the document type. +Presence is synchronized only when subscribed to the document. + ### Class: `ShareDB.Query` `query.ready` _(Boolean)_ @@ -358,6 +374,9 @@ Additional fields may be added to the error object for debugging context dependi * 4021 - Invalid client id * 4022 - Database adapter does not support queries * 4023 - Cannot project snapshots of this type +* 4024 - OT Type does not support presence +* 4025 - Not subscribed to document +* 4026 - Presence data superseded ### 5000 - Internal error diff --git a/lib/agent.js b/lib/agent.js index d1a944de4..f04baa2bd 100644 --- a/lib/agent.js +++ b/lib/agent.js @@ -1,6 +1,7 @@ var hat = require('hat'); var util = require('./util'); var types = require('./types'); +var ShareDBError = require('./error'); /** * Agent deserializes the wire protocol messages received from the stream and @@ -25,6 +26,9 @@ function Agent(backend, stream) { // Map from queryId -> emitter this.subscribedQueries = {}; + // The max presence sequence number received from the client. + this.maxPresenceSeq = 0; + // We need to track this manually to make sure we don't reply to messages // after the stream was closed. this.closed = false; @@ -98,10 +102,17 @@ Agent.prototype._subscribeToStream = function(collection, id, stream) { console.error('Doc subscription stream error', collection, id, data.error); return; } + if (data.a === 'p') { + // Send other clients' presence data + if (data.src !== agent.clientId) agent.send(data); + return; + } if (agent._isOwnOp(collection, data)) return; agent._sendOp(collection, id, data); }); stream.on('end', function() { + var presence = agent._createPresence(collection, id); + agent.backend.sendPresence(presence); // The op stream is done sending, so release its reference var streams = agent.subscribedDocs[collection]; if (!streams) return; @@ -268,6 +279,13 @@ Agent.prototype._checkRequest = function(request) { // Bulk request if (request.c != null && typeof request.c !== 'string') return 'Invalid collection'; if (typeof request.b !== 'object') return 'Invalid bulk subscribe data'; + } else if (request.a === 'p') { + // Presence + if (typeof request.c !== 'string') return 'Invalid collection'; + if (typeof request.d !== 'string') return 'Invalid id'; + if (typeof request.v !== 'number' || request.v < 0) return 'Invalid version'; + if (typeof request.seq !== 'number' || request.seq <= 0) return 'Invalid seq'; + if (typeof request.r !== 'undefined' && typeof request.r !== 'boolean') return 'Invalid "request reply" value'; } }; @@ -300,6 +318,9 @@ Agent.prototype._handleMessage = function(request, callback) { var op = this._createOp(request); if (!op) return callback({code: 4000, message: 'Invalid op message'}); return this._submit(request.c, request.d, op, callback); + case 'p': + var presence = this._createPresence(request.c, request.d, request.p, request.v, request.r, request.seq); + return this._presence(presence, callback); default: callback({code: 4000, message: 'Invalid or unknown message'}); } @@ -582,3 +603,30 @@ Agent.prototype._createOp = function(request) { return new DeleteOp(src, request.seq, request.v, request.del); } }; + +Agent.prototype._presence = function(presence, callback) { + if (presence.seq <= this.maxPresenceSeq) { + return callback(new ShareDBError(4026, 'Presence data superseded')); + } + this.maxPresenceSeq = presence.seq; + if (!this.subscribedDocs[presence.c] || !this.subscribedDocs[presence.c][presence.d]) { + return callback(new ShareDBError(4025, 'Cannot send presence. Not subscribed to document: ' + presence.c + ' ' + presence.d)); + } + this.backend.sendPresence(presence, function(err) { + if (err) return callback(err); + callback(null, { seq: presence.seq }); + }); +}; + +Agent.prototype._createPresence = function(collection, id, data, version, requestReply, seq) { + return { + a: 'p', + src: this.clientId, + seq: seq != null ? seq : this.maxPresenceSeq, + c: collection, + d: id, + p: data, + v: version, + r: requestReply + }; +}; diff --git a/lib/backend.js b/lib/backend.js index 22791f30b..40a1ca282 100644 --- a/lib/backend.js +++ b/lib/backend.js @@ -515,6 +515,11 @@ Backend.prototype.getChannels = function(collection, id) { ]; }; +Backend.prototype.sendPresence = function(presence, callback) { + var channels = [ this.getDocChannel(presence.c, presence.d) ]; + this.pubsub.publish(channels, presence, callback); +}; + function pluckIds(snapshots) { var ids = []; for (var i = 0; i < snapshots.length; i++) { diff --git a/lib/client/connection.js b/lib/client/connection.js index f4cc298e6..b8d7f1ccc 100644 --- a/lib/client/connection.js +++ b/lib/client/connection.js @@ -243,6 +243,11 @@ Connection.prototype.handleMessage = function(message) { if (doc) doc._handleOp(err, message); return; + case 'p': + var doc = this.getExisting(message.c, message.d); + if (doc) doc._handlePresence(err, message); + return; + default: console.warn('Ignoring unrecognized message', message); } @@ -408,6 +413,23 @@ Connection.prototype.sendOp = function(doc, op) { this.send(message); }; +Connection.prototype.sendPresence = function(doc, data, requestReply) { + // Ensure the doc is registered so that it receives the reply message + this._addDoc(doc); + var message = { + a: 'p', + c: doc.collection, + d: doc.id, + p: data, + v: doc.version || 0, + seq: this.seq++ + }; + if (requestReply) { + message.r = true; + } + this.send(message); +}; + /** * Sends a message down the socket diff --git a/lib/client/doc.js b/lib/client/doc.js index a7d1d845e..e92c4b644 100644 --- a/lib/client/doc.js +++ b/lib/client/doc.js @@ -28,6 +28,14 @@ var types = require('../types'); * }) * * + * Presence + * -------- + * + * We can associate transient "presence" data with a document, eg caret position, etc. + * The presence data is synchronized on the best-effort basis between clients subscribed to the same document. + * Each client has their own presence data which is read-write. Other clients' data is read-only. + * + * * Events * ------ * @@ -42,6 +50,7 @@ var types = require('../types'); * the data is null. It is passed the data before delteion as an * arguments * - `load ()` Fired when a new snapshot is ingested from a fetch, subscribe, or query + * - `presence ([src])` Fired after the presence data has changed. */ module.exports = Doc; @@ -57,11 +66,37 @@ function Doc(connection, collection, id) { this.type = null; this.data = undefined; + // The current presence data + // Map of src -> presence data + // Local src === '' + this.presence = Object.create(null); + // The presence objects received from the server + // Map of src -> presence + this.receivedPresence = Object.create(null); + // The minimum amount of time to wait before removing processed presence from this.receivedPresence. + // The processed presence is removed to avoid leaking memory, in case peers keep connecting and disconnecting a lot. + // The processed presence is not removed immediately to enable avoiding race conditions, where messages with lower + // sequence number arrive after messages with higher sequence numbers. + this.receivedPresenceTimeout = 60000; + // If set to true, then the next time the local presence is sent, + // all other clients will be asked to reply with their own presence data. + this.requestReplyPresence = true; + // A list of ops sent by the server. These are needed for transforming presence data, + // if we get that presence data for an older version of the document. + // The ops are cached for 1 minute by default, which should be lots, considering that the presence + // data is supposed to be synced in real-time. + this.cachedOps = []; + this.cachedOpsTimeout = 60000; + // The sequence number of the inflight presence request. + this.inflightPresenceSeq = 0; + // Array of callbacks or nulls as placeholders this.inflightFetch = []; this.inflightSubscribe = []; this.inflightUnsubscribe = []; + this.inflightPresence = null; this.pendingFetch = []; + this.pendingPresence = null; // Whether we think we are subscribed on the server. Synchronously set to // false on calls to unsubscribe and disconnect. Should never be true when @@ -108,6 +143,24 @@ Doc.prototype.destroy = function(callback) { doc.unsubscribe(); } doc.connection._destroyDoc(doc); + + // Make sure all presence callbacks are called + var callbacks = []; + if (doc.inflightPresence) { + // This shouldn't be possible but check just in case. + callbacks.push.apply(callbacks, doc.inflightPresence); + doc.inflightPresence = null; + doc.inflightPresenceSeq = 0; + } + if (doc.pendingPresence) { + callbacks.push.apply(callbacks, doc.pendingPresence); + doc.pendingPresence = null; + } + + doc.receivedPresence = Object.create(null); + doc.cachedOps.length = 0; + + callEach(callbacks); if (callback) callback(); }); }; @@ -186,12 +239,14 @@ Doc.prototype.ingestSnapshot = function(snapshot, callback) { if (this.version > snapshot.v) return callback && callback(); this.version = snapshot.v; + this.cachedOps.length = 0; var type = (snapshot.type === undefined) ? types.defaultType : snapshot.type; this._setType(type); this.data = (this.type && this.type.deserialize) ? this.type.deserialize(snapshot.data) : snapshot.data; this.emit('load'); + this._processAllReceivedPresence(); callback && callback(); }; @@ -257,6 +312,7 @@ Doc.prototype._handleSubscribe = function(err, snapshot) { if (this.wantSubscribe) this.subscribed = true; this.ingestSnapshot(snapshot, callback); this._emitNothingPending(); + this.flush(); }; Doc.prototype._handleUnsubscribe = function(err) { @@ -307,6 +363,13 @@ Doc.prototype._handleOp = function(err, message) { return; } + var serverOp = { + src: message.src, + create: !!message.create, + op: message.op, + del: !!message.del + }; + if (this.inflightOp) { var transformErr = transformX(this.inflightOp, message); if (transformErr) return this._hardRollback(transformErr); @@ -318,7 +381,9 @@ Doc.prototype._handleOp = function(err, message) { } this.version++; + this._cacheOp(serverOp); this._otApply(message, false); + this._processAllReceivedPresence(); return; }; @@ -342,7 +407,10 @@ Doc.prototype._onConnectionStateChanged = function() { if (this.inflightUnsubscribe.length) { var callbacks = this.inflightUnsubscribe; this.inflightUnsubscribe = []; + this._pausePresence(); callEach(callbacks); + } else { + this._pausePresence(); } } }; @@ -402,8 +470,10 @@ Doc.prototype.unsubscribe = function(callback) { if (this.connection.canSend) { var isDuplicate = this.connection.sendUnsubscribe(this); pushActionCallback(this.inflightUnsubscribe, isDuplicate, callback); + this._pausePresence(); return; } + this._pausePresence(); if (callback) process.nextTick(callback); }; @@ -426,14 +496,22 @@ function pushActionCallback(inflight, isDuplicate, callback) { // // Only one operation can be in-flight at a time. If an operation is already on // its way, or we're not currently connected, this method does nothing. +// +// If there are no pending ops, this method sends the pending presence data, if possible. Doc.prototype.flush = function() { - // Ignore if we can't send or we are already sending an op - if (!this.connection.canSend || this.inflightOp) return; + if (this.paused) return; - // Send first pending op unless paused - if (!this.paused && this.pendingOps.length) { + if (this.connection.canSend && !this.inflightOp && this.pendingOps.length) { this._sendOp(); } + + if (this.subscribed && !this.inflightPresence && this.pendingPresence && !this.hasWritePending()) { + this.inflightPresence = this.pendingPresence; + this.inflightPresenceSeq = this.connection.seq; + this.pendingPresence = null; + this.connection.sendPresence(this, this.presence[''], this.requestReplyPresence); + this.requestReplyPresence = false; + } }; // Helper function to set op to contain a no-op. @@ -538,6 +616,7 @@ Doc.prototype._otApply = function(op, source) { // Apply the individual op component this.emit('before op', componentOp.op, source); this.data = this.type.apply(this.data, componentOp.op); + this._transformAllPresence(op); this.emit('op', componentOp.op, source); } // Pop whatever was submitted since we started applying this op @@ -550,6 +629,7 @@ Doc.prototype._otApply = function(op, source) { this.emit('before op', op.op, source); // Apply the operation to the local data, mutating it in place this.data = this.type.apply(this.data, op.op); + this._transformAllPresence(op); // Emit an 'op' event once the local data includes the changes from the // op. For locally submitted ops, this will be synchronously with // submission and before the server or other clients have received the op. @@ -566,6 +646,7 @@ Doc.prototype._otApply = function(op, source) { this.type.createDeserialized(op.create.data) : this.type.deserialize(this.type.create(op.create.data)) : this.type.create(op.create.data); + this._transformAllPresence(op); this.emit('create', source); return; } @@ -573,6 +654,7 @@ Doc.prototype._otApply = function(op, source) { if (op.del) { var oldData = this.data; this._setType(null); + this._transformAllPresence(op); this.emit('del', oldData, source); return; } @@ -820,6 +902,7 @@ Doc.prototype.resume = function() { Doc.prototype._opAcknowledged = function(message) { if (this.inflightOp.create) { this.version = message.v; + this.cachedOps.length = 0; } else if (message.v !== this.version) { // We should already be at the same version, because the server should @@ -832,8 +915,15 @@ Doc.prototype._opAcknowledged = function(message) { // The op was committed successfully. Increment the version number this.version++; + this._cacheOp({ + src: this.inflightOp.src, + create: !!this.inflightOp.create, + op: this.inflightOp.op, + del: !!this.inflightOp.del + }); this._clearInflightOp(); + this._processAllReceivedPresence(); }; Doc.prototype._rollback = function(err) { @@ -868,21 +958,45 @@ Doc.prototype._rollback = function(err) { }; Doc.prototype._hardRollback = function(err) { - // Cancel all pending ops and reset if we can't invert - var op = this.inflightOp; - var pending = this.pendingOps; + var callbacks = []; + if (this.inflightPresence) { + callbacks.push.apply(callbacks, this.inflightPresence); + this.inflightPresence = null; + this.inflightPresenceSeq = 0; + } + if (this.pendingPresence) { + callbacks.push.apply(callbacks, this.pendingPresence); + this.pendingPresence = null; + } + if (this.inflightOp) { + callbacks.push.apply(callbacks, this.inflightOp.callbacks); + } + for (var i = 0; i < this.pendingOps.length; i++) { + callbacks.push.apply(callbacks, this.pendingOps[i].callbacks); + } + this._setType(null); this.version = null; this.inflightOp = null; this.pendingOps = []; + this.cachedOps.length = 0; + this.receivedPresence = Object.create(null); + this.requestReplyPresence = true; + + var srcList = Object.keys(this.presence); + var changedSrcList = []; + for (var i = 0; i < srcList.length; i++) { + var src = srcList[i]; + if (this._setPresence(src, null)) { + changedSrcList.push(src); + } + } + this._emitPresence(changedSrcList); // Fetch the latest from the server to get us back into a working state var doc = this; this.fetch(function() { - var called = op && callEach(op.callbacks, err); - for (var i = 0; i < pending.length; i++) { - callEach(pending[i].callbacks, err); - } + var called = callEach(callbacks, err); if (err && !called) return doc.emit('error', err); }); }; @@ -909,3 +1023,251 @@ function callEach(callbacks, err) { } return called; } + +// *** Presence + +Doc.prototype.submitPresence = function (data, callback) { + if (data != null) { + if (!this.type) { + var err = new ShareDBError(4015, 'Cannot submit presence. Document has not been created. ' + this.collection + '.' + this.id); + if (callback) return callback(err); + return this.emit('error', err); + } + + if (!this.type.createPresence || !this.type.transformPresence) { + var err = new ShareDBError(4024, 'Cannot submit presence. Document\'s type does not support presence. ' + this.collection + '.' + this.id); + if (callback) return callback(err); + return this.emit('error', err); + } + + data = this.type.createPresence(data); + } + + if (!this.pendingPresence) this.pendingPresence = []; + if (callback) this.pendingPresence.push(callback); + this._setPresence('', data, true); + + var doc = this; + process.nextTick(function() { + doc.flush(); + }); +}; + +Doc.prototype._handlePresence = function(err, presence) { + if (!this.subscribed) return; + + var src = presence.src; + if (!src) { + // Handle the ACK for the presence data we submitted. + // this.inflightPresenceSeq would not equal presence.seq after a hard rollback, + // when all callbacks are flushed with an error. + if (this.inflightPresenceSeq === presence.seq) { + var callbacks = this.inflightPresence; + this.inflightPresence = null; + this.inflightPresenceSeq = 0; + var called = callbacks && callEach(callbacks, err); + if (err && !called) this.emit('error', err); + this.flush(); + } + return; + } + + // This shouldn't happen but check just in case. + if (err) return this.emit('error', err); + + if (presence.r && !this.pendingPresence) { + // Another client requested us to share our current presence data + this.pendingPresence = []; + this.flush(); + } + + // Ignore older messages which arrived out of order + if ( + this.receivedPresence[src] && ( + this.receivedPresence[src].seq > presence.seq || + (this.receivedPresence[src].seq === presence.seq && presence.v != null) + ) + ) return; + + this.receivedPresence[src] = presence; + + if (presence.v == null) { + // null version should happen only when the server automatically sends + // null presence for an unsubscribed client + presence.processedAt = Date.now(); + return this._setPresence(src, null, true); + } + + // Get missing ops first, if necessary + if (this.version == null || this.version < presence.v) return this.fetch(); + + this._processReceivedPresence(src, true); +}; + +// If emit is true and presence has changed, emits a presence event. +// Returns true, if presence has changed for src. Otherwise false. +Doc.prototype._processReceivedPresence = function(src, emit) { + if (!src) return false; + var presence = this.receivedPresence[src]; + if (!presence) return false; + + if (presence.processedAt != null) { + if (Date.now() >= presence.processedAt + this.receivedPresenceTimeout) { + // Remove old received and processed presence + delete this.receivedPresence[src]; + } + return false; + } + + if (this.version == null || this.version < presence.v) return false; // keep waiting for the missing snapshot or ops + + if (presence.p == null) { + // Remove presence data as requested + presence.processedAt = Date.now(); + return this._setPresence(src, null, emit); + } + + if (!this.type || !this.type.createPresence || !this.type.transformPresence) { + // Remove presence data because the document is not created or its type does not support presence + presence.processedAt = Date.now(); + return this._setPresence(src, null, emit); + } + + if (this.inflightOp && this.inflightOp.op == null) { + // Remove presence data because receivedPresence can be transformed only against "op", not "create" nor "del" + presence.processedAt = Date.now(); + return this._setPresence(src, null, emit); + } + + for (var i = 0; i < this.pendingOps.length; i++) { + if (this.pendingOps[i].op == null) { + // Remove presence data because receivedPresence can be transformed only against "op", not "create" nor "del" + presence.processedAt = Date.now(); + return this._setPresence(src, null, emit); + } + } + + var startIndex = this.cachedOps.length - (this.version - presence.v); + if (startIndex < 0) { + // Remove presence data because we can't transform receivedPresence + presence.processedAt = Date.now(); + return this._setPresence(src, null, emit); + } + + for (var i = startIndex; i < this.cachedOps.length; i++) { + if (this.cachedOps[i].op == null) { + // Remove presence data because receivedPresence can be transformed only against "op", not "create" nor "del" + presence.processedAt = Date.now(); + return this._setPresence(src, null, emit); + } + } + + // Make sure the format of the data is correct + var data = this.type.createPresence(presence.p); + + // Transform against past ops + for (var i = startIndex; i < this.cachedOps.length; i++) { + var op = this.cachedOps[i]; + data = this.type.transformPresence(data, op.op, presence.src === op.src); + } + + // Transform against pending ops + if (this.inflightOp) { + data = this.type.transformPresence(data, this.inflightOp.op, false); + } + + for (var i = 0; i < this.pendingOps.length; i++) { + data = this.type.transformPresence(data, this.pendingOps[i].op, false); + } + + // Set presence data + presence.processedAt = Date.now(); + return this._setPresence(src, data, emit); +}; + +Doc.prototype._processAllReceivedPresence = function() { + var srcList = Object.keys(this.receivedPresence); + var changedSrcList = []; + for (var i = 0; i < srcList.length; i++) { + var src = srcList[i]; + if (this._processReceivedPresence(src)) { + changedSrcList.push(src); + } + } + this._emitPresence(changedSrcList); +}; + +Doc.prototype._transformPresence = function(src, op) { + var presenceData = this.presence[src]; + if (op.op != null) { + var isOwnOperation = src === (op.src || ''); + presenceData = this.type.transformPresence(presenceData, op.op, isOwnOperation); + } else { + presenceData = null; + } + return this._setPresence(src, presenceData); +}; + +Doc.prototype._transformAllPresence = function(op) { + var srcList = Object.keys(this.presence); + var changedSrcList = []; + for (var i = 0; i < srcList.length; i++) { + var src = srcList[i]; + if (this._transformPresence(src, op)) { + changedSrcList.push(src); + } + } + this._emitPresence(changedSrcList); +}; + +Doc.prototype._pausePresence = function() { + if (this.inflightPresence) { + this.pendingPresence = + this.pendingPresence ? + this.inflightPresence.concat(this.pendingPresence) : + this.inflightPresence; + this.inflightPresence = null; + this.inflightPresenceSeq = 0; + } + this.receivedPresence = Object.create(null); + this.requestReplyPresence = true; + var srcList = Object.keys(this.presence); + var changedSrcList = []; + for (var i = 0; i < srcList.length; i++) { + var src = srcList[i]; + if (src && this._setPresence(src, null)) { + changedSrcList.push(src); + } + } + this._emitPresence(changedSrcList); +}; + +// If emit is true and presence has changed, emits a presence event. +// Returns true, if presence has changed. Otherwise false. +Doc.prototype._setPresence = function(src, data, emit) { + if (data == null) { + if (this.presence[src] == null) return false; + delete this.presence[src]; + } else { + var isPresenceEqual = + this.presence[src] === data || + (this.type.comparePresence && this.type.comparePresence(this.presence[src], data)); + if (isPresenceEqual) return false; + this.presence[src] = data; + } + if (emit) this._emitPresence([ src ]); + return true; +}; + +Doc.prototype._emitPresence = function(srcList) { + if (srcList && srcList.length > 0) { + this.emit('presence', srcList); + } +}; + +Doc.prototype._cacheOp = function(op) { + this.cachedOps.push(op); + setTimeout(function() { + if (this.cachedOps[0] === op) this.cachedOps.shift(); + }.bind(this), this.cachedOpsTimeout); +}; diff --git a/test/client/presence-type.js b/test/client/presence-type.js new file mode 100644 index 000000000..51ad272a0 --- /dev/null +++ b/test/client/presence-type.js @@ -0,0 +1,82 @@ +// A simple type for testing presence, where: +// +// - snapshot is a list +// - operation is { index, value } -> insert value at index in snapshot +// - presence is { index } -> an index in the snapshot +exports.type = { + name: 'wrapped-presence-no-compare', + uri: 'http://sharejs.org/types/wrapped-presence-no-compare', + create: create, + apply: apply, + transform: transform, + createPresence: createPresence, + transformPresence: transformPresence +}; + +// The same as `exports.type` but implements `comparePresence`. +exports.type2 = { + name: 'wrapped-presence-with-compare', + uri: 'http://sharejs.org/types/wrapped-presence-with-compare', + create: create, + apply: apply, + transform: transform, + createPresence: createPresence, + transformPresence: transformPresence, + comparePresence: comparePresence +}; + +// The same as `exports.type` but `presence.index` is unwrapped. +exports.type3 = { + name: 'unwrapped-presence', + uri: 'http://sharejs.org/types/unwrapped-presence', + create: create, + apply: apply, + transform: transform, + createPresence: createPresence2, + transformPresence: transformPresence2 +}; + +function create(data) { + return data || []; +} + +function apply(snapshot, op) { + snapshot.splice(op.index, 0, op.value); + return snapshot; +} + +function transform(op1, op2, side) { + return op1.index < op2.index || (op1.index === op2.index && side === 'left') ? + op1 : + { + index: op1.index + 1, + value: op1.value + }; +} + +function createPresence(data) { + return { index: (data && data.index) | 0 }; +} + +function transformPresence(presence, op, isOwnOperation) { + return presence.index < op.index || (presence.index === op.index && !isOwnOperation) ? + presence : + { + index: presence.index + 1 + }; +} + +function comparePresence(presence1, presence2) { + return presence1 === presence2 || + (presence1 == null && presence2 == null) || + (presence1 != null && presence2 != null && presence1.index === presence2.index); +} + +function createPresence2(data) { + return data | 0; +} + +function transformPresence2(presence, op, isOwnOperation) { + return presence < op.index || (presence === op.index && !isOwnOperation) ? + presence : presence + 1; +} diff --git a/test/client/presence.js b/test/client/presence.js new file mode 100644 index 000000000..271b9b063 --- /dev/null +++ b/test/client/presence.js @@ -0,0 +1,1277 @@ +var async = require('async'); +var util = require('../util'); +var errorHandler = util.errorHandler; +var Backend = require('../../lib/backend'); +var ShareDBError = require('../../lib/error'); +var expect = require('expect.js'); +var types = require('../../lib/types'); +var presenceType = require('./presence-type'); +types.register(presenceType.type); +types.register(presenceType.type2); +types.register(presenceType.type3); + +[ + 'wrapped-presence-no-compare', + 'wrapped-presence-with-compare', + 'unwrapped-presence' +].forEach(function(typeName) { + function p(index) { + return typeName === 'unwrapped-presence' ? index : { index: index }; + } + + describe('client presence (' + typeName + ')', function() { + beforeEach(function() { + this.backend = new Backend(); + this.connection = this.backend.connect(); + this.connection2 = this.backend.connect(); + this.doc = this.connection.get('dogs', 'fido'); + this.doc2 = this.connection2.get('dogs', 'fido'); + }); + + afterEach(function(done) { + this.backend.close(done); + }); + + it('sends presence immediately', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + function(done) { + this.doc.requestReplyPresence = false; + this.doc.submitPresence(p(1), errorHandler(done)); + this.doc2.once('presence', function(srcList) { + expect(srcList).to.eql([ this.connection.id ]); + expect(this.doc2.data).to.eql([]); + expect(this.doc2.presence[this.connection.id]).to.eql(p(1)); + done(); + }.bind(this)); + }.bind(this) + ], allDone); + }); + + it('sends presence after pending ops', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + function(done) { + this.doc.submitOp({ index: 0, value: 'a' }, errorHandler(done)); + this.doc.submitOp({ index: 1, value: 'b' }, errorHandler(done)); + this.doc.requestReplyPresence = false; + this.doc.submitPresence(p(1), errorHandler(done)); + this.doc2.once('presence', function(srcList) { + expect(srcList).to.eql([ this.connection.id ]); + expect(this.doc2.data).to.eql([ 'a', 'b' ]); + expect(this.doc2.presence[this.connection.id]).to.eql(p(1)); + done(); + }.bind(this)); + }.bind(this) + ], allDone); + }); + + it('waits for pending ops before processing future presence', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + function(done) { + this.doc2.on('presence', function(srcList) { + expect(srcList).to.eql([ this.connection.id ]); + expect(this.doc2.data).to.eql([ 'a', 'b' ]); + expect(this.doc2.presence[this.connection.id]).to.eql(p(1)); + done(); + }.bind(this)); + // A hack to send presence for a future version. + this.doc.version += 2; + this.doc.requestReplyPresence = false; + this.doc.submitPresence(p(1), function(err) { + if (err) return done(err); + this.doc.version -= 2; + this.doc.submitOp({ index: 0, value: 'a' }, errorHandler(done)); + this.doc.submitOp({ index: 1, value: 'b' }, errorHandler(done)); + }.bind(this)); + }.bind(this) + ], allDone); + }); + + it('handles presence sent for earlier revisions (own ops, presence.index < op.index)', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'a' ], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitOp.bind(this.doc, { index: 1, value: 'b' }), + this.doc.submitOp.bind(this.doc, { index: 2, value: 'c' }), + function(done) { + this.doc2.on('presence', function(srcList) { + expect(srcList).to.eql([ this.connection.id ]); + expect(this.doc2.data).to.eql([ 'a', 'b', 'c' ]); + expect(this.doc2.presence[this.connection.id]).to.eql(p(0)); + done(); + }.bind(this)); + // A hack to send presence for an older version. + this.doc.version = 1; + this.doc.data = [ 'a' ]; + this.doc.requestReplyPresence = false; + this.doc.submitPresence(p(0), errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('handles presence sent for earlier revisions (own ops, presence.index === op.index)', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'a' ], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitOp.bind(this.doc, { index: 1, value: 'c' }), + this.doc.submitOp.bind(this.doc, { index: 1, value: 'b' }), + function(done) { + this.doc2.on('presence', function(srcList) { + expect(srcList).to.eql([ this.connection.id ]); + expect(this.doc2.data).to.eql([ 'a', 'b', 'c' ]); + expect(this.doc2.presence[this.connection.id]).to.eql(p(3)); + done(); + }.bind(this)); + // A hack to send presence for an older version. + this.doc.version = 1; + this.doc.data = [ 'a' ]; + this.doc.requestReplyPresence = false; + this.doc.submitPresence(p(1), errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('handles presence sent for earlier revisions (own ops, presence.index > op.index)', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'c' ], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitOp.bind(this.doc, { index: 0, value: 'b' }), + this.doc.submitOp.bind(this.doc, { index: 0, value: 'a' }), + function(done) { + this.doc2.on('presence', function(srcList) { + expect(srcList).to.eql([ this.connection.id ]); + expect(this.doc2.data).to.eql([ 'a', 'b', 'c' ]); + expect(this.doc2.presence[this.connection.id]).to.eql(p(3)); + done(); + }.bind(this)); + // A hack to send presence for an older version. + this.doc.version = 1; + this.doc.data = [ 'c' ]; + this.doc.requestReplyPresence = false; + this.doc.submitPresence(p(1), errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('handles presence sent for earlier revisions (non-own ops, presence.index < op.index)', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'a' ], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc2.submitOp.bind(this.doc2, { index: 1, value: 'b' }), + this.doc2.submitOp.bind(this.doc2, { index: 2, value: 'c' }), + function(done) { + this.doc2.on('presence', function(srcList) { + expect(srcList).to.eql([ this.connection.id ]); + expect(this.doc2.data).to.eql([ 'a', 'b', 'c' ]); + expect(this.doc2.presence[this.connection.id]).to.eql(p(0)); + done(); + }.bind(this)); + // A hack to send presence for an older version. + this.doc.version = 1; + this.doc.data = [ 'a' ]; + this.doc.requestReplyPresence = false; + this.doc.submitPresence(p(0), errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('handles presence sent for earlier revisions (non-own ops, presence.index === op.index)', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'a' ], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc2.submitOp.bind(this.doc2, { index: 1, value: 'c' }), + this.doc2.submitOp.bind(this.doc2, { index: 1, value: 'b' }), + function(done) { + this.doc2.on('presence', function(srcList) { + expect(srcList).to.eql([ this.connection.id ]); + expect(this.doc2.data).to.eql([ 'a', 'b', 'c' ]); + expect(this.doc2.presence[this.connection.id]).to.eql(p(1)); + done(); + }.bind(this)); + // A hack to send presence for an older version. + this.doc.version = 1; + this.doc.data = [ 'a' ]; + this.doc.requestReplyPresence = false; + this.doc.submitPresence(p(1), errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('handles presence sent for earlier revisions (non-own ops, presence.index > op.index)', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'c' ], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc2.submitOp.bind(this.doc2, { index: 0, value: 'b' }), + this.doc2.submitOp.bind(this.doc2, { index: 0, value: 'a' }), + function(done) { + this.doc2.on('presence', function(srcList) { + expect(srcList).to.eql([ this.connection.id ]); + expect(this.doc2.data).to.eql([ 'a', 'b', 'c' ]); + expect(this.doc2.presence[this.connection.id]).to.eql(p(3)); + done(); + }.bind(this)); + // A hack to send presence for an older version. + this.doc.version = 1; + this.doc.data = [ 'c' ]; + this.doc.requestReplyPresence = false; + this.doc.submitPresence(p(1), errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('handles presence sent for earlier revisions (transform against non-op)', function(allDone) { + async.series([ + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.create.bind(this.doc, [], typeName), + this.doc.submitOp.bind(this.doc, { index: 0, value: 'a' }), + this.doc.del.bind(this.doc), + this.doc.create.bind(this.doc, [ 'b' ], typeName), + function(done) { + this.doc2.once('presence', function(srcList) { + expect(srcList).to.eql([ this.connection.id ]); + expect(this.doc2.data).to.eql([ 'b' ]); + expect(this.doc2.presence[this.connection.id]).to.eql(p(0)); + done(); + }.bind(this)); + this.doc.requestReplyPresence = false; + this.doc.submitPresence(p(0), errorHandler(done)); + }.bind(this), + function(done) { + this.doc2.on('presence', function(srcList) { + expect(srcList).to.eql([ this.connection.id ]); + expect(this.doc2.data).to.eql([ 'b' ]); + expect(this.doc2.presence).to.not.have.key(this.connection.id); + done(); + }.bind(this)); + // A hack to send presence for an older version. + this.doc.version = 2; + this.doc.requestReplyPresence = false; + this.doc.submitPresence(p(1), errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('handles presence sent for earlier revisions (no cached ops)', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'a' ], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitOp.bind(this.doc, { index: 1, value: 'b' }), + this.doc.submitOp.bind(this.doc, { index: 2, value: 'c' }), + function(done) { + this.doc2.once('presence', function(srcList) { + expect(srcList).to.eql([ this.connection.id ]); + expect(this.doc2.data).to.eql([ 'a', 'b', 'c' ]); + expect(this.doc2.presence[this.connection.id]).to.eql(p(0)); + done(); + }.bind(this)); + this.doc.requestReplyPresence = false; + this.doc.submitPresence(p(0), errorHandler(done)); + }.bind(this), + function(done) { + this.doc2.cachedOps = []; + this.doc2.on('presence', function(srcList) { + expect(srcList).to.eql([ this.connection.id ]); + expect(this.doc2.data).to.eql([ 'a', 'b', 'c' ]); + expect(this.doc2.presence).to.not.have.key(this.connection.id); + done(); + }.bind(this)); + // A hack to send presence for an older version. + this.doc.version = 1; + this.doc.data = [ 'a' ]; + this.doc.requestReplyPresence = false; + this.doc.submitPresence(p(0), errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('transforms presence against local delete', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitPresence.bind(this.doc, p(0)), + this.doc2.submitPresence.bind(this.doc2, p(0)), + async.nextTick, // wait for the doc2 presence message to reach doc + function(done) { + this.doc.on('presence', function(srcList) { + expect(srcList.sort()).to.eql([ '', this.connection2.id ]); + expect(this.doc.presence).to.not.have.key(''); + expect(this.doc.presence).to.not.have.key(this.connection2.id); + done(); + }.bind(this)); + this.doc.del(errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('transforms presence against non-local delete', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitPresence.bind(this.doc, p(0)), + this.doc2.submitPresence.bind(this.doc2, p(0)), + async.nextTick, // wait for the doc2 presence message to reach doc + function(done) { + this.doc.on('presence', function(srcList) { + expect(srcList.sort()).to.eql([ '', this.connection2.id ]); + expect(this.doc.presence).to.not.have.key(''); + expect(this.doc.presence).to.not.have.key(this.connection2.id); + done(); + }.bind(this)); + this.doc2.del(errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('transforms presence against local op (presence.index != op.index)', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'a', 'c' ], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitPresence.bind(this.doc, p(0)), + this.doc2.submitPresence.bind(this.doc2, p(2)), + async.nextTick, // wait for the doc2 presence message to reach doc + function(done) { + this.doc.on('presence', function(srcList) { + expect(srcList).to.eql([ this.connection2.id ]); + expect(this.doc.presence['']).to.eql(p(0)); + expect(this.doc.presence[this.connection2.id]).to.eql(p(3)); + done(); + }.bind(this)); + this.doc.submitOp({ index: 1, value: 'b' }, errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('transforms presence against non-local op (presence.index != op.index)', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'a', 'c' ], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitPresence.bind(this.doc, p(0)), + this.doc2.submitPresence.bind(this.doc2, p(2)), + async.nextTick, // wait for the doc2 presence message to reach doc + function(done) { + this.doc.on('presence', function(srcList) { + expect(srcList).to.eql([ this.connection2.id ]); + expect(this.doc.presence['']).to.eql(p(0)); + expect(this.doc.presence[this.connection2.id]).to.eql(p(3)); + done(); + }.bind(this)); + this.doc2.submitOp({ index: 1, value: 'b' }, errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('transforms presence against local op (presence.index == op.index)', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'a', 'c' ], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitPresence.bind(this.doc, p(1)), + this.doc2.submitPresence.bind(this.doc2, p(1)), + async.nextTick, // wait for the doc2 presence message to reach doc + function(done) { + this.doc.on('presence', function(srcList) { + expect(srcList).to.eql([ '' ]); + expect(this.doc.presence['']).to.eql(p(2)); + expect(this.doc.presence[this.connection2.id]).to.eql(p(1)); + done(); + }.bind(this)); + this.doc.submitOp({ index: 1, value: 'b' }, errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('transforms presence against non-local op (presence.index == op.index)', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'a', 'c' ], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitPresence.bind(this.doc, p(1)), + this.doc2.submitPresence.bind(this.doc2, p(1)), + async.nextTick, // wait for the doc2 presence message to reach doc + function(done) { + this.doc.on('presence', function(srcList) { + expect(srcList).to.eql([ this.connection2.id ]); + expect(this.doc.presence['']).to.eql(p(1)); + expect(this.doc.presence[this.connection2.id]).to.eql(p(2)); + done(); + }.bind(this)); + this.doc2.submitOp({ index: 1, value: 'b' }, errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('caches local ops', function(allDone) { + var op = { index: 1, value: 'b' }; + async.series([ + this.doc.create.bind(this.doc, [ 'a' ], typeName), + this.doc.submitOp.bind(this.doc, op), + this.doc.del.bind(this.doc), + function(done) { + expect(this.doc.cachedOps.length).to.equal(3); + expect(this.doc.cachedOps[0].create).to.equal(true); + expect(this.doc.cachedOps[1].op).to.equal(op); + expect(this.doc.cachedOps[2].del).to.equal(true); + done(); + }.bind(this) + ], allDone); + }); + + it('caches non-local ops', function(allDone) { + var op = { index: 1, value: 'b' }; + async.series([ + this.doc2.subscribe.bind(this.doc2), + this.doc.create.bind(this.doc, [ 'a' ], typeName), + this.doc.submitOp.bind(this.doc, op), + this.doc.del.bind(this.doc), + async.nextTick, + function(done) { + expect(this.doc2.cachedOps.length).to.equal(3); + expect(this.doc2.cachedOps[0].create).to.equal(true); + expect(this.doc2.cachedOps[1].op).to.eql(op); + expect(this.doc2.cachedOps[2].del).to.equal(true); + done(); + }.bind(this) + ], allDone); + }); + + it('removes cached ops', function(allDone) { + var op = { index: 1, value: 'b' }; + this.doc.cachedOpsTimeout = 0; + async.series([ + this.doc.create.bind(this.doc, [ 'a' ], typeName), + this.doc.submitOp.bind(this.doc, op), + this.doc.del.bind(this.doc), + function(done) { + expect(this.doc.cachedOps.length).to.equal(3); + expect(this.doc.cachedOps[0].create).to.equal(true); + expect(this.doc.cachedOps[1].op).to.equal(op); + expect(this.doc.cachedOps[2].del).to.equal(true); + done(); + }.bind(this), + setTimeout, + function(done) { + expect(this.doc.cachedOps.length).to.equal(0); + done(); + }.bind(this) + ], allDone); + }); + + it('removes correct cached ops', function(allDone) { + var op = { index: 1, value: 'b' }; + this.doc.cachedOpsTimeout = 0; + async.series([ + this.doc.create.bind(this.doc, [ 'a' ], typeName), + this.doc.submitOp.bind(this.doc, op), + this.doc.del.bind(this.doc), + function(done) { + expect(this.doc.cachedOps.length).to.equal(3); + expect(this.doc.cachedOps[0].create).to.equal(true); + expect(this.doc.cachedOps[1].op).to.equal(op); + expect(this.doc.cachedOps[2].del).to.equal(true); + this.doc.cachedOps.shift(); + this.doc.cachedOps.push({ op: true }); + done(); + }.bind(this), + setTimeout, + function(done) { + expect(this.doc.cachedOps.length).to.equal(1); + expect(this.doc.cachedOps[0].op).to.equal(true); + done(); + }.bind(this) + ], allDone); + }); + + it('requests reply presence when sending presence for the first time', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'a' ], typeName), + this.doc.subscribe.bind(this.doc), + this.doc.submitPresence.bind(this.doc, p(0)), + this.doc2.subscribe.bind(this.doc2), + function(done) { + this.doc2.on('presence', function(srcList) { + if (srcList[0] === '') { + expect(srcList).to.eql([ '' ]); + expect(this.doc2.presence['']).to.eql(p(1)); + expect(this.doc2.presence).to.not.have.key(this.connection.id); + } else { + expect(srcList).to.eql([ this.connection.id ]); + expect(this.doc2.presence['']).to.eql(p(1)); + expect(this.doc2.presence[this.connection.id]).to.eql(p(0)); + expect(this.doc2.requestReplyPresence).to.equal(false); + done(); + } + }.bind(this)); + this.doc2.submitPresence(p(1), errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('fails to submit presence for uncreated document: callback(err)', function(allDone) { + async.series([ + this.doc.subscribe.bind(this.doc), + function(done) { + this.doc.submitPresence(p(0), function(err) { + expect(err).to.be.an(Error); + expect(err.code).to.equal(4015); + done(); + }); + }.bind(this) + ], allDone); + }); + + it('fails to submit presence for uncreated document: emit(err)', function(allDone) { + async.series([ + this.doc.subscribe.bind(this.doc), + function(done) { + this.doc.on('error', function(err) { + expect(err).to.be.an(Error); + expect(err.code).to.equal(4015); + done(); + }); + this.doc.submitPresence(p(0)); + }.bind(this) + ], allDone); + }); + + it('fails to submit presence, if type does not support presence: callback(err)', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, {}), + this.doc.subscribe.bind(this.doc), + function(done) { + this.doc.submitPresence(p(0), function(err) { + expect(err).to.be.an(Error); + expect(err.code).to.equal(4024); + done(); + }); + }.bind(this) + ], allDone); + }); + + it('fails to submit presence, if type does not support presence: emit(err)', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, {}), + this.doc.subscribe.bind(this.doc), + function(done) { + this.doc.on('error', function(err) { + expect(err).to.be.an(Error); + expect(err.code).to.equal(4024); + done(); + }); + this.doc.submitPresence(p(0)); + }.bind(this) + ], allDone); + }); + + it('submits null presence', function(allDone) { + async.series([ + this.doc.subscribe.bind(this.doc), + this.doc.submitPresence.bind(this.doc, null) + ], allDone); + }); + + it('sends presence once, if submitted multiple times synchronously', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'a' ], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + function(done) { + this.doc2.on('presence', function(srcList) { + expect(srcList).to.eql([ this.connection.id ]); + expect(this.doc2.presence[this.connection.id]).to.eql(p(2)); + done(); + }.bind(this)); + this.doc.requestReplyPresence = false; + this.doc.submitPresence(p(0), errorHandler(done)); + this.doc.submitPresence(p(1), errorHandler(done)); + this.doc.submitPresence(p(2), errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('buffers presence until subscribed', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'a' ], typeName), + this.doc2.subscribe.bind(this.doc2), + function(done) { + this.doc2.on('presence', function(srcList) { + expect(srcList).to.eql([ this.connection.id ]); + expect(this.doc2.presence[this.connection.id]).to.eql(p(1)); + done(); + }.bind(this)); + this.doc.requestReplyPresence = false; + this.doc.submitPresence(p(1), errorHandler(done)); + setTimeout(function() { + this.doc.subscribe(function(err) { + if (err) return done(err); + expect(this.doc2.presence).to.eql({}); + }.bind(this)); + }.bind(this)); + }.bind(this) + ], allDone); + }); + + it('buffers presence when disconnected', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'a' ], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + function(done) { + this.doc2.on('presence', function(srcList) { + expect(srcList).to.eql([ this.connection.id ]); + expect(this.doc2.presence[this.connection.id]).to.eql(p(1)); + done(); + }.bind(this)); + this.connection.close(); + this.doc.submitPresence(p(1), errorHandler(done)); + process.nextTick(function() { + this.backend.connect(this.connection); + this.doc.requestReplyPresence = false; + }.bind(this)); + }.bind(this) + ], allDone); + }); + + it('submits presence without a callback', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'a' ], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + function(done) { + this.doc2.on('presence', function(srcList) { + expect(srcList).to.eql([ this.connection.id ]); + expect(this.doc2.presence[this.connection.id]).to.eql(p(0)); + done(); + }.bind(this)); + this.doc.requestReplyPresence = false; + this.doc.submitPresence(p(0)); + }.bind(this) + ], allDone); + }); + + it('cancels pending presence on destroy', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'a' ], typeName), + function(done) { + this.doc.submitPresence(p(0), done); + console.log(!!this.doc.inflightPresence, !!this.doc.pendingPresence); + this.doc.destroy(errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('cancels inflight presence on destroy', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'a' ], typeName), + this.doc.subscribe.bind(this.doc), + function(done) { + this.doc.submitPresence(p(0), done); + process.nextTick(function() { + this.doc.destroy(errorHandler(done)); + }.bind(this)); + }.bind(this) + ], allDone); + }); + + it('receives presence after doc is deleted', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'a' ], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitPresence.bind(this.doc, p(0)), + async.nextTick, + function(done) { + expect(this.doc2.presence[this.connection.id]).to.eql(p(0)); + this.doc2.on('presence', function(srcList) { + expect(srcList).to.eql([ this.connection.id ]); + expect(this.doc2.presence).to.not.have.key(this.connection.id); + done(); + }.bind(this)); + this.doc.requestReplyPresence = false; + this.doc.submitPresence(p(0), errorHandler(done)); + this.doc2.del(errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('clears peer presence on peer disconnection', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'a' ], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitPresence.bind(this.doc, p(0)), + this.doc2.submitPresence.bind(this.doc2, p(1)), + async.nextTick, + function(done) { + expect(this.doc.presence['']).to.eql(p(0)); + expect(this.doc.presence[this.connection2.id]).to.eql(p(1)); + expect(this.doc2.presence[this.connection.id]).to.eql(p(0)); + expect(this.doc2.presence['']).to.eql(p(1)); + + var connectionId = this.connection.id; + this.doc2.on('presence', function(srcList) { + expect(srcList).to.eql([ connectionId ]); + expect(this.doc2.presence).to.not.have.key(connectionId); + expect(this.doc2.presence['']).to.eql(p(1)); + done(); + }.bind(this)); + this.connection.close(); + + // this.doc.requestReplyPresence = false; + // this.doc.submitPresence(p(0), errorHandler(done)); + // this.doc2.del(errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('clears peer presence on own disconnection', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'a' ], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitPresence.bind(this.doc, p(0)), + this.doc2.submitPresence.bind(this.doc2, p(1)), + async.nextTick, + function(done) { + expect(this.doc.presence['']).to.eql(p(0)); + expect(this.doc.presence[this.connection2.id]).to.eql(p(1)); + expect(this.doc2.presence[this.connection.id]).to.eql(p(0)); + expect(this.doc2.presence['']).to.eql(p(1)); + + var connectionId = this.connection.id; + this.doc2.on('presence', function(srcList) { + expect(srcList).to.eql([ connectionId ]); + expect(this.doc2.presence).to.not.have.key(connectionId); + expect(this.doc2.presence['']).to.eql(p(1)); + done(); + }.bind(this)); + this.connection2.close(); + + // this.doc.requestReplyPresence = false; + // this.doc.submitPresence(p(0), errorHandler(done)); + // this.doc2.del(errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('clears peer presence on peer unsubscribe', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'a' ], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitPresence.bind(this.doc, p(0)), + this.doc2.submitPresence.bind(this.doc2, p(1)), + async.nextTick, + function(done) { + expect(this.doc.presence['']).to.eql(p(0)); + expect(this.doc.presence[this.connection2.id]).to.eql(p(1)); + expect(this.doc2.presence[this.connection.id]).to.eql(p(0)); + expect(this.doc2.presence['']).to.eql(p(1)); + + var connectionId = this.connection.id; + this.doc2.on('presence', function(srcList) { + expect(srcList).to.eql([ connectionId ]); + expect(this.doc2.presence).to.not.have.key(connectionId); + expect(this.doc2.presence['']).to.eql(p(1)); + done(); + }.bind(this)); + this.doc.unsubscribe(errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('clears peer presence on own unsubscribe', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'a' ], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitPresence.bind(this.doc, p(0)), + this.doc2.submitPresence.bind(this.doc2, p(1)), + async.nextTick, + function(done) { + expect(this.doc.presence['']).to.eql(p(0)); + expect(this.doc.presence[this.connection2.id]).to.eql(p(1)); + expect(this.doc2.presence[this.connection.id]).to.eql(p(0)); + expect(this.doc2.presence['']).to.eql(p(1)); + + var connectionId = this.connection.id; + this.doc2.on('presence', function(srcList) { + expect(srcList).to.eql([ connectionId ]); + expect(this.doc2.presence).to.not.have.key(connectionId); + expect(this.doc2.presence['']).to.eql(p(1)); + done(); + }.bind(this)); + this.doc2.unsubscribe(errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('pauses inflight and pending presence on disconnect', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'a' ], typeName), + this.doc.subscribe.bind(this.doc), + function(done) { + var called = 0; + function callback(err) { + if (err) return done(err); + if (++called === 2) done(); + } + this.doc.submitPresence(p(0), callback); + process.nextTick(function() { + this.doc.submitPresence(p(1), callback); + this.connection.close(); + process.nextTick(function() { + this.backend.connect(this.connection); + }.bind(this)); + }.bind(this)); + }.bind(this) + ], allDone); + }); + + it('pauses inflight and pending presence on unsubscribe', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'a' ], typeName), + this.doc.subscribe.bind(this.doc), + function(done) { + var called = 0; + function callback(err) { + if (err) return done(err); + if (++called === 2) done(); + } + this.doc.submitPresence(p(0), callback); + process.nextTick(function() { + this.doc.submitPresence(p(1), callback); + this.doc.unsubscribe(errorHandler(done)); + process.nextTick(function() { + this.doc.subscribe(errorHandler(done)); + }.bind(this)); + }.bind(this)); + }.bind(this) + ], allDone); + }); + + it('transforms received presence against inflight and pending ops (presence.index < op.index)', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'a' ], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + function(done) { + this.doc2.on('presence', function(srcList) { + expect(srcList).to.eql([ this.connection.id ]); + expect(this.doc2.presence[this.connection.id]).to.eql(p(0)); + done(); + }.bind(this)); + this.doc.requestReplyPresence = false; + this.doc.submitPresence(p(0), errorHandler(done)); + this.doc2.submitOp({ index: 1, value: 'b' }, errorHandler(done)) + this.doc2.submitOp({ index: 2, value: 'c' }, errorHandler(done)) + }.bind(this) + ], allDone); + }); + + it('transforms received presence against inflight and pending ops (presence.index === op.index)', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'a' ], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + function(done) { + this.doc2.on('presence', function(srcList) { + expect(srcList).to.eql([ this.connection.id ]); + expect(this.doc2.presence[this.connection.id]).to.eql(p(1)); + done(); + }.bind(this)); + this.doc.requestReplyPresence = false; + this.doc.submitPresence(p(1), errorHandler(done)); + this.doc2.submitOp({ index: 1, value: 'c' }, errorHandler(done)) + this.doc2.submitOp({ index: 1, value: 'b' }, errorHandler(done)) + }.bind(this) + ], allDone); + }); + + it('transforms received presence against inflight and pending ops (presence.index > op.index)', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'c' ], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + function(done) { + this.doc2.on('presence', function(srcList) { + expect(srcList).to.eql([ this.connection.id ]); + expect(this.doc2.presence[this.connection.id]).to.eql(p(3)); + done(); + }.bind(this)); + this.doc.requestReplyPresence = false; + this.doc.submitPresence(p(1), errorHandler(done)); + this.doc2.submitOp({ index: 0, value: 'b' }, errorHandler(done)) + this.doc2.submitOp({ index: 0, value: 'a' }, errorHandler(done)) + }.bind(this) + ], allDone); + }); + + it('transforms received presence against inflight delete', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'c' ], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitPresence.bind(this.doc, p(1)), + async.nextTick, + function(done) { + this.doc2.on('presence', function(srcList) { + expect(srcList).to.eql([ this.connection.id ]); + expect(this.doc2.presence).to.not.have.key(this.connection.id); + done(); + }.bind(this)); + this.doc.requestReplyPresence = false; + this.doc.submitPresence(p(2), errorHandler(done)); + this.doc2.del(errorHandler(done)); + this.doc2.create([ 'c' ], typeName, errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('transforms received presence against a pending delete', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'c' ], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitPresence.bind(this.doc, p(1)), + async.nextTick, + function(done) { + var firstCall = true; + this.doc2.on('presence', function(srcList) { + if (firstCall) return firstCall = false; + expect(srcList).to.eql([ this.connection.id ]); + expect(this.doc2.presence).to.not.have.key(this.connection.id); + done(); + }.bind(this)); + this.doc.requestReplyPresence = false; + this.doc.submitPresence(p(2), errorHandler(done)); + this.doc2.submitOp({ index: 0, value: 'b' }, errorHandler(done)); + this.doc2.del(errorHandler(done)); + this.doc2.create([ 'c' ], typeName, errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('emits the same presence only if comparePresence is not implemented (local presence)', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'c' ], typeName), + this.doc.subscribe.bind(this.doc), + this.doc.submitPresence.bind(this.doc, p(1)), + function(done) { + this.doc.on('presence', function(srcList) { + if (typeName === 'wrapped-presence-no-compare') { + expect(srcList).to.eql([ '' ]); + expect(this.doc.presence['']).to.eql(p(1)); + done(); + } else { + done(new Error('Unexpected presence event')); + } + }.bind(this)); + this.doc.submitPresence(p(1), typeName === 'wrapped-presence-no-compare' ? errorHandler(done) : done); + }.bind(this) + ], allDone); + }); + + it('emits the same presence only if comparePresence is not implemented (non-local presence)', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'c' ], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitPresence.bind(this.doc, p(1)), + async.nextTick, + function(done) { + this.doc2.on('presence', function(srcList) { + if (typeName === 'wrapped-presence-no-compare') { + expect(srcList).to.eql([ this.connection.id ]); + expect(this.doc2.presence[this.connection.id]).to.eql(p(1)); + done(); + } else { + done(new Error('Unexpected presence event')); + } + }.bind(this)); + this.doc.submitPresence(p(1), typeName === 'wrapped-presence-no-compare' ? errorHandler(done) : done); + }.bind(this) + ], allDone); + }); + + it('returns an error when not subscribed on the server', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'c' ], typeName), + this.doc.subscribe.bind(this.doc), + function(done) { + this.connection.sendUnsubscribe(this.doc); + process.nextTick(done); + }.bind(this), + function(done) { + this.doc.on('error', done); + this.doc.submitPresence(p(0), function(err) { + expect(err).to.be.an(Error); + expect(err.code).to.equal(4025); + done(); + }.bind(this)); + }.bind(this) + ], allDone); + }); + + it('emits an error when not subscribed on the server and no callback is provided', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'c' ], typeName), + this.doc.subscribe.bind(this.doc), + function(done) { + this.connection.sendUnsubscribe(this.doc); + process.nextTick(done); + }.bind(this), + function(done) { + this.doc.on('error', function(err) { + expect(err).to.be.an(Error); + expect(err.code).to.equal(4025); + done(); + }.bind(this)); + this.doc.submitPresence(p(0)); + }.bind(this) + ], allDone); + }); + + it('returns an error when the server gets an old sequence number', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'c' ], typeName), + this.doc.subscribe.bind(this.doc), + this.doc.submitPresence.bind(this.doc, p(0)), + async.nextTick, + function(done) { + this.doc.on('error', done); + this.connection.seq--; + this.doc.submitPresence(p(0), function(err) { + expect(err).to.be.an(Error); + expect(err.code).to.equal(4026); + done(); + }.bind(this)); + }.bind(this) + ], allDone); + }); + + it('emits an error when the server gets an old sequence number and no callback is provided', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'c' ], typeName), + this.doc.subscribe.bind(this.doc), + this.doc.submitPresence.bind(this.doc, p(0)), + async.nextTick, + function(done) { + this.doc.on('error', function(err) { + expect(err).to.be.an(Error); + expect(err.code).to.equal(4026); + done(); + }.bind(this)); + this.connection.seq--; + this.doc.submitPresence(p(0)); + }.bind(this) + ], allDone); + }); + + it('returns an error when publishing presence fails', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'c' ], typeName), + this.doc.subscribe.bind(this.doc), + async.nextTick, + function(done) { + var sendPresence = this.backend.sendPresence; + this.backend.sendPresence = function(presence, callback) { + if (presence.a === 'p' && presence.v != null) { + return callback(new ShareDBError(-1, 'Test publishing error')); + } + sendPresence.apply(this, arguments); + }; + this.doc.on('error', done); + this.doc.submitPresence(p(0), function(err) { + expect(err).to.be.an(Error); + expect(err.code).to.equal(-1); + done(); + }.bind(this)); + }.bind(this) + ], allDone); + }); + + it('emits an error when publishing presence fails and no callback is provided', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'c' ], typeName), + this.doc.subscribe.bind(this.doc), + async.nextTick, + function(done) { + var sendPresence = this.backend.sendPresence; + this.backend.sendPresence = function(presence, callback) { + if (presence.a === 'p' && presence.v != null) { + return callback(new ShareDBError(-1, 'Test publishing error')); + } + sendPresence.apply(this, arguments); + }; + this.doc.on('error', function(err) { + expect(err).to.be.an(Error); + expect(err.code).to.equal(-1); + done(); + }.bind(this)); + this.doc.submitPresence(p(0)); + }.bind(this) + ], allDone); + }); + + it('clears presence on hard rollback and emits an error', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'a', 'b', 'c' ], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitPresence.bind(this.doc, p(0)), + this.doc2.submitPresence.bind(this.doc2, p(0)), + async.nextTick, + function(done) { + // A hack to allow testing of hard rollback of both inflight and pending presence. + var doc = this.doc; + var _handlePresence = this.doc._handlePresence; + this.doc._handlePresence = function(err, presence) { + setTimeout(function() { + _handlePresence.call(doc, err, presence); + }); + }; + + this.doc.submitPresence(p(1)); // inflightPresence + process.nextTick(function() { + this.doc.submitPresence(p(2)); // pendingPresence + + var presenceEmitted = false; + this.doc.on('presence', function(srcList) { + expect(presenceEmitted).to.equal(false); + presenceEmitted = true; + expect(srcList.sort()).to.eql([ '', this.connection2.id ]); + expect(this.doc.presence).to.not.have.key(''); + expect(this.doc.presence).to.not.have.key(this.connection2.id); + }.bind(this)); + + this.doc.on('error', function(err) { + expect(presenceEmitted).to.equal(true); + expect(err).to.be.an(Error); + expect(err.code).to.equal(4000); + done(); + }.bind(this)); + + // send an invalid op + this.doc._submit({ index: 3, value: 'b' }, true); + }.bind(this)); + }.bind(this) + ], allDone); + }); + + it('clears presence on hard rollback and executes all callbacks', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'a', 'b', 'c' ], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitPresence.bind(this.doc, p(0)), + this.doc2.submitPresence.bind(this.doc2, p(0)), + async.nextTick, + function(done) { + // A hack to allow testing of hard rollback of both inflight and pending presence. + var doc = this.doc; + var _handlePresence = this.doc._handlePresence; + this.doc._handlePresence = function(err, presence) { + setTimeout(function() { + _handlePresence.call(doc, err, presence); + }); + }; + + var presenceEmitted = false; + var called = 0; + function callback(err) { + expect(presenceEmitted).to.equal(true); + expect(err).to.be.an(Error); + expect(err.code).to.equal(4000); + if (++called < 3) return; + done(); + } + this.doc.submitPresence(p(1), callback); // inflightPresence + process.nextTick(function() { + this.doc.submitPresence(p(2), callback); // pendingPresence + + this.doc.on('presence', function(srcList) { + expect(presenceEmitted).to.equal(false); + presenceEmitted = true; + expect(srcList.sort()).to.eql([ '', this.connection2.id ]); + expect(this.doc.presence).to.not.have.key(''); + expect(this.doc.presence).to.not.have.key(this.connection2.id); + }.bind(this)); + this.doc.on('error', done); + + // send an invalid op + this.doc._submit({ index: 3, value: 'b' }, true, callback); + }.bind(this)); + }.bind(this) + ], allDone); + }); + + function testReceivedMessageExpiry(expireCache, reduceSequence) { + return function(allDone) { + var lastPresence = null; + var handleMessage = this.connection.handleMessage; + this.connection.handleMessage = function(message) { + if (message.a === 'p' && message.src) { + lastPresence = JSON.parse(JSON.stringify(message)); + } + return handleMessage.apply(this, arguments); + }; + if (expireCache) { + this.doc.receivedPresenceTimeout = 0; + } + async.series([ + this.doc.create.bind(this.doc, [ 'a' ], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + function(done) { + this.doc2.requestReplyPresence = false; + this.doc2.submitPresence(p(0), done); + }.bind(this), + async.nextTick, // wait for presence to reach this.doc + this.doc2.submitOp.bind(this.doc2, { index: 1, value: 'b' }), // forces processing of all received presence + async.nextTick, // wait for op to reach this.doc + function(done) { + expect(this.doc.data).to.eql([ 'a', 'b' ]); + expect(this.doc.presence[this.connection2.id]).to.eql(p(0)); + // Replay the `lastPresence` with modified payload. + lastPresence.p = p(1); + lastPresence.v++; // +1 to account for the op above + if (reduceSequence) { + lastPresence.seq--; + } + this.connection.handleMessage(lastPresence); + process.nextTick(done); + }.bind(this), + function(done) { + expect(this.doc.presence[this.connection2.id]).to.eql(expireCache ? p(1) : p(0)); + process.nextTick(done); + }.bind(this) + ], allDone); + }; + } + + it('ignores an old message (cache not expired, presence.seq === cachedPresence.seq)', testReceivedMessageExpiry(false, false)); + it('ignores an old message (cache not expired, presence.seq < cachedPresence.seq)', testReceivedMessageExpiry(false, true)); + it('processes an old message (cache expired, presence.seq === cachedPresence.seq)', testReceivedMessageExpiry(true, false)); + it('processes an old message (cache expired, presence.seq < cachedPresence.seq)', testReceivedMessageExpiry(true, true)); + }); +}); diff --git a/test/util.js b/test/util.js index 508f81a00..dfbfc0b8f 100644 --- a/test/util.js +++ b/test/util.js @@ -14,3 +14,9 @@ exports.pluck = function(docs, key) { } return values; }; + +exports.errorHandler = function(callback) { + return function(err) { + if (err) callback(err); + }; +}; From 33c72644521c82bf528344b9b6f95bb6debd26d4 Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Fri, 27 Apr 2018 12:53:46 +0100 Subject: [PATCH 10/45] Execute some callbacks asynchronously --- lib/agent.js | 8 ++++++-- lib/client/doc.js | 18 ++++++++++++------ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/lib/agent.js b/lib/agent.js index f04baa2bd..ac9c12d70 100644 --- a/lib/agent.js +++ b/lib/agent.js @@ -606,11 +606,15 @@ Agent.prototype._createOp = function(request) { Agent.prototype._presence = function(presence, callback) { if (presence.seq <= this.maxPresenceSeq) { - return callback(new ShareDBError(4026, 'Presence data superseded')); + return process.nextTick(function() { + callback(new ShareDBError(4026, 'Presence data superseded')); + }); } this.maxPresenceSeq = presence.seq; if (!this.subscribedDocs[presence.c] || !this.subscribedDocs[presence.c][presence.d]) { - return callback(new ShareDBError(4025, 'Cannot send presence. Not subscribed to document: ' + presence.c + ' ' + presence.d)); + return process.nextTick(function() { + callback(new ShareDBError(4025, 'Cannot send presence. Not subscribed to document: ' + presence.c + ' ' + presence.d)); + }); } this.backend.sendPresence(presence, function(err) { if (err) return callback(err); diff --git a/lib/client/doc.js b/lib/client/doc.js index e92c4b644..1900b2d68 100644 --- a/lib/client/doc.js +++ b/lib/client/doc.js @@ -1029,15 +1029,21 @@ function callEach(callbacks, err) { Doc.prototype.submitPresence = function (data, callback) { if (data != null) { if (!this.type) { - var err = new ShareDBError(4015, 'Cannot submit presence. Document has not been created. ' + this.collection + '.' + this.id); - if (callback) return callback(err); - return this.emit('error', err); + var doc = this; + return process.nextTick(function() { + var err = new ShareDBError(4015, 'Cannot submit presence. Document has not been created. ' + doc.collection + '.' + doc.id); + if (callback) return callback(err); + doc.emit('error', err); + }); } if (!this.type.createPresence || !this.type.transformPresence) { - var err = new ShareDBError(4024, 'Cannot submit presence. Document\'s type does not support presence. ' + this.collection + '.' + this.id); - if (callback) return callback(err); - return this.emit('error', err); + var doc = this; + return process.nextTick(function() { + var err = new ShareDBError(4024, 'Cannot submit presence. Document\'s type does not support presence. ' + doc.collection + '.' + doc.id); + if (callback) return callback(err); + doc.emit('error', err); + }); } data = this.type.createPresence(data); From 8ff4b3335f3055ae3d3cefa7ee1773a3656919ba Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Mon, 30 Apr 2018 11:42:50 +0100 Subject: [PATCH 11/45] Don't send presence unnecessarily --- lib/client/doc.js | 14 ++++++++--- test/client/presence.js | 55 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/lib/client/doc.js b/lib/client/doc.js index 1900b2d68..62d8e17fa 100644 --- a/lib/client/doc.js +++ b/lib/client/doc.js @@ -1049,9 +1049,17 @@ Doc.prototype.submitPresence = function (data, callback) { data = this.type.createPresence(data); } - if (!this.pendingPresence) this.pendingPresence = []; - if (callback) this.pendingPresence.push(callback); - this._setPresence('', data, true); + if (this._setPresence('', data, true) || this.pendingPresence || this.inflightPresence) { + if (!this.pendingPresence) { + this.pendingPresence = []; + } + if (callback) { + this.pendingPresence.push(callback); + } + + } else if (callback) { + process.nextTick(callback); + } var doc = this; process.nextTick(function() { diff --git a/test/client/presence.js b/test/client/presence.js index 271b9b063..90371cf6d 100644 --- a/test/client/presence.js +++ b/test/client/presence.js @@ -1060,7 +1060,7 @@ types.register(presenceType.type3); function(done) { this.doc.on('error', done); this.connection.seq--; - this.doc.submitPresence(p(0), function(err) { + this.doc.submitPresence(p(1), function(err) { expect(err).to.be.an(Error); expect(err.code).to.equal(4026); done(); @@ -1082,7 +1082,60 @@ types.register(presenceType.type3); done(); }.bind(this)); this.connection.seq--; + this.doc.submitPresence(p(1)); + }.bind(this) + ], allDone); + }); + + it('does not publish presence unnecessarily', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'c' ], typeName), + this.doc.subscribe.bind(this.doc), + this.doc.submitPresence.bind(this.doc, p(0)), + async.nextTick, + function(done) { + this.doc.on('error', done); + // Decremented sequence number would cause the server to return an error, however, + // the message won't be sent to the server at all because the presence data has not changed. + this.connection.seq--; + this.doc.submitPresence(p(0), function(err) { + if (typeName === 'wrapped-presence-no-compare') { + // The OT type does not support comparing presence. + expect(err).to.be.an(Error); + expect(err.code).to.equal(4026); + } else { + expect(err).to.not.be.ok(); + } + done(); + }.bind(this)); + }.bind(this) + ], allDone); + }); + + it('does not publish presence unnecessarily when no callback is provided', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'c' ], typeName), + this.doc.subscribe.bind(this.doc), + this.doc.submitPresence.bind(this.doc, p(0)), + async.nextTick, + function(done) { + this.doc.on('error', function(err) { + if (typeName === 'wrapped-presence-no-compare') { + // The OT type does not support comparing presence. + expect(err).to.be.an(Error); + expect(err.code).to.equal(4026); + done(); + } else { + done(err); + } + }.bind(this)); + // Decremented sequence number would cause the server to return an error, however, + // the message won't be sent to the server at all because the presence data has not changed. + this.connection.seq--; this.doc.submitPresence(p(0)); + if (typeName !== 'wrapped-presence-no-compare') { + process.nextTick(done); + } }.bind(this) ], allDone); }); From 0ff380dda1c6263a31bd3878e73283424877a36e Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Mon, 30 Apr 2018 12:06:54 +0100 Subject: [PATCH 12/45] Re-sync presence after re-subscribe and re-connect --- lib/client/doc.js | 2 ++ test/client/presence.js | 51 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/lib/client/doc.js b/lib/client/doc.js index 62d8e17fa..fe689ef2b 100644 --- a/lib/client/doc.js +++ b/lib/client/doc.js @@ -1242,6 +1242,8 @@ Doc.prototype._pausePresence = function() { this.inflightPresence; this.inflightPresence = null; this.inflightPresenceSeq = 0; + } else if (!this.pendingPresence && this.presence[''] != null) { + this.pendingPresence = []; } this.receivedPresence = Object.create(null); this.requestReplyPresence = true; diff --git a/test/client/presence.js b/test/client/presence.js index 90371cf6d..ea9c14caf 100644 --- a/test/client/presence.js +++ b/test/client/presence.js @@ -869,6 +869,57 @@ types.register(presenceType.type3); ], allDone); }); + it('re-synchronizes presence after reconnecting', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'a' ], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitPresence.bind(this.doc, p(0)), + this.doc2.submitPresence.bind(this.doc2, p(1)), + async.nextTick, + function(done) { + expect(this.doc.presence['']).to.eql(p(0)); + expect(this.doc.presence[this.connection2.id]).to.eql(p(1)); + this.connection.close(); + expect(this.doc.presence['']).to.eql(p(0)); + expect(this.doc.presence).to.not.have.key(this.connection2.id); + this.backend.connect(this.connection); + process.nextTick(done); + }.bind(this), + setTimeout, // wait for re-sync + function(done) { + expect(this.doc.presence['']).to.eql(p(0)); + expect(this.doc.presence[this.connection2.id]).to.eql(p(1)); + process.nextTick(done); + }.bind(this) + ], allDone); + }); + + it('re-synchronizes presence after resubscribing', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [ 'a' ], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitPresence.bind(this.doc, p(0)), + this.doc2.submitPresence.bind(this.doc2, p(1)), + async.nextTick, + function(done) { + expect(this.doc.presence['']).to.eql(p(0)); + expect(this.doc.presence[this.connection2.id]).to.eql(p(1)); + this.doc.unsubscribe(errorHandler(done)); + expect(this.doc.presence['']).to.eql(p(0)); + expect(this.doc.presence).to.not.have.key(this.connection2.id); + this.doc.subscribe(done); + }.bind(this), + setTimeout, // wait for re-sync + function(done) { + expect(this.doc.presence['']).to.eql(p(0)); + expect(this.doc.presence[this.connection2.id]).to.eql(p(1)); + process.nextTick(done); + }.bind(this) + ], allDone); + }); + it('transforms received presence against inflight and pending ops (presence.index < op.index)', function(allDone) { async.series([ this.doc.create.bind(this.doc, [ 'a' ], typeName), From d67dd6a777661fad628ff84fa869f2a96d6fe6b9 Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Tue, 1 May 2018 14:35:24 +0100 Subject: [PATCH 13/45] Emit presence asynchronously --- lib/client/doc.js | 5 +- test/client/presence.js | 130 +++++++++++++++++++++------------------- 2 files changed, 71 insertions(+), 64 deletions(-) diff --git a/lib/client/doc.js b/lib/client/doc.js index fe689ef2b..f84ce65e1 100644 --- a/lib/client/doc.js +++ b/lib/client/doc.js @@ -1277,7 +1277,10 @@ Doc.prototype._setPresence = function(src, data, emit) { Doc.prototype._emitPresence = function(srcList) { if (srcList && srcList.length > 0) { - this.emit('presence', srcList); + var doc = this; + process.nextTick(function() { + doc.emit('presence', srcList); + }); } }; diff --git a/test/client/presence.js b/test/client/presence.js index ea9c14caf..b5cec497a 100644 --- a/test/client/presence.js +++ b/test/client/presence.js @@ -295,7 +295,7 @@ types.register(presenceType.type3); this.doc.version = 1; this.doc.data = [ 'a' ]; this.doc.requestReplyPresence = false; - this.doc.submitPresence(p(0), errorHandler(done)); + this.doc.submitPresence(p(1), errorHandler(done)); }.bind(this) ], allDone); }); @@ -307,7 +307,7 @@ types.register(presenceType.type3); this.doc2.subscribe.bind(this.doc2), this.doc.submitPresence.bind(this.doc, p(0)), this.doc2.submitPresence.bind(this.doc2, p(0)), - async.nextTick, // wait for the doc2 presence message to reach doc + setTimeout, function(done) { this.doc.on('presence', function(srcList) { expect(srcList.sort()).to.eql([ '', this.connection2.id ]); @@ -327,7 +327,7 @@ types.register(presenceType.type3); this.doc2.subscribe.bind(this.doc2), this.doc.submitPresence.bind(this.doc, p(0)), this.doc2.submitPresence.bind(this.doc2, p(0)), - async.nextTick, // wait for the doc2 presence message to reach doc + setTimeout, function(done) { this.doc.on('presence', function(srcList) { expect(srcList.sort()).to.eql([ '', this.connection2.id ]); @@ -347,7 +347,7 @@ types.register(presenceType.type3); this.doc2.subscribe.bind(this.doc2), this.doc.submitPresence.bind(this.doc, p(0)), this.doc2.submitPresence.bind(this.doc2, p(2)), - async.nextTick, // wait for the doc2 presence message to reach doc + setTimeout, function(done) { this.doc.on('presence', function(srcList) { expect(srcList).to.eql([ this.connection2.id ]); @@ -367,7 +367,7 @@ types.register(presenceType.type3); this.doc2.subscribe.bind(this.doc2), this.doc.submitPresence.bind(this.doc, p(0)), this.doc2.submitPresence.bind(this.doc2, p(2)), - async.nextTick, // wait for the doc2 presence message to reach doc + setTimeout, function(done) { this.doc.on('presence', function(srcList) { expect(srcList).to.eql([ this.connection2.id ]); @@ -387,7 +387,7 @@ types.register(presenceType.type3); this.doc2.subscribe.bind(this.doc2), this.doc.submitPresence.bind(this.doc, p(1)), this.doc2.submitPresence.bind(this.doc2, p(1)), - async.nextTick, // wait for the doc2 presence message to reach doc + setTimeout, function(done) { this.doc.on('presence', function(srcList) { expect(srcList).to.eql([ '' ]); @@ -407,7 +407,7 @@ types.register(presenceType.type3); this.doc2.subscribe.bind(this.doc2), this.doc.submitPresence.bind(this.doc, p(1)), this.doc2.submitPresence.bind(this.doc2, p(1)), - async.nextTick, // wait for the doc2 presence message to reach doc + setTimeout, function(done) { this.doc.on('presence', function(srcList) { expect(srcList).to.eql([ this.connection2.id ]); @@ -443,7 +443,7 @@ types.register(presenceType.type3); this.doc.create.bind(this.doc, [ 'a' ], typeName), this.doc.submitOp.bind(this.doc, op), this.doc.del.bind(this.doc), - async.nextTick, + setTimeout, function(done) { expect(this.doc2.cachedOps.length).to.equal(3); expect(this.doc2.cachedOps[0].create).to.equal(true); @@ -698,7 +698,7 @@ types.register(presenceType.type3); this.doc.subscribe.bind(this.doc), this.doc2.subscribe.bind(this.doc2), this.doc.submitPresence.bind(this.doc, p(0)), - async.nextTick, + setTimeout, function(done) { expect(this.doc2.presence[this.connection.id]).to.eql(p(0)); this.doc2.on('presence', function(srcList) { @@ -720,7 +720,7 @@ types.register(presenceType.type3); this.doc2.subscribe.bind(this.doc2), this.doc.submitPresence.bind(this.doc, p(0)), this.doc2.submitPresence.bind(this.doc2, p(1)), - async.nextTick, + setTimeout, function(done) { expect(this.doc.presence['']).to.eql(p(0)); expect(this.doc.presence[this.connection2.id]).to.eql(p(1)); @@ -750,7 +750,7 @@ types.register(presenceType.type3); this.doc2.subscribe.bind(this.doc2), this.doc.submitPresence.bind(this.doc, p(0)), this.doc2.submitPresence.bind(this.doc2, p(1)), - async.nextTick, + setTimeout, function(done) { expect(this.doc.presence['']).to.eql(p(0)); expect(this.doc.presence[this.connection2.id]).to.eql(p(1)); @@ -780,7 +780,7 @@ types.register(presenceType.type3); this.doc2.subscribe.bind(this.doc2), this.doc.submitPresence.bind(this.doc, p(0)), this.doc2.submitPresence.bind(this.doc2, p(1)), - async.nextTick, + setTimeout, function(done) { expect(this.doc.presence['']).to.eql(p(0)); expect(this.doc.presence[this.connection2.id]).to.eql(p(1)); @@ -806,7 +806,7 @@ types.register(presenceType.type3); this.doc2.subscribe.bind(this.doc2), this.doc.submitPresence.bind(this.doc, p(0)), this.doc2.submitPresence.bind(this.doc2, p(1)), - async.nextTick, + setTimeout, function(done) { expect(this.doc.presence['']).to.eql(p(0)); expect(this.doc.presence[this.connection2.id]).to.eql(p(1)); @@ -876,7 +876,7 @@ types.register(presenceType.type3); this.doc2.subscribe.bind(this.doc2), this.doc.submitPresence.bind(this.doc, p(0)), this.doc2.submitPresence.bind(this.doc2, p(1)), - async.nextTick, + setTimeout, function(done) { expect(this.doc.presence['']).to.eql(p(0)); expect(this.doc.presence[this.connection2.id]).to.eql(p(1)); @@ -902,7 +902,7 @@ types.register(presenceType.type3); this.doc2.subscribe.bind(this.doc2), this.doc.submitPresence.bind(this.doc, p(0)), this.doc2.submitPresence.bind(this.doc2, p(1)), - async.nextTick, + setTimeout, function(done) { expect(this.doc.presence['']).to.eql(p(0)); expect(this.doc.presence[this.connection2.id]).to.eql(p(1)); @@ -983,7 +983,7 @@ types.register(presenceType.type3); this.doc.subscribe.bind(this.doc), this.doc2.subscribe.bind(this.doc2), this.doc.submitPresence.bind(this.doc, p(1)), - async.nextTick, + setTimeout, function(done) { this.doc2.on('presence', function(srcList) { expect(srcList).to.eql([ this.connection.id ]); @@ -1004,7 +1004,7 @@ types.register(presenceType.type3); this.doc.subscribe.bind(this.doc), this.doc2.subscribe.bind(this.doc2), this.doc.submitPresence.bind(this.doc, p(1)), - async.nextTick, + setTimeout, function(done) { var firstCall = true; this.doc2.on('presence', function(srcList) { @@ -1048,7 +1048,7 @@ types.register(presenceType.type3); this.doc.subscribe.bind(this.doc), this.doc2.subscribe.bind(this.doc2), this.doc.submitPresence.bind(this.doc, p(1)), - async.nextTick, + setTimeout, function(done) { this.doc2.on('presence', function(srcList) { if (typeName === 'wrapped-presence-no-compare') { @@ -1107,7 +1107,7 @@ types.register(presenceType.type3); this.doc.create.bind(this.doc, [ 'c' ], typeName), this.doc.subscribe.bind(this.doc), this.doc.submitPresence.bind(this.doc, p(0)), - async.nextTick, + setTimeout, function(done) { this.doc.on('error', done); this.connection.seq--; @@ -1125,7 +1125,7 @@ types.register(presenceType.type3); this.doc.create.bind(this.doc, [ 'c' ], typeName), this.doc.subscribe.bind(this.doc), this.doc.submitPresence.bind(this.doc, p(0)), - async.nextTick, + setTimeout, function(done) { this.doc.on('error', function(err) { expect(err).to.be.an(Error); @@ -1143,7 +1143,7 @@ types.register(presenceType.type3); this.doc.create.bind(this.doc, [ 'c' ], typeName), this.doc.subscribe.bind(this.doc), this.doc.submitPresence.bind(this.doc, p(0)), - async.nextTick, + setTimeout, function(done) { this.doc.on('error', done); // Decremented sequence number would cause the server to return an error, however, @@ -1168,7 +1168,7 @@ types.register(presenceType.type3); this.doc.create.bind(this.doc, [ 'c' ], typeName), this.doc.subscribe.bind(this.doc), this.doc.submitPresence.bind(this.doc, p(0)), - async.nextTick, + setTimeout, function(done) { this.doc.on('error', function(err) { if (typeName === 'wrapped-presence-no-compare') { @@ -1195,7 +1195,7 @@ types.register(presenceType.type3); async.series([ this.doc.create.bind(this.doc, [ 'c' ], typeName), this.doc.subscribe.bind(this.doc), - async.nextTick, + setTimeout, function(done) { var sendPresence = this.backend.sendPresence; this.backend.sendPresence = function(presence, callback) { @@ -1218,7 +1218,7 @@ types.register(presenceType.type3); async.series([ this.doc.create.bind(this.doc, [ 'c' ], typeName), this.doc.subscribe.bind(this.doc), - async.nextTick, + setTimeout, function(done) { var sendPresence = this.backend.sendPresence; this.backend.sendPresence = function(presence, callback) { @@ -1244,7 +1244,7 @@ types.register(presenceType.type3); this.doc2.subscribe.bind(this.doc2), this.doc.submitPresence.bind(this.doc, p(0)), this.doc2.submitPresence.bind(this.doc2, p(0)), - async.nextTick, + setTimeout, function(done) { // A hack to allow testing of hard rollback of both inflight and pending presence. var doc = this.doc; @@ -1254,30 +1254,31 @@ types.register(presenceType.type3); _handlePresence.call(doc, err, presence); }); }; + process.nextTick(done); + }.bind(this), + this.doc.submitPresence.bind(this.doc, p(1)), // inflightPresence + process.nextTick, // wait for "presence" event + this.doc.submitPresence.bind(this.doc, p(2)), // pendingPresence + process.nextTick, // wait for "presence" event + function(done) { + var presenceEmitted = false; + this.doc.on('presence', function(srcList) { + expect(presenceEmitted).to.equal(false); + presenceEmitted = true; + expect(srcList.sort()).to.eql([ '', this.connection2.id ]); + expect(this.doc.presence).to.not.have.key(''); + expect(this.doc.presence).to.not.have.key(this.connection2.id); + }.bind(this)); - this.doc.submitPresence(p(1)); // inflightPresence - process.nextTick(function() { - this.doc.submitPresence(p(2)); // pendingPresence - - var presenceEmitted = false; - this.doc.on('presence', function(srcList) { - expect(presenceEmitted).to.equal(false); - presenceEmitted = true; - expect(srcList.sort()).to.eql([ '', this.connection2.id ]); - expect(this.doc.presence).to.not.have.key(''); - expect(this.doc.presence).to.not.have.key(this.connection2.id); - }.bind(this)); - - this.doc.on('error', function(err) { - expect(presenceEmitted).to.equal(true); - expect(err).to.be.an(Error); - expect(err.code).to.equal(4000); - done(); - }.bind(this)); - - // send an invalid op - this.doc._submit({ index: 3, value: 'b' }, true); + this.doc.on('error', function(err) { + expect(presenceEmitted).to.equal(true); + expect(err).to.be.an(Error); + expect(err.code).to.equal(4000); + done(); }.bind(this)); + + // send an invalid op + this.doc._submit({}, true); }.bind(this) ], allDone); }); @@ -1289,7 +1290,7 @@ types.register(presenceType.type3); this.doc2.subscribe.bind(this.doc2), this.doc.submitPresence.bind(this.doc, p(0)), this.doc2.submitPresence.bind(this.doc2, p(0)), - async.nextTick, + setTimeout, function(done) { // A hack to allow testing of hard rollback of both inflight and pending presence. var doc = this.doc; @@ -1299,7 +1300,9 @@ types.register(presenceType.type3); _handlePresence.call(doc, err, presence); }); }; - + process.nextTick(done); + }.bind(this), + function(done) { var presenceEmitted = false; var called = 0; function callback(err) { @@ -1310,20 +1313,21 @@ types.register(presenceType.type3); done(); } this.doc.submitPresence(p(1), callback); // inflightPresence - process.nextTick(function() { + process.nextTick(function() { // wait for presence event this.doc.submitPresence(p(2), callback); // pendingPresence - - this.doc.on('presence', function(srcList) { - expect(presenceEmitted).to.equal(false); - presenceEmitted = true; - expect(srcList.sort()).to.eql([ '', this.connection2.id ]); - expect(this.doc.presence).to.not.have.key(''); - expect(this.doc.presence).to.not.have.key(this.connection2.id); + process.nextTick(function() { // wait for presence event + this.doc.on('presence', function(srcList) { + expect(presenceEmitted).to.equal(false); + presenceEmitted = true; + expect(srcList.sort()).to.eql([ '', this.connection2.id ]); + expect(this.doc.presence).to.not.have.key(''); + expect(this.doc.presence).to.not.have.key(this.connection2.id); + }.bind(this)); + this.doc.on('error', done); + + // send an invalid op + this.doc._submit({ index: 3, value: 'b' }, true, callback); }.bind(this)); - this.doc.on('error', done); - - // send an invalid op - this.doc._submit({ index: 3, value: 'b' }, true, callback); }.bind(this)); }.bind(this) ], allDone); @@ -1350,9 +1354,9 @@ types.register(presenceType.type3); this.doc2.requestReplyPresence = false; this.doc2.submitPresence(p(0), done); }.bind(this), - async.nextTick, // wait for presence to reach this.doc + setTimeout, this.doc2.submitOp.bind(this.doc2, { index: 1, value: 'b' }), // forces processing of all received presence - async.nextTick, // wait for op to reach this.doc + setTimeout, function(done) { expect(this.doc.data).to.eql([ 'a', 'b' ]); expect(this.doc.presence[this.connection2.id]).to.eql(p(0)); From e8ec2158a46c6cc9c3e1f90c5909723e1acb5580 Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Wed, 9 May 2018 12:53:18 +0100 Subject: [PATCH 14/45] Add `submitted` param to `presence` event --- README.md | 4 +- lib/client/doc.js | 14 ++--- test/client/presence.js | 130 +++++++++++++++++++++++++--------------- 3 files changed, 92 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 3cbdea6e8..2c6dc1293 100644 --- a/README.md +++ b/README.md @@ -258,8 +258,8 @@ An operation was applied to the data. `source` will be `false` for ops received `doc.on('del', function(data, source) {...})` The document was deleted. Document contents before deletion are passed in as an argument. `source` will be `false` for ops received from the server and defaults to `true` for ops generated locally. -`doc.on('presence', function(srcList) {...})` -Presence data has changed. `srcList` is an Array of `doc.presence` property names for which values have changed. +`doc.on('presence', function(srcList, submitted) {...})` +Presence data has changed. `srcList` is an Array of `doc.presence` property names for which values have changed. `submitted` is `true`, if the event is the result of new presence data being submitted by the local or remote user, otherwise it is `false` - eg if the presence data was transformed against an operation or was cleared on unsubscribe, disconnect or roll-back. `doc.on('error', function(err) {...})` There was an error fetching the document or applying an operation. diff --git a/lib/client/doc.js b/lib/client/doc.js index f84ce65e1..fa35b4926 100644 --- a/lib/client/doc.js +++ b/lib/client/doc.js @@ -991,7 +991,7 @@ Doc.prototype._hardRollback = function(err) { changedSrcList.push(src); } } - this._emitPresence(changedSrcList); + this._emitPresence(changedSrcList, false); // Fetch the latest from the server to get us back into a working state var doc = this; @@ -1208,7 +1208,7 @@ Doc.prototype._processAllReceivedPresence = function() { changedSrcList.push(src); } } - this._emitPresence(changedSrcList); + this._emitPresence(changedSrcList, true); }; Doc.prototype._transformPresence = function(src, op) { @@ -1231,7 +1231,7 @@ Doc.prototype._transformAllPresence = function(op) { changedSrcList.push(src); } } - this._emitPresence(changedSrcList); + this._emitPresence(changedSrcList, false); }; Doc.prototype._pausePresence = function() { @@ -1255,7 +1255,7 @@ Doc.prototype._pausePresence = function() { changedSrcList.push(src); } } - this._emitPresence(changedSrcList); + this._emitPresence(changedSrcList, false); }; // If emit is true and presence has changed, emits a presence event. @@ -1271,15 +1271,15 @@ Doc.prototype._setPresence = function(src, data, emit) { if (isPresenceEqual) return false; this.presence[src] = data; } - if (emit) this._emitPresence([ src ]); + if (emit) this._emitPresence([ src ], true); return true; }; -Doc.prototype._emitPresence = function(srcList) { +Doc.prototype._emitPresence = function(srcList, submitted) { if (srcList && srcList.length > 0) { var doc = this; process.nextTick(function() { - doc.emit('presence', srcList); + doc.emit('presence', srcList, submitted); }); } }; diff --git a/test/client/presence.js b/test/client/presence.js index b5cec497a..3ddc86bff 100644 --- a/test/client/presence.js +++ b/test/client/presence.js @@ -40,8 +40,9 @@ types.register(presenceType.type3); function(done) { this.doc.requestReplyPresence = false; this.doc.submitPresence(p(1), errorHandler(done)); - this.doc2.once('presence', function(srcList) { + this.doc2.once('presence', function(srcList, submitted) { expect(srcList).to.eql([ this.connection.id ]); + expect(submitted).to.equal(true); expect(this.doc2.data).to.eql([]); expect(this.doc2.presence[this.connection.id]).to.eql(p(1)); done(); @@ -60,8 +61,9 @@ types.register(presenceType.type3); this.doc.submitOp({ index: 1, value: 'b' }, errorHandler(done)); this.doc.requestReplyPresence = false; this.doc.submitPresence(p(1), errorHandler(done)); - this.doc2.once('presence', function(srcList) { + this.doc2.once('presence', function(srcList, submitted) { expect(srcList).to.eql([ this.connection.id ]); + expect(submitted).to.equal(true); expect(this.doc2.data).to.eql([ 'a', 'b' ]); expect(this.doc2.presence[this.connection.id]).to.eql(p(1)); done(); @@ -76,8 +78,9 @@ types.register(presenceType.type3); this.doc.subscribe.bind(this.doc), this.doc2.subscribe.bind(this.doc2), function(done) { - this.doc2.on('presence', function(srcList) { + this.doc2.on('presence', function(srcList, submitted) { expect(srcList).to.eql([ this.connection.id ]); + expect(submitted).to.equal(true); expect(this.doc2.data).to.eql([ 'a', 'b' ]); expect(this.doc2.presence[this.connection.id]).to.eql(p(1)); done(); @@ -103,8 +106,9 @@ types.register(presenceType.type3); this.doc.submitOp.bind(this.doc, { index: 1, value: 'b' }), this.doc.submitOp.bind(this.doc, { index: 2, value: 'c' }), function(done) { - this.doc2.on('presence', function(srcList) { + this.doc2.on('presence', function(srcList, submitted) { expect(srcList).to.eql([ this.connection.id ]); + expect(submitted).to.equal(true); expect(this.doc2.data).to.eql([ 'a', 'b', 'c' ]); expect(this.doc2.presence[this.connection.id]).to.eql(p(0)); done(); @@ -126,8 +130,9 @@ types.register(presenceType.type3); this.doc.submitOp.bind(this.doc, { index: 1, value: 'c' }), this.doc.submitOp.bind(this.doc, { index: 1, value: 'b' }), function(done) { - this.doc2.on('presence', function(srcList) { + this.doc2.on('presence', function(srcList, submitted) { expect(srcList).to.eql([ this.connection.id ]); + expect(submitted).to.equal(true); expect(this.doc2.data).to.eql([ 'a', 'b', 'c' ]); expect(this.doc2.presence[this.connection.id]).to.eql(p(3)); done(); @@ -149,8 +154,9 @@ types.register(presenceType.type3); this.doc.submitOp.bind(this.doc, { index: 0, value: 'b' }), this.doc.submitOp.bind(this.doc, { index: 0, value: 'a' }), function(done) { - this.doc2.on('presence', function(srcList) { + this.doc2.on('presence', function(srcList, submitted) { expect(srcList).to.eql([ this.connection.id ]); + expect(submitted).to.equal(true); expect(this.doc2.data).to.eql([ 'a', 'b', 'c' ]); expect(this.doc2.presence[this.connection.id]).to.eql(p(3)); done(); @@ -172,8 +178,9 @@ types.register(presenceType.type3); this.doc2.submitOp.bind(this.doc2, { index: 1, value: 'b' }), this.doc2.submitOp.bind(this.doc2, { index: 2, value: 'c' }), function(done) { - this.doc2.on('presence', function(srcList) { + this.doc2.on('presence', function(srcList, submitted) { expect(srcList).to.eql([ this.connection.id ]); + expect(submitted).to.equal(true); expect(this.doc2.data).to.eql([ 'a', 'b', 'c' ]); expect(this.doc2.presence[this.connection.id]).to.eql(p(0)); done(); @@ -195,8 +202,9 @@ types.register(presenceType.type3); this.doc2.submitOp.bind(this.doc2, { index: 1, value: 'c' }), this.doc2.submitOp.bind(this.doc2, { index: 1, value: 'b' }), function(done) { - this.doc2.on('presence', function(srcList) { + this.doc2.on('presence', function(srcList, submitted) { expect(srcList).to.eql([ this.connection.id ]); + expect(submitted).to.equal(true); expect(this.doc2.data).to.eql([ 'a', 'b', 'c' ]); expect(this.doc2.presence[this.connection.id]).to.eql(p(1)); done(); @@ -218,8 +226,9 @@ types.register(presenceType.type3); this.doc2.submitOp.bind(this.doc2, { index: 0, value: 'b' }), this.doc2.submitOp.bind(this.doc2, { index: 0, value: 'a' }), function(done) { - this.doc2.on('presence', function(srcList) { + this.doc2.on('presence', function(srcList, submitted) { expect(srcList).to.eql([ this.connection.id ]); + expect(submitted).to.equal(true); expect(this.doc2.data).to.eql([ 'a', 'b', 'c' ]); expect(this.doc2.presence[this.connection.id]).to.eql(p(3)); done(); @@ -242,8 +251,9 @@ types.register(presenceType.type3); this.doc.del.bind(this.doc), this.doc.create.bind(this.doc, [ 'b' ], typeName), function(done) { - this.doc2.once('presence', function(srcList) { + this.doc2.once('presence', function(srcList, submitted) { expect(srcList).to.eql([ this.connection.id ]); + expect(submitted).to.equal(true); expect(this.doc2.data).to.eql([ 'b' ]); expect(this.doc2.presence[this.connection.id]).to.eql(p(0)); done(); @@ -252,8 +262,9 @@ types.register(presenceType.type3); this.doc.submitPresence(p(0), errorHandler(done)); }.bind(this), function(done) { - this.doc2.on('presence', function(srcList) { + this.doc2.on('presence', function(srcList, submitted) { expect(srcList).to.eql([ this.connection.id ]); + expect(submitted).to.equal(true); expect(this.doc2.data).to.eql([ 'b' ]); expect(this.doc2.presence).to.not.have.key(this.connection.id); done(); @@ -274,8 +285,9 @@ types.register(presenceType.type3); this.doc.submitOp.bind(this.doc, { index: 1, value: 'b' }), this.doc.submitOp.bind(this.doc, { index: 2, value: 'c' }), function(done) { - this.doc2.once('presence', function(srcList) { + this.doc2.once('presence', function(srcList, submitted) { expect(srcList).to.eql([ this.connection.id ]); + expect(submitted).to.equal(true); expect(this.doc2.data).to.eql([ 'a', 'b', 'c' ]); expect(this.doc2.presence[this.connection.id]).to.eql(p(0)); done(); @@ -285,8 +297,9 @@ types.register(presenceType.type3); }.bind(this), function(done) { this.doc2.cachedOps = []; - this.doc2.on('presence', function(srcList) { + this.doc2.on('presence', function(srcList, submitted) { expect(srcList).to.eql([ this.connection.id ]); + expect(submitted).to.equal(true); expect(this.doc2.data).to.eql([ 'a', 'b', 'c' ]); expect(this.doc2.presence).to.not.have.key(this.connection.id); done(); @@ -309,8 +322,9 @@ types.register(presenceType.type3); this.doc2.submitPresence.bind(this.doc2, p(0)), setTimeout, function(done) { - this.doc.on('presence', function(srcList) { + this.doc.on('presence', function(srcList, submitted) { expect(srcList.sort()).to.eql([ '', this.connection2.id ]); + expect(submitted).to.equal(false); expect(this.doc.presence).to.not.have.key(''); expect(this.doc.presence).to.not.have.key(this.connection2.id); done(); @@ -329,8 +343,9 @@ types.register(presenceType.type3); this.doc2.submitPresence.bind(this.doc2, p(0)), setTimeout, function(done) { - this.doc.on('presence', function(srcList) { + this.doc.on('presence', function(srcList, submitted) { expect(srcList.sort()).to.eql([ '', this.connection2.id ]); + expect(submitted).to.equal(false); expect(this.doc.presence).to.not.have.key(''); expect(this.doc.presence).to.not.have.key(this.connection2.id); done(); @@ -349,8 +364,9 @@ types.register(presenceType.type3); this.doc2.submitPresence.bind(this.doc2, p(2)), setTimeout, function(done) { - this.doc.on('presence', function(srcList) { + this.doc.on('presence', function(srcList, submitted) { expect(srcList).to.eql([ this.connection2.id ]); + expect(submitted).to.equal(false); expect(this.doc.presence['']).to.eql(p(0)); expect(this.doc.presence[this.connection2.id]).to.eql(p(3)); done(); @@ -369,8 +385,9 @@ types.register(presenceType.type3); this.doc2.submitPresence.bind(this.doc2, p(2)), setTimeout, function(done) { - this.doc.on('presence', function(srcList) { + this.doc.on('presence', function(srcList, submitted) { expect(srcList).to.eql([ this.connection2.id ]); + expect(submitted).to.equal(false); expect(this.doc.presence['']).to.eql(p(0)); expect(this.doc.presence[this.connection2.id]).to.eql(p(3)); done(); @@ -389,8 +406,9 @@ types.register(presenceType.type3); this.doc2.submitPresence.bind(this.doc2, p(1)), setTimeout, function(done) { - this.doc.on('presence', function(srcList) { + this.doc.on('presence', function(srcList, submitted) { expect(srcList).to.eql([ '' ]); + expect(submitted).to.equal(false); expect(this.doc.presence['']).to.eql(p(2)); expect(this.doc.presence[this.connection2.id]).to.eql(p(1)); done(); @@ -409,8 +427,9 @@ types.register(presenceType.type3); this.doc2.submitPresence.bind(this.doc2, p(1)), setTimeout, function(done) { - this.doc.on('presence', function(srcList) { + this.doc.on('presence', function(srcList, submitted) { expect(srcList).to.eql([ this.connection2.id ]); + expect(submitted).to.equal(false); expect(this.doc.presence['']).to.eql(p(1)); expect(this.doc.presence[this.connection2.id]).to.eql(p(2)); done(); @@ -508,9 +527,10 @@ types.register(presenceType.type3); this.doc.submitPresence.bind(this.doc, p(0)), this.doc2.subscribe.bind(this.doc2), function(done) { - this.doc2.on('presence', function(srcList) { + this.doc2.on('presence', function(srcList, submitted) { if (srcList[0] === '') { expect(srcList).to.eql([ '' ]); + expect(submitted).to.equal(true); expect(this.doc2.presence['']).to.eql(p(1)); expect(this.doc2.presence).to.not.have.key(this.connection.id); } else { @@ -595,8 +615,9 @@ types.register(presenceType.type3); this.doc.subscribe.bind(this.doc), this.doc2.subscribe.bind(this.doc2), function(done) { - this.doc2.on('presence', function(srcList) { + this.doc2.on('presence', function(srcList, submitted) { expect(srcList).to.eql([ this.connection.id ]); + expect(submitted).to.equal(true); expect(this.doc2.presence[this.connection.id]).to.eql(p(2)); done(); }.bind(this)); @@ -613,8 +634,9 @@ types.register(presenceType.type3); this.doc.create.bind(this.doc, [ 'a' ], typeName), this.doc2.subscribe.bind(this.doc2), function(done) { - this.doc2.on('presence', function(srcList) { + this.doc2.on('presence', function(srcList, submitted) { expect(srcList).to.eql([ this.connection.id ]); + expect(submitted).to.equal(true); expect(this.doc2.presence[this.connection.id]).to.eql(p(1)); done(); }.bind(this)); @@ -636,8 +658,9 @@ types.register(presenceType.type3); this.doc.subscribe.bind(this.doc), this.doc2.subscribe.bind(this.doc2), function(done) { - this.doc2.on('presence', function(srcList) { + this.doc2.on('presence', function(srcList, submitted) { expect(srcList).to.eql([ this.connection.id ]); + expect(submitted).to.equal(true); expect(this.doc2.presence[this.connection.id]).to.eql(p(1)); done(); }.bind(this)); @@ -657,8 +680,9 @@ types.register(presenceType.type3); this.doc.subscribe.bind(this.doc), this.doc2.subscribe.bind(this.doc2), function(done) { - this.doc2.on('presence', function(srcList) { + this.doc2.on('presence', function(srcList, submitted) { expect(srcList).to.eql([ this.connection.id ]); + expect(submitted).to.equal(true); expect(this.doc2.presence[this.connection.id]).to.eql(p(0)); done(); }.bind(this)); @@ -701,13 +725,16 @@ types.register(presenceType.type3); setTimeout, function(done) { expect(this.doc2.presence[this.connection.id]).to.eql(p(0)); - this.doc2.on('presence', function(srcList) { + this.doc2.on('presence', function(srcList, submitted) { expect(srcList).to.eql([ this.connection.id ]); + // The call to `del` transforms the presence and fires the event. + // The call to `submitPresence` does not fire the event because presence is already null. + expect(submitted).to.equal(false); expect(this.doc2.presence).to.not.have.key(this.connection.id); done(); }.bind(this)); this.doc.requestReplyPresence = false; - this.doc.submitPresence(p(0), errorHandler(done)); + this.doc.submitPresence(p(1), errorHandler(done)); this.doc2.del(errorHandler(done)); }.bind(this) ], allDone); @@ -728,17 +755,14 @@ types.register(presenceType.type3); expect(this.doc2.presence['']).to.eql(p(1)); var connectionId = this.connection.id; - this.doc2.on('presence', function(srcList) { + this.doc2.on('presence', function(srcList, submitted) { expect(srcList).to.eql([ connectionId ]); + expect(submitted).to.equal(true); expect(this.doc2.presence).to.not.have.key(connectionId); expect(this.doc2.presence['']).to.eql(p(1)); done(); }.bind(this)); this.connection.close(); - - // this.doc.requestReplyPresence = false; - // this.doc.submitPresence(p(0), errorHandler(done)); - // this.doc2.del(errorHandler(done)); }.bind(this) ], allDone); }); @@ -758,17 +782,14 @@ types.register(presenceType.type3); expect(this.doc2.presence['']).to.eql(p(1)); var connectionId = this.connection.id; - this.doc2.on('presence', function(srcList) { + this.doc2.on('presence', function(srcList, submitted) { expect(srcList).to.eql([ connectionId ]); + expect(submitted).to.equal(false); expect(this.doc2.presence).to.not.have.key(connectionId); expect(this.doc2.presence['']).to.eql(p(1)); done(); }.bind(this)); this.connection2.close(); - - // this.doc.requestReplyPresence = false; - // this.doc.submitPresence(p(0), errorHandler(done)); - // this.doc2.del(errorHandler(done)); }.bind(this) ], allDone); }); @@ -788,8 +809,9 @@ types.register(presenceType.type3); expect(this.doc2.presence['']).to.eql(p(1)); var connectionId = this.connection.id; - this.doc2.on('presence', function(srcList) { + this.doc2.on('presence', function(srcList, submitted) { expect(srcList).to.eql([ connectionId ]); + expect(submitted).to.equal(true); expect(this.doc2.presence).to.not.have.key(connectionId); expect(this.doc2.presence['']).to.eql(p(1)); done(); @@ -814,8 +836,9 @@ types.register(presenceType.type3); expect(this.doc2.presence['']).to.eql(p(1)); var connectionId = this.connection.id; - this.doc2.on('presence', function(srcList) { + this.doc2.on('presence', function(srcList, submitted) { expect(srcList).to.eql([ connectionId ]); + expect(submitted).to.equal(false); expect(this.doc2.presence).to.not.have.key(connectionId); expect(this.doc2.presence['']).to.eql(p(1)); done(); @@ -926,8 +949,9 @@ types.register(presenceType.type3); this.doc.subscribe.bind(this.doc), this.doc2.subscribe.bind(this.doc2), function(done) { - this.doc2.on('presence', function(srcList) { + this.doc2.on('presence', function(srcList, submitted) { expect(srcList).to.eql([ this.connection.id ]); + expect(submitted).to.equal(true); expect(this.doc2.presence[this.connection.id]).to.eql(p(0)); done(); }.bind(this)); @@ -945,8 +969,9 @@ types.register(presenceType.type3); this.doc.subscribe.bind(this.doc), this.doc2.subscribe.bind(this.doc2), function(done) { - this.doc2.on('presence', function(srcList) { + this.doc2.on('presence', function(srcList, submitted) { expect(srcList).to.eql([ this.connection.id ]); + expect(submitted).to.equal(true); expect(this.doc2.presence[this.connection.id]).to.eql(p(1)); done(); }.bind(this)); @@ -964,8 +989,9 @@ types.register(presenceType.type3); this.doc.subscribe.bind(this.doc), this.doc2.subscribe.bind(this.doc2), function(done) { - this.doc2.on('presence', function(srcList) { + this.doc2.on('presence', function(srcList, submitted) { expect(srcList).to.eql([ this.connection.id ]); + expect(submitted).to.equal(true); expect(this.doc2.presence[this.connection.id]).to.eql(p(3)); done(); }.bind(this)); @@ -985,8 +1011,11 @@ types.register(presenceType.type3); this.doc.submitPresence.bind(this.doc, p(1)), setTimeout, function(done) { - this.doc2.on('presence', function(srcList) { + this.doc2.on('presence', function(srcList, submitted) { expect(srcList).to.eql([ this.connection.id ]); + // The call to `del` transforms the presence and fires the event. + // The call to `submitPresence` does not fire the event because presence is already null. + expect(submitted).to.equal(false); expect(this.doc2.presence).to.not.have.key(this.connection.id); done(); }.bind(this)); @@ -1007,9 +1036,12 @@ types.register(presenceType.type3); setTimeout, function(done) { var firstCall = true; - this.doc2.on('presence', function(srcList) { + this.doc2.on('presence', function(srcList, submitted) { if (firstCall) return firstCall = false; expect(srcList).to.eql([ this.connection.id ]); + // The call to `del` transforms the presence and fires the event. + // The call to `submitPresence` does not fire the event because presence is already null. + expect(submitted).to.equal(false); expect(this.doc2.presence).to.not.have.key(this.connection.id); done(); }.bind(this)); @@ -1028,9 +1060,10 @@ types.register(presenceType.type3); this.doc.subscribe.bind(this.doc), this.doc.submitPresence.bind(this.doc, p(1)), function(done) { - this.doc.on('presence', function(srcList) { + this.doc.on('presence', function(srcList, submitted) { if (typeName === 'wrapped-presence-no-compare') { expect(srcList).to.eql([ '' ]); + expect(submitted).to.equal(true); expect(this.doc.presence['']).to.eql(p(1)); done(); } else { @@ -1050,9 +1083,10 @@ types.register(presenceType.type3); this.doc.submitPresence.bind(this.doc, p(1)), setTimeout, function(done) { - this.doc2.on('presence', function(srcList) { + this.doc2.on('presence', function(srcList, submitted) { if (typeName === 'wrapped-presence-no-compare') { expect(srcList).to.eql([ this.connection.id ]); + expect(submitted).to.equal(true); expect(this.doc2.presence[this.connection.id]).to.eql(p(1)); done(); } else { @@ -1262,10 +1296,11 @@ types.register(presenceType.type3); process.nextTick, // wait for "presence" event function(done) { var presenceEmitted = false; - this.doc.on('presence', function(srcList) { + this.doc.on('presence', function(srcList, submitted) { expect(presenceEmitted).to.equal(false); presenceEmitted = true; expect(srcList.sort()).to.eql([ '', this.connection2.id ]); + expect(submitted).to.equal(false); expect(this.doc.presence).to.not.have.key(''); expect(this.doc.presence).to.not.have.key(this.connection2.id); }.bind(this)); @@ -1316,10 +1351,11 @@ types.register(presenceType.type3); process.nextTick(function() { // wait for presence event this.doc.submitPresence(p(2), callback); // pendingPresence process.nextTick(function() { // wait for presence event - this.doc.on('presence', function(srcList) { + this.doc.on('presence', function(srcList, submitted) { expect(presenceEmitted).to.equal(false); presenceEmitted = true; expect(srcList.sort()).to.eql([ '', this.connection2.id ]); + expect(submitted).to.equal(false); expect(this.doc.presence).to.not.have.key(''); expect(this.doc.presence).to.not.have.key(this.connection2.id); }.bind(this)); From 173bf3a58379e61edaacb75ccfa14b69bb5d55af Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Wed, 13 Jun 2018 23:44:42 +0100 Subject: [PATCH 15/45] Use the correct variable The issue could not cause problems in practice because ot-json0 does not support presence. --- lib/client/doc.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/client/doc.js b/lib/client/doc.js index fa35b4926..039adcf7f 100644 --- a/lib/client/doc.js +++ b/lib/client/doc.js @@ -616,7 +616,7 @@ Doc.prototype._otApply = function(op, source) { // Apply the individual op component this.emit('before op', componentOp.op, source); this.data = this.type.apply(this.data, componentOp.op); - this._transformAllPresence(op); + this._transformAllPresence(componentOp); this.emit('op', componentOp.op, source); } // Pop whatever was submitted since we started applying this op From 054d34d90e870277372df80293bff03aeb820cd3 Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Thu, 21 Jun 2018 11:43:00 +0100 Subject: [PATCH 16/45] Small test update --- test/client/presence.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/client/presence.js b/test/client/presence.js index 3ddc86bff..d74532a49 100644 --- a/test/client/presence.js +++ b/test/client/presence.js @@ -1313,7 +1313,7 @@ types.register(presenceType.type3); }.bind(this)); // send an invalid op - this.doc._submit({}, true); + this.doc._submit({}, null); }.bind(this) ], allDone); }); @@ -1362,7 +1362,7 @@ types.register(presenceType.type3); this.doc.on('error', done); // send an invalid op - this.doc._submit({ index: 3, value: 'b' }, true, callback); + this.doc._submit({ index: 3, value: 'b' }, null, callback); }.bind(this)); }.bind(this)); }.bind(this) From 9f843efc23f9c07c9dc3c2bb1d43a67955c16a79 Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Thu, 21 Jun 2018 17:35:48 +0100 Subject: [PATCH 17/45] Implement undo/redo --- .editorconfig | 9 + .travis.yml | 1 - README.md | 42 +- lib/client/doc.js | 353 +++++++- package-lock.json | 1528 ++++++++++++++++++++++++++++++++ package.json | 3 + test/client/invertible-type.js | 80 ++ test/client/undo-redo.js | 1376 ++++++++++++++++++++++++++++ 8 files changed, 3371 insertions(+), 21 deletions(-) create mode 100644 .editorconfig create mode 100644 package-lock.json create mode 100644 test/client/invertible-type.js create mode 100644 test/client/undo-redo.js diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..e29f5e504 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = LF +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/.travis.yml b/.travis.yml index 7bd066b20..5c87e1e6d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,7 +4,6 @@ node_js: - "9" - "8" - "6" - - "4" script: "npm run jshint && npm run test-cover" # Send coverage data to Coveralls after_script: "cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js" diff --git a/README.md b/README.md index 12d84d744..2023a0908 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ tracker](https://github.com/share/sharedb/issues). - Realtime synchronization of any JSON document - Concurrent multi-user collaboration +- Local undo and redo - Synchronous editing API with asynchronous eventual consistency - Realtime query subscriptions - Simple integration with any database - [MongoDB](https://github.com/share/sharedb-mongo), [PostgresQL](https://github.com/share/sharedb-postgres) (experimental) @@ -214,7 +215,7 @@ changes. Returns a [`ShareDB.Query`](#class-sharedbquery) instance. ### Class: `ShareDB.Doc` -`doc.type` _(String_) +`doc.type` _(String)_ The [OT type](https://github.com/ottypes/docs) of this document `doc.id` _(String)_ @@ -223,6 +224,13 @@ Unique document ID `doc.data` _(Object)_ Document contents. Available after document is fetched or subscribed to. +`doc.undoLimit` _(Number, read-write, default=100)_ +The max number of operations to keep on the undo stack. + +`doc.undoComposeTimeout` _(Number, read-write, default=1000)_ +The max time difference between operations in milliseconds, +which still allows "UNDOABLE" operations to be composed on the undo stack. + `doc.fetch(function(err) {...})` Populate the fields on `doc` with a snapshot of the document from the server. @@ -246,8 +254,8 @@ The document was created. Technically, this means it has a type. `source` will b `doc.on('before op'), function(op, source) {...})` An operation is about to be applied to the data. `source` will be `false` for ops received from the server and defaults to `true` for ops generated locally. -`doc.on('op', function(op, source) {...})` -An operation was applied to the data. `source` will be `false` for ops received from the server and defaults to `true` for ops generated locally. +`doc.on('op', function(op, source, operationType) {...})` +An operation was applied to the data. `source` will be `false` for ops received from the server and defaults to `true` for ops generated locally. `operationType` is one of the following: `"UNDOABLE"` _(local operation that can be undone)_, `"FIXED"` _(local or remote operation that can't be undone nor redone)_, `"UNDO"` _(local undo operation that can be redone)_ and `"REDO"` _(local redo operation that can be undone)_. `doc.on('del', function(data, source) {...})` The document was deleted. Document contents before deletion are passed in as an argument. `source` will be `false` for ops received from the server and defaults to `true` for ops generated locally. @@ -271,6 +279,19 @@ Apply operation to document and send it to the server. [operations for the default `'ot-json0'` type](https://github.com/ottypes/json0#summary-of-operations). Call this after you've either fetched or subscribed to the document. * `options.source` Argument passed to the `'op'` event locally. This is not sent to the server or other clients. Defaults to `true`. +* `options.undoable` Should it be possible to undo this operation, default=false. +* `options.fixUpUndoStack` Determines how a non-undoable operation affects the undo stack. If `false` (default), the operation transforms the undo stack, otherwise it is inverted and composed into the last operation on the undo stack. +* `options.fixUpRedoStack` Determines how a non-undoable operation affects the redo stack. If `false` (default), the operation transforms the redo stack, otherwise it is inverted and composed into the last operation on the redo stack. + +`doc.submitSnapshot(snapshot[, options][, function(err) {...}])` +Diff the current and the provided snapshots to generate an operation, apply the operation to the document and send it to the server. +`snapshot` structure depends on the document type. +Call this after you've either fetched or subscribed to the document. +* `options.source` Argument passed to the `'op'` event locally. This is not sent to the server or other clients. Defaults to `true`. +* `options.undoable` Should it be possible to undo this operation, default=false. +* `options.fixUpUndoStack` Determines how a non-undoable operation affects the undo stack. If `false` (default), the operation transforms the undo stack, otherwise it is inverted and composed into the last operation on the undo stack. +* `options.fixUpRedoStack` Determines how a non-undoable operation affects the redo stack. If `false` (default), the operation transforms the redo stack, otherwise it is inverted and composed into the last operation on the redo stack. +* `options.diffHint` A hint passed into the `diff`/`diffX` functions defined by the document type. `doc.del([options][, function(err) {...}])` Delete the document locally and send delete operation to the server. @@ -285,6 +306,20 @@ Invokes the given callback function after Note that `whenNothingPending` does NOT wait for pending `model.query()` calls. +`doc.canUndo()` +Return `true`, if there's an operation on the undo stack that can be undone, otherwise `false`. + +`doc.undo([options][, function(err) {...}])` +Undo a previously applied "UNDOABLE" or "REDO" operation. +* `options.source` Argument passed to the `'op'` event locally. This is not sent to the server or other clients. Defaults to `true`. + +`doc.canRedo()` +Return `true`, if there's an operation on the redo stack that can be undone, otherwise `false`. + +`doc.redo([options][, function(err) {...}])` +Redo a previously applied "UNDO" operation. +* `options.source` Argument passed to the `'op'` event locally. This is not sent to the server or other clients. Defaults to `true`. + ### Class: `ShareDB.Query` `query.ready` _(Boolean)_ @@ -360,6 +395,7 @@ Additional fields may be added to the error object for debugging context dependi * 4021 - Invalid client id * 4022 - Database adapter does not support queries * 4023 - Cannot project snapshots of this type +* 4024 - OT Type does not support `diff` nor `diffX` ### 5000 - Internal error diff --git a/lib/client/doc.js b/lib/client/doc.js index 05e17976d..c6b9fa5bb 100644 --- a/lib/client/doc.js +++ b/lib/client/doc.js @@ -44,6 +44,13 @@ var types = require('../types'); * - `load ()` Fired when a new snapshot is ingested from a fetch, subscribe, or query */ +var OPERATION_TYPES = { + UNDOABLE: 'UNDOABLE', // basic operation that can be undone + FIXED: 'FIXED', // basic operation that cannot be undone + UNDO: 'UNDO', // undo operation + REDO: 'REDO' // redo operation +}; + module.exports = Doc; function Doc(connection, collection, id) { emitter.EventEmitter.call(this); @@ -57,6 +64,19 @@ function Doc(connection, collection, id) { this.type = null; this.data = undefined; + // Undo stack for local operations. + this.undoStack = []; + // Redo stack for local operations. + this.redoStack = []; + // The max number of undo operations to keep on the stack. + this.undoLimit = 100; + // The max time difference between operations in milliseconds, + // which still allows the operations to be composed on the undoStack. + this.undoComposeTimeout = 1000; + // The timestamp of the previous reversible operation. Used to determine if + // the next reversible operation can be composed on the undoStack. + this.previousUndoableOperationTime = -Infinity; + // Array of callbacks or nulls as placeholders this.inflightFetch = []; this.inflightSubscribe = []; @@ -191,6 +211,7 @@ Doc.prototype.ingestSnapshot = function(snapshot, callback) { this.data = (this.type && this.type.deserialize) ? this.type.deserialize(snapshot.data) : snapshot.data; + this._clearUndoRedo(); this.emit('load'); callback && callback(); }; @@ -318,7 +339,7 @@ Doc.prototype._handleOp = function(err, message) { } this.version++; - this._otApply(message, false); + this._otApply(message); return; }; @@ -500,7 +521,8 @@ function transformX(client, server) { * * @private */ -Doc.prototype._otApply = function(op, source) { +Doc.prototype._otApply = function(op, options) { + var source = options && options.source || false; if (op.op) { if (!this.type) { var err = new ShareDBError(4015, 'Cannot apply op to uncreated document. ' + this.collection + '.' + this.id); @@ -537,8 +559,8 @@ Doc.prototype._otApply = function(op, source) { } // Apply the individual op component this.emit('before op', componentOp.op, source); - this.data = this.type.apply(this.data, componentOp.op); - this.emit('op', componentOp.op, source); + this._applyOp(componentOp, options); + this.emit('op', componentOp.op, source, 'FIXED'); } // Pop whatever was submitted since we started applying this op this._popApplyStack(stackLength); @@ -549,13 +571,13 @@ Doc.prototype._otApply = function(op, source) { // the snapshot before it gets changed this.emit('before op', op.op, source); // Apply the operation to the local data, mutating it in place - this.data = this.type.apply(this.data, op.op); + var operationType = this._applyOp(op, options); // Emit an 'op' event once the local data includes the changes from the // op. For locally submitted ops, this will be synchronously with // submission and before the server or other clients have received the op. // For ops from other clients, this will be after the op has been // committed to the database and published - this.emit('op', op.op, source); + this.emit('op', op.op, source, operationType); return; } @@ -566,6 +588,7 @@ Doc.prototype._otApply = function(op, source) { this.type.createDeserialized(op.create.data) : this.type.deserialize(this.type.create(op.create.data)) : this.type.create(op.create.data); + this._clearUndoRedo(); this.emit('create', source); return; } @@ -573,11 +596,167 @@ Doc.prototype._otApply = function(op, source) { if (op.del) { var oldData = this.data; this._setType(null); + this._clearUndoRedo(); this.emit('del', oldData, source); return; } }; +// Applies `op` to `this.data` and updates the undo/redo stacks. +Doc.prototype._applyOp = function(op, options) { + var undoOp = options && options.undoOp || null; + var operationType = options && options.operationType || OPERATION_TYPES.FIXED; + var fixUpUndoStack = options && options.fixUpUndoStack; + var fixUpRedoStack = options && options.fixUpRedoStack; + var needsUndoOp = operationType !== OPERATION_TYPES.FIXED || fixUpUndoStack || fixUpRedoStack; + + if (needsUndoOp && undoOp == null) { + if (this.type.applyAndInvert) { + var result = this.type.applyAndInvert(this.data, op.op); + this.data = result[0]; + undoOp = { op: result[1] }; + + } else if (this.type.invert) { + this.data = this.type.apply(this.data, op.op); + undoOp = { op: this.type.invert(op.op) }; + + } else { + this.data = this.type.apply(this.data, op.op); + operationType = OPERATION_TYPES.FIXED; + } + + } else { + this.data = this.type.apply(this.data, op.op); + } + + switch (operationType) { + case OPERATION_TYPES.UNDOABLE: + this._updateStacksUndoable(op, undoOp); + break; + case OPERATION_TYPES.UNDO: + this._updateStacksUndo(op, undoOp); + break; + case OPERATION_TYPES.REDO: + this._updateStacksRedo(op, undoOp); + break; + default: + this._updateStacksFixed(op, undoOp, fixUpUndoStack, fixUpRedoStack); + break; + }; + + return operationType; +}; + +Doc.prototype._clearUndoRedo = function() { + this.undoStack.length = 0; + this.redoStack.length = 0; + this.previousUndoableOperationTime = -Infinity; +}; + +Doc.prototype._updateStacksUndoable = function(op, undoOp) { + var now = Date.now(); + + if (this.undoStack.length === 0 || now - this.previousUndoableOperationTime > this.undoComposeTimeout) { + this.undoStack.push(undoOp); + + } else if (this.type.composeSimilar) { + var lastOp = this.undoStack.pop(); + var composedOp = this.type.composeSimilar(undoOp.op, lastOp.op); + if (composedOp != null) { + this.undoStack.push({ op: composedOp }); + } else { + this.undoStack.push(lastOp, undoOp); + } + + } else if (this.type.compose) { + var lastOp = this.undoStack.pop(); + var composedOp = this.type.compose(undoOp.op, lastOp.op); + this.undoStack.push({ op: composedOp }); + + } else { + this.undoStack.push(undoOp); + } + + this.redoStack.length = 0; + this.previousUndoableOperationTime = now; + + var isNoop = this.type.isNoop; + if (isNoop && isNoop(this.undoStack[this.undoStack.length - 1].op)) { + this.undoStack.pop(); + } + + var itemsToRemove = this.undoStack.length - this.undoLimit; + if (itemsToRemove > 0) { + this.undoStack.splice(0, itemsToRemove); + } +}; + +Doc.prototype._updateStacksUndo = function(op, undoOp) { + if (!this.type.isNoop || !this.type.isNoop(undoOp.op)) { + this.redoStack.push(undoOp); + } + this.previousUndoableOperationTime = -Infinity; +}; + +Doc.prototype._updateStacksRedo = function(op, undoOp) { + if (!this.type.isNoop || !this.type.isNoop(undoOp.op)) { + this.undoStack.push(undoOp); + } + this.previousUndoableOperationTime = -Infinity; +}; + +Doc.prototype._updateStacksFixed = function(op, undoOp, fixUpUndoStack, fixUpRedoStack) { + if (fixUpUndoStack && this.undoStack.length > 0 && this.type.compose && undoOp) { + var lastOp = this.undoStack.pop(); + var composedOp = this.type.compose(undoOp.op, lastOp.op); + if (!this.type.isNoop || !this.type.isNoop(composedOp)) { + this.undoStack.push({ op: composedOp }); + } + } else { + this.undoStack = this._transformStack(this.undoStack, op.op); + } + + if (fixUpRedoStack && this.redoStack.length > 0 && this.type.compose && undoOp) { + var lastOp = this.redoStack.pop(); + var composedOp = this.type.compose(undoOp.op, lastOp.op); + if (!this.type.isNoop || !this.type.isNoop(composedOp)) { + this.redoStack.push({ op: composedOp }); + } + } else { + this.redoStack = this._transformStack(this.redoStack, op.op); + } +}; + +Doc.prototype._transformStack = function(stack, op) { + var transform = this.type.transform; + var transformX = this.type.transformX; + var isNoop = this.type.isNoop; + var newStack = []; + var newStackIndex = 0; + + for (var i = stack.length - 1; i >= 0; --i) { + var stackOp = stack[i].op; + var transformedStackOp; + var transformedOp; + + if (transformX) { + var result = transformX(op, stackOp); + transformedOp = result[0]; + transformedStackOp = result[1]; + } else { + transformedOp = transform(op, stackOp, 'left'); + transformedStackOp = transform(stackOp, op, 'right'); + } + + if (!isNoop || !isNoop(transformedStackOp)) { + newStack[newStackIndex++] = { op: transformedStackOp }; + } + + op = transformedOp; + } + + return newStack.reverse(); +}; // ***** Sending operations @@ -630,10 +809,14 @@ Doc.prototype._sendOp = function() { // @param [op.op] // @param [op.del] // @param [op.create] +// @param options { source, operationType, undoOp, fixUpUndoStack, fixUpRedoStack } // @param [callback] called when operation is submitted -Doc.prototype._submit = function(op, source, callback) { +Doc.prototype._submit = function(op, options, callback) { + if (!options) options = {}; + if (!options.operationType) options.operationType = OPERATION_TYPES.FIXED; + // Locally submitted ops must always have a truthy source - if (!source) source = true; + if (!options.source) options.source = true; // The op contains either op, create, delete, or none of the above (a no-op). if (op.op) { @@ -644,10 +827,15 @@ Doc.prototype._submit = function(op, source, callback) { } // Try to normalize the op. This removes trailing skip:0's and things like that. if (this.type.normalize) op.op = this.type.normalize(op.op); + // Try to skip processing empty operations. + if (this.type.isNoop && this.type.isNoop(op.op)) { + if (callback) process.nextTick(callback); + return; + } } this._pushOp(op, callback); - this._otApply(op, source); + this._otApply(op, options); // The call to flush is delayed so if submit() is called multiple times // synchronously, all the ops are combined before being sent to the server. @@ -733,19 +921,150 @@ Doc.prototype._tryCompose = function(op) { // Submit an operation to the document. // -// @param operation handled by the OT type -// @param options {source: ...} +// @param component operation handled by the OT type +// @param options.source passed into 'op' event handler +// @param options.undoable should the operation be undoable +// @param options.fixUpUndoStack Determines how non-undoable op affects undoStack. +// If false (default), op transforms undoStack. +// If true, op is inverted and composed into the last operation on the undoStack. +// @param options.fixUpRedoStack Determines how non-undoable op affects redoStack. +// If false (default), op transforms redoStack. +// If true, op is inverted and composed into the last operation on the redoStack. // @param [callback] called after operation submitted // -// @fires before op, op, after op +// @fires before op, op Doc.prototype.submitOp = function(component, options, callback) { if (typeof options === 'function') { callback = options; options = null; } var op = {op: component}; - var source = options && options.source; - this._submit(op, source, callback); + var submitOptions = { + source: options && options.source, + operationType: options && options.undoable ? OPERATION_TYPES.UNDOABLE : OPERATION_TYPES.FIXED, + fixUpUndoStack: options && options.fixUpUndoStack, + fixUpRedoStack: options && options.fixUpRedoStack + }; + this._submit(op, submitOptions, callback); +}; + +// Submits new content for the document. +// +// This function works only if the type supports `diff` or `diffX`. +// It diffs the current and new snapshot to generate an operation, +// which is then submitted as usual. +// +// @param snapshot new snapshot data +// @param options.source passed into 'op' event handler +// @param options.undoable should the operation be undoable +// @param options.fixUpUndoStack Determines how non-undoable op affects undoStack. +// If false (default), op transforms undoStack. +// If true, op is inverted and composed into the last operation on the undoStack. +// @param options.fixUpRedoStack Determines how non-undoable op affects redoStack. +// If false (default), op transforms redoStack. +// If true, op is inverted and composed into the last operation on the redoStack. +// @param options.diffHint a hint passed into diff/diffX +// @param [callback] called after operation submitted + +// @fires before op, op +Doc.prototype.submitSnapshot = function(snapshot, options, callback) { + if (typeof options === 'function') { + callback = options; + options = null; + } + if (!this.type) { + var err = new ShareDBError(4015, 'Cannot submit snapshot. Document has not been created. ' + this.collection + '.' + this.id); + if (callback) return callback(err); + return this.emit('error', err); + } + if (!this.type.diff && !this.type.diffX) { + var err = new ShareDBError(4024, 'Cannot submit snapshot. Document type does not support diff nor diffX. ' + this.collection + '.' + this.id); + if (callback) return callback(err); + return this.emit('error', err); + } + + var undoable = !!(options && options.undoable); + var fixUpUndoStack = options && options.fixUpUndoStack; + var fixUpRedoStack = options && options.fixUpRedoStack; + var diffHint = options && options.diffHint; + var needsUndoOp = undoable || fixUpUndoStack || fixUpRedoStack; + var op, undoOp; + + if ((needsUndoOp && this.type.diffX) || !this.type.diff) { + var diffs = this.type.diffX(this.data, snapshot, diffHint); + undoOp = { op: diffs[0] }; + op = { op: diffs[1] }; + } else { + undoOp = null; + op = { op: this.type.diff(this.data, snapshot, diffHint) }; + } + + var submitOptions = { + source: options && options.source, + operationType: undoable ? OPERATION_TYPES.UNDOABLE : OPERATION_TYPES.FIXED, + undoOp: undoOp, + fixUpUndoStack: fixUpUndoStack, + fixUpRedoStack: fixUpRedoStack + }; + this._submit(op, submitOptions, callback); +}; + +// Returns true, if there are any operations on the undo stack, otherwise false. +Doc.prototype.canUndo = function() { + return this.undoStack.length > 0 +}; + +// Undoes a submitted operation. +// +// @param options {source: ...} +// @param [callback] called after operation submitted +// @fires before op, op +Doc.prototype.undo = function(options, callback) { + if (typeof options === 'function') { + callback = options; + options = null; + } + + if (!this.canUndo()) { + if (callback) process.nextTick(callback); + return; + } + + var op = this.undoStack.pop(); + var submitOptions = { + source: options && options.source, + operationType: OPERATION_TYPES.UNDO + }; + this._submit(op, submitOptions, callback); +}; + +// Returns true, if there are any operations on the redo stack, otherwise false. +Doc.prototype.canRedo = function() { + return this.redoStack.length > 0 +}; + +// Redoes an undone operation. +// +// @param options {source:...} +// @param [callback] called after operation submitted +// @fires before op, op +Doc.prototype.redo = function(options, callback) { + if (typeof options === 'function') { + callback = options; + options = null; + } + + if (!this.canRedo()) { + if (callback) process.nextTick(callback); + return; + } + + var op = this.redoStack.pop() + var submitOptions = { + source: options && options.source, + operationType: OPERATION_TYPES.REDO + }; + this._submit(op, submitOptions, callback); }; // Create the document, which in ShareJS semantics means to set its type. Every @@ -776,7 +1095,7 @@ Doc.prototype.create = function(data, type, options, callback) { } var op = {create: {type: type, data: data}}; var source = options && options.source; - this._submit(op, source, callback); + this._submit(op, { source: source }, callback); }; // Delete the document. This creates and submits a delete operation to the @@ -798,7 +1117,7 @@ Doc.prototype.del = function(options, callback) { } var op = {del: true}; var source = options && options.source; - this._submit(op, source, callback); + this._submit(op, { source: source }, callback); }; @@ -858,7 +1177,7 @@ Doc.prototype._rollback = function(err) { // I'm still not 100% sure about this functionality, because its really a // local op. Basically, the problem is that if the client's op is rejected // by the server, the editor window should update to reflect the undo. - this._otApply(op, false); + this._otApply(op); this._clearInflightOp(err); return; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..0af5c9b23 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1528 @@ +{ + "name": "sharedb", + "version": "1.0.0-beta.9", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@teamwork/ot-rich-text": { + "version": "6.3.3", + "resolved": "https://registry.npmjs.org/@teamwork/ot-rich-text/-/ot-rich-text-6.3.3.tgz", + "integrity": "sha512-cYHZPTRMY6N7GxJ3SENzHyGVtgLlDfMdtfRs1CG+6+6DzUkfP/VkEIoR+K5jp02DyI5yvMErkyJkv4BvM23sLg==", + "dev": true, + "requires": { + "fast-diff": "^1.1.2" + } + }, + "abbrev": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.0.9.tgz", + "integrity": "sha1-kbR5JYinc4wl813W9jdSovh3YTU=", + "dev": true + }, + "align-text": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz", + "integrity": "sha1-DNkKVhCT810KmSVsIrcGlDP60Rc=", + "dev": true, + "requires": { + "kind-of": "^3.0.2", + "longest": "^1.0.1", + "repeat-string": "^1.5.2" + } + }, + "amdefine": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", + "integrity": "sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU=", + "dev": true + }, + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + }, + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "dev": true + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "arraydiff": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/arraydiff/-/arraydiff-0.1.3.tgz", + "integrity": "sha1-hqVDbXty8b3aX9bXTock5C+Dzk0=" + }, + "asn1": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz", + "integrity": "sha1-2sh4dxPJlmhJ/IGAd36+nB3fO4Y=", + "dev": true + }, + "assert-plus": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-0.2.0.tgz", + "integrity": "sha1-104bh+ev/A24qttwIfP+SBAasjQ=", + "dev": true + }, + "async": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", + "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=" + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", + "dev": true + }, + "aws-sign2": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.6.0.tgz", + "integrity": "sha1-FDQt0428yU0OW4fXY81jYSwOeU8=", + "dev": true + }, + "aws4": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.7.0.tgz", + "integrity": "sha512-32NDda82rhwD9/JBCCkB+MRYDp0oSvlo2IL6rQWA10PQi7tDUM3eqMSltXmY+Oyl/7N3P3qNtAlv7X0d9bI28w==", + "dev": true + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true + }, + "bcrypt-pbkdf": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.1.tgz", + "integrity": "sha1-Y7xdy2EzG5K8Bf1SiVPDNGKgb40=", + "dev": true, + "optional": true, + "requires": { + "tweetnacl": "^0.14.3" + } + }, + "boom": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/boom/-/boom-2.10.1.tgz", + "integrity": "sha1-OciRjO/1eZ+D+UkqhI9iWt0Mdm8=", + "dev": true, + "requires": { + "hoek": "2.x.x" + } + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "browser-stdout": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.0.tgz", + "integrity": "sha1-81HTKWnTL6XXpVZxVCY9korjvR8=", + "dev": true + }, + "camelcase": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz", + "integrity": "sha1-m7UwTS4LVmmLLHWLCKPqqdqlijk=", + "dev": true, + "optional": true + }, + "caseless": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.11.0.tgz", + "integrity": "sha1-cVuW6phBWTzDMGeSP17GDr2k99c=", + "dev": true + }, + "center-align": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/center-align/-/center-align-0.1.3.tgz", + "integrity": "sha1-qg0yYptu6XIgBBHL1EYckHvCt60=", + "dev": true, + "optional": true, + "requires": { + "align-text": "^0.1.3", + "lazy-cache": "^1.0.3" + } + }, + "chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, + "requires": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + } + }, + "cli": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cli/-/cli-1.0.1.tgz", + "integrity": "sha1-IoF1NPJL+klQw01TLUjsvGIbjBQ=", + "dev": true, + "requires": { + "exit": "0.1.2", + "glob": "^7.1.1" + }, + "dependencies": { + "glob": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", + "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + } + } + }, + "cliui": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-2.1.0.tgz", + "integrity": "sha1-S0dXYP+AJkx2LDoXGQMukcf+oNE=", + "dev": true, + "optional": true, + "requires": { + "center-align": "^0.1.1", + "right-align": "^0.1.1", + "wordwrap": "0.0.2" + }, + "dependencies": { + "wordwrap": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz", + "integrity": "sha1-t5Zpu0LstAn4PVg8rVLKF+qhZD8=", + "dev": true, + "optional": true + } + } + }, + "combined-stream": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.6.tgz", + "integrity": "sha1-cj599ugBrFYTETp+RFqbactjKBg=", + "dev": true, + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "commander": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.15.1.tgz", + "integrity": "sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag==", + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "console-browserify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.1.0.tgz", + "integrity": "sha1-8CQcRXMKn8YyOyBtvzjtx0HQuxA=", + "dev": true, + "requires": { + "date-now": "^0.1.4" + } + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", + "dev": true + }, + "coveralls": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/coveralls/-/coveralls-2.13.3.tgz", + "integrity": "sha512-iiAmn+l1XqRwNLXhW8Rs5qHZRFMYp9ZIPjEOVRpC/c4so6Y/f4/lFi0FfR5B9cCqgyhkJ5cZmbvcVRfP8MHchw==", + "dev": true, + "requires": { + "js-yaml": "3.6.1", + "lcov-parse": "0.0.10", + "log-driver": "1.2.5", + "minimist": "1.2.0", + "request": "2.79.0" + } + }, + "cryptiles": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/cryptiles/-/cryptiles-2.0.5.tgz", + "integrity": "sha1-O9/s3GCBR8HGcgL6KR59ylnqo7g=", + "dev": true, + "requires": { + "boom": "2.x.x" + } + }, + "dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0" + }, + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", + "dev": true + } + } + }, + "date-now": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/date-now/-/date-now-0.1.4.tgz", + "integrity": "sha1-6vQ5/U1ISK105cx9vvIAZyueNFs=", + "dev": true + }, + "debug": { + "version": "2.6.8", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.8.tgz", + "integrity": "sha1-5zFTHKLt4n0YgiJCfaF4IdaP9Pw=", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=", + "dev": true, + "optional": true + }, + "deep-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", + "integrity": "sha1-9dJgKStmDghO/0zbyfCK0yR0SLU=", + "dev": true + }, + "deep-is": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", + "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=" + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", + "dev": true + }, + "diff": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.2.0.tgz", + "integrity": "sha1-yc45Okt8vQsFinJck98pkCeGj/k=", + "dev": true + }, + "dom-serializer": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.1.0.tgz", + "integrity": "sha1-BzxpdUbOB4DOI75KKOKT5AvDDII=", + "dev": true, + "requires": { + "domelementtype": "~1.1.1", + "entities": "~1.1.1" + }, + "dependencies": { + "domelementtype": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.1.3.tgz", + "integrity": "sha1-vSh3PiZCiBrsUVRJJCmcXNgiGFs=", + "dev": true + }, + "entities": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.1.tgz", + "integrity": "sha1-blwtClYhtdra7O+AuQ7ftc13cvA=", + "dev": true + } + } + }, + "domelementtype": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.0.tgz", + "integrity": "sha1-sXrtguirWeUt2cGbF1bg/BhyBMI=", + "dev": true + }, + "domhandler": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.3.0.tgz", + "integrity": "sha1-LeWaCCLVAn+r/28DLCsloqir5zg=", + "dev": true, + "requires": { + "domelementtype": "1" + } + }, + "domutils": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz", + "integrity": "sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8=", + "dev": true, + "requires": { + "dom-serializer": "0", + "domelementtype": "1" + } + }, + "ecc-jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz", + "integrity": "sha1-D8c6ntXw1Tw4GTOYUj735UN3dQU=", + "dev": true, + "optional": true, + "requires": { + "jsbn": "~0.1.0" + } + }, + "entities": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-1.0.0.tgz", + "integrity": "sha1-sph6o4ITR/zeZCsk/fyeT7cSvyY=", + "dev": true + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "escodegen": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.8.1.tgz", + "integrity": "sha1-WltTr0aTEQvrsIZ6o0MN07cKEBg=", + "dev": true, + "requires": { + "esprima": "^2.7.1", + "estraverse": "^1.9.1", + "esutils": "^2.0.2", + "optionator": "^0.8.1", + "source-map": "~0.2.0" + } + }, + "esprima": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-2.7.3.tgz", + "integrity": "sha1-luO3DVd59q1JzQMmc9HDEnZ7pYE=", + "dev": true + }, + "estraverse": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-1.9.3.tgz", + "integrity": "sha1-r2fy3JIlgkFZUJJgkaQAXSnJu0Q=", + "dev": true + }, + "esutils": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz", + "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=", + "dev": true + }, + "exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha1-BjJjj42HfMghB9MKD/8aF8uhzQw=", + "dev": true + }, + "expect.js": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/expect.js/-/expect.js-0.3.1.tgz", + "integrity": "sha1-sKWaDS7/VDdUTr8M6qYBWEHQm1s=", + "dev": true + }, + "extend": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.1.tgz", + "integrity": "sha1-p1Xqe8Gt/MWjHOfnYtuq3F5jZEQ=", + "dev": true + }, + "extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=", + "dev": true + }, + "fast-diff": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz", + "integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==", + "dev": true + }, + "fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", + "dev": true + }, + "forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=", + "dev": true + }, + "form-data": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.1.4.tgz", + "integrity": "sha1-M8GDrPGTJ27KqYFDpp6Uv+4XUNE=", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.5", + "mime-types": "^2.1.12" + } + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true + }, + "generate-function": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.0.0.tgz", + "integrity": "sha1-aFj+fAlpt9TpCTM3ZHrHn2DfvnQ=", + "dev": true + }, + "generate-object-property": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/generate-object-property/-/generate-object-property-1.2.0.tgz", + "integrity": "sha1-nA4cQDCM6AT0eDYYuTf6iPmdUNA=", + "dev": true, + "requires": { + "is-property": "^1.0.0" + } + }, + "getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0" + }, + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", + "dev": true + } + } + }, + "glob": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz", + "integrity": "sha1-G8k2ueAvSmA/zCIuz3Yz0wuLk7E=", + "dev": true, + "requires": { + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "2 || 3", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "graceful-readlink": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz", + "integrity": "sha1-TK+tdrxi8C+gObL5Tpo906ORpyU=", + "dev": true + }, + "growl": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/growl/-/growl-1.9.2.tgz", + "integrity": "sha1-Dqd0NxXbjY3ixe3hd14bRayFwC8=", + "dev": true + }, + "handlebars": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.0.11.tgz", + "integrity": "sha1-Ywo13+ApS8KB7a5v/F0yn8eYLcw=", + "dev": true, + "requires": { + "async": "^1.4.0", + "optimist": "^0.6.1", + "source-map": "^0.4.4", + "uglify-js": "^2.6" + }, + "dependencies": { + "source-map": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz", + "integrity": "sha1-66T12pwNyZneaAMti092FzZSA2s=", + "dev": true, + "requires": { + "amdefine": ">=0.0.4" + } + } + } + }, + "har-validator": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-2.0.6.tgz", + "integrity": "sha1-zcvAgYgmWtEZtqWnyKtw7s+10n0=", + "dev": true, + "requires": { + "chalk": "^1.1.1", + "commander": "^2.9.0", + "is-my-json-valid": "^2.12.4", + "pinkie-promise": "^2.0.0" + } + }, + "has-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "has-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz", + "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo=", + "dev": true + }, + "hat": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/hat/-/hat-0.0.3.tgz", + "integrity": "sha1-uwFKnmSzeIrtgAWRdBPU/z1QLYo=" + }, + "hawk": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/hawk/-/hawk-3.1.3.tgz", + "integrity": "sha1-B4REvXwWQLD+VA0sm3PVlnjo4cQ=", + "dev": true, + "requires": { + "boom": "2.x.x", + "cryptiles": "2.x.x", + "hoek": "2.x.x", + "sntp": "1.x.x" + } + }, + "he": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz", + "integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0=", + "dev": true + }, + "hoek": { + "version": "2.16.3", + "resolved": "https://registry.npmjs.org/hoek/-/hoek-2.16.3.tgz", + "integrity": "sha1-ILt0A9POo5jpHcRxCo/xuCdKJe0=", + "dev": true + }, + "htmlparser2": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.8.3.tgz", + "integrity": "sha1-mWwosZFRaovoZQGn15dX5ccMEGg=", + "dev": true, + "requires": { + "domelementtype": "1", + "domhandler": "2.3", + "domutils": "1.5", + "entities": "1.0", + "readable-stream": "1.1" + } + }, + "http-signature": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.1.1.tgz", + "integrity": "sha1-33LiZwZs0Kxn+3at+OE0qPvPkb8=", + "dev": true, + "requires": { + "assert-plus": "^0.2.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + } + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", + "dev": true + }, + "is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true + }, + "is-my-ip-valid": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-my-ip-valid/-/is-my-ip-valid-1.0.0.tgz", + "integrity": "sha512-gmh/eWXROncUzRnIa1Ubrt5b8ep/MGSnfAUI3aRp+sqTCs1tv1Isl8d8F6JmkN3dXKc3ehZMrtiPN9eL03NuaQ==", + "dev": true + }, + "is-my-json-valid": { + "version": "2.17.2", + "resolved": "https://registry.npmjs.org/is-my-json-valid/-/is-my-json-valid-2.17.2.tgz", + "integrity": "sha512-IBhBslgngMQN8DDSppmgDv7RNrlFotuuDsKcrCP3+HbFaVivIBU7u9oiiErw8sH4ynx3+gOGQ3q2otkgiSi6kg==", + "dev": true, + "requires": { + "generate-function": "^2.0.0", + "generate-object-property": "^1.1.0", + "is-my-ip-valid": "^1.0.0", + "jsonpointer": "^4.0.0", + "xtend": "^4.0.0" + } + }, + "is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha1-V/4cTkhHTt1lsJkR8msc1Ald2oQ=", + "dev": true + }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", + "dev": true + }, + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", + "dev": true + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true + }, + "isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=", + "dev": true + }, + "istanbul": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/istanbul/-/istanbul-0.4.5.tgz", + "integrity": "sha1-ZcfXPUxNqE1POsMQuRj7C4Azczs=", + "dev": true, + "requires": { + "abbrev": "1.0.x", + "async": "1.x", + "escodegen": "1.8.x", + "esprima": "2.7.x", + "glob": "^5.0.15", + "handlebars": "^4.0.1", + "js-yaml": "3.x", + "mkdirp": "0.5.x", + "nopt": "3.x", + "once": "1.x", + "resolve": "1.1.x", + "supports-color": "^3.1.0", + "which": "^1.1.1", + "wordwrap": "^1.0.0" + }, + "dependencies": { + "supports-color": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz", + "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=", + "dev": true, + "requires": { + "has-flag": "^1.0.0" + } + } + } + }, + "js-yaml": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.6.1.tgz", + "integrity": "sha1-bl/mfYsgXOTSL60Ft3geja3MSzA=", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^2.6.0" + } + }, + "jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", + "dev": true, + "optional": true + }, + "jshint": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/jshint/-/jshint-2.9.5.tgz", + "integrity": "sha1-HnJSkVzmgbQIJ+4UJIxG006apiw=", + "dev": true, + "requires": { + "cli": "~1.0.0", + "console-browserify": "1.1.x", + "exit": "0.1.x", + "htmlparser2": "3.8.x", + "lodash": "3.7.x", + "minimatch": "~3.0.2", + "shelljs": "0.3.x", + "strip-json-comments": "1.0.x" + } + }, + "json-schema": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", + "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=", + "dev": true + }, + "json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", + "dev": true + }, + "json3": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/json3/-/json3-3.3.2.tgz", + "integrity": "sha1-PAQ0dD35Pi9cQq7nsZvLSDV19OE=", + "dev": true + }, + "jsonpointer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-4.0.1.tgz", + "integrity": "sha1-T9kss04OnbPInIYi7PUfm5eMbLk=", + "dev": true + }, + "jsprim": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", + "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", + "dev": true, + "requires": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.2.3", + "verror": "1.10.0" + }, + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", + "dev": true + } + } + }, + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + }, + "lazy-cache": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz", + "integrity": "sha1-odePw6UEdMuAhF07O24dpJpEbo4=", + "dev": true, + "optional": true + }, + "lcov-parse": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/lcov-parse/-/lcov-parse-0.0.10.tgz", + "integrity": "sha1-GwuP+ayceIklBYK3C3ExXZ2m2aM=", + "dev": true + }, + "levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=", + "dev": true, + "requires": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + } + }, + "lodash": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.7.0.tgz", + "integrity": "sha1-Nni9irmVBXwHreg27S7wh9qBHUU=", + "dev": true + }, + "lodash._baseassign": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/lodash._baseassign/-/lodash._baseassign-3.2.0.tgz", + "integrity": "sha1-jDigmVAPIVrQnlnxci/QxSv+Ck4=", + "dev": true, + "requires": { + "lodash._basecopy": "^3.0.0", + "lodash.keys": "^3.0.0" + } + }, + "lodash._basecopy": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash._basecopy/-/lodash._basecopy-3.0.1.tgz", + "integrity": "sha1-jaDmqHbPNEwK2KVIghEd08XHyjY=", + "dev": true + }, + "lodash._basecreate": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash._basecreate/-/lodash._basecreate-3.0.3.tgz", + "integrity": "sha1-G8ZhYU2qf8MRt9A78WgGoCE8+CE=", + "dev": true + }, + "lodash._getnative": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/lodash._getnative/-/lodash._getnative-3.9.1.tgz", + "integrity": "sha1-VwvH3t5G1hzc3mh9ZdPuy6o6r/U=", + "dev": true + }, + "lodash._isiterateecall": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash._isiterateecall/-/lodash._isiterateecall-3.0.9.tgz", + "integrity": "sha1-UgOte6Ql+uhCRg5pbbnPPmqsBXw=", + "dev": true + }, + "lodash.create": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lodash.create/-/lodash.create-3.1.1.tgz", + "integrity": "sha1-1/KEnw29p+BGgruM1yqwIkYd6+c=", + "dev": true, + "requires": { + "lodash._baseassign": "^3.0.0", + "lodash._basecreate": "^3.0.0", + "lodash._isiterateecall": "^3.0.0" + } + }, + "lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo=", + "dev": true + }, + "lodash.isarray": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/lodash.isarray/-/lodash.isarray-3.0.4.tgz", + "integrity": "sha1-eeTriMNqgSKvhvhEqpvNhRtfu1U=", + "dev": true + }, + "lodash.keys": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-3.1.2.tgz", + "integrity": "sha1-TbwEcrFWvlCgsoaFXRvQsMZWCYo=", + "dev": true, + "requires": { + "lodash._getnative": "^3.0.0", + "lodash.isarguments": "^3.0.0", + "lodash.isarray": "^3.0.0" + } + }, + "log-driver": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/log-driver/-/log-driver-1.2.5.tgz", + "integrity": "sha1-euTsJXMC/XkNVXyxDJcQDYV7AFY=", + "dev": true + }, + "longest": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz", + "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=", + "dev": true + }, + "make-error": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.4.tgz", + "integrity": "sha512-0Dab5btKVPhibSalc9QGXb559ED7G7iLjFXBaj9Wq8O3vorueR5K5jaE3hkG6ZQINyhA/JgG6Qk4qdFQjsYV6g==" + }, + "mime-db": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", + "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==", + "dev": true + }, + "mime-types": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz", + "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", + "dev": true, + "requires": { + "mime-db": "~1.33.0" + } + }, + "mingo": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/mingo/-/mingo-2.2.2.tgz", + "integrity": "sha1-vmnUhq5uCsVLl53F9EEtshhR9pM=", + "dev": true + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", + "dev": true + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "dev": true, + "requires": { + "minimist": "0.0.8" + }, + "dependencies": { + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", + "dev": true + } + } + }, + "mocha": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-3.5.3.tgz", + "integrity": "sha512-/6na001MJWEtYxHOV1WLfsmR4YIynkUEhBwzsb+fk2qmQ3iqsi258l/Q2MWHJMImAcNpZ8DEdYAK72NHoIQ9Eg==", + "dev": true, + "requires": { + "browser-stdout": "1.3.0", + "commander": "2.9.0", + "debug": "2.6.8", + "diff": "3.2.0", + "escape-string-regexp": "1.0.5", + "glob": "7.1.1", + "growl": "1.9.2", + "he": "1.1.1", + "json3": "3.3.2", + "lodash.create": "3.1.1", + "mkdirp": "0.5.1", + "supports-color": "3.1.2" + }, + "dependencies": { + "commander": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.9.0.tgz", + "integrity": "sha1-nJkJQXbhIkDLItbFFGCYQA/g99Q=", + "dev": true, + "requires": { + "graceful-readlink": ">= 1.0.0" + } + }, + "glob": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.1.tgz", + "integrity": "sha1-gFIR3wT6rxxjo2ADBs31reULLsg=", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.2", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "supports-color": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.1.2.tgz", + "integrity": "sha1-cqJiiU2dQIuVbKBf83su2KbiotU=", + "dev": true, + "requires": { + "has-flag": "^1.0.0" + } + } + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + }, + "nopt": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz", + "integrity": "sha1-xkZdvwirzU2zWTF/eaxopkayj/k=", + "dev": true, + "requires": { + "abbrev": "1" + } + }, + "oauth-sign": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz", + "integrity": "sha1-Rqarfwrq2N6unsBWV4C31O/rnUM=", + "dev": true + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "optimist": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", + "integrity": "sha1-2j6nRob6IaGaERwybpDrFaAZZoY=", + "dev": true, + "requires": { + "minimist": "~0.0.1", + "wordwrap": "~0.0.2" + }, + "dependencies": { + "minimist": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz", + "integrity": "sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8=", + "dev": true + }, + "wordwrap": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", + "integrity": "sha1-o9XabNXAvAAI03I0u68b7WMFkQc=", + "dev": true + } + } + }, + "optionator": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.2.tgz", + "integrity": "sha1-NkxeQJ0/TWMB1sC0wFu6UBgK62Q=", + "dev": true, + "requires": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.4", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "wordwrap": "~1.0.0" + } + }, + "ot-json0": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ot-json0/-/ot-json0-1.1.0.tgz", + "integrity": "sha512-wf5fci7GGpMYRDnbbdIFQymvhsbFACMHtxjivQo5KgvAHlxekyfJ9aPsRr6YfFQthQkk4bmsl5yESrZwC/oMYQ==" + }, + "ot-text": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ot-text/-/ot-text-1.0.1.tgz", + "integrity": "sha1-P4UPbuhYvDbvRayapR0Gx354388=", + "dev": true + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true + }, + "pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=", + "dev": true + }, + "pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=", + "dev": true, + "requires": { + "pinkie": "^2.0.0" + } + }, + "prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=", + "dev": true + }, + "punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=", + "dev": true + }, + "qs": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.3.2.tgz", + "integrity": "sha1-51vV9uJoEioqDgvaYwslUMFmUCw=", + "dev": true + }, + "quill-delta": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.2.tgz", + "integrity": "sha512-grWEQq9woEidPDogtDNxQKmy2LFf9zBC0EU/YTSw6TwKmMjtihTxdnPtPRfrqazB2MSJ7YdCWxmsJ7aQKRSEgg==", + "dev": true, + "requires": { + "deep-equal": "^1.0.1", + "extend": "^3.0.1", + "fast-diff": "1.1.2" + } + }, + "readable-stream": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", + "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=", + "dev": true + }, + "request": { + "version": "2.79.0", + "resolved": "https://registry.npmjs.org/request/-/request-2.79.0.tgz", + "integrity": "sha1-Tf5b9r6LjNw3/Pk+BLZVd3InEN4=", + "dev": true, + "requires": { + "aws-sign2": "~0.6.0", + "aws4": "^1.2.1", + "caseless": "~0.11.0", + "combined-stream": "~1.0.5", + "extend": "~3.0.0", + "forever-agent": "~0.6.1", + "form-data": "~2.1.1", + "har-validator": "~2.0.6", + "hawk": "~3.1.3", + "http-signature": "~1.1.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.7", + "oauth-sign": "~0.8.1", + "qs": "~6.3.0", + "stringstream": "~0.0.4", + "tough-cookie": "~2.3.0", + "tunnel-agent": "~0.4.1", + "uuid": "^3.0.0" + } + }, + "resolve": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.1.7.tgz", + "integrity": "sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs=", + "dev": true + }, + "rich-text": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/rich-text/-/rich-text-3.1.0.tgz", + "integrity": "sha1-BMlx3tzo64IBDPrP9uegzjXHqCU=", + "dev": true, + "requires": { + "quill-delta": "^3.2.0" + } + }, + "right-align": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/right-align/-/right-align-0.1.3.tgz", + "integrity": "sha1-YTObci/mo1FWiSENJOFMlhSGE+8=", + "dev": true, + "optional": true, + "requires": { + "align-text": "^0.1.1" + } + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, + "sharedb": { + "version": "1.0.0-beta.9", + "resolved": "https://registry.npmjs.org/sharedb/-/sharedb-1.0.0-beta.9.tgz", + "integrity": "sha1-LX20J83hIJJNLasIzpLZq6134As=", + "dev": true, + "requires": { + "arraydiff": "^0.1.1", + "async": "^1.4.2", + "deep-is": "^0.1.3", + "hat": "0.0.3", + "make-error": "^1.1.1", + "ot-json0": "^1.0.1" + } + }, + "sharedb-mingo-memory": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/sharedb-mingo-memory/-/sharedb-mingo-memory-1.0.0.tgz", + "integrity": "sha1-vS5171YTCrheE5uMlMVSFv4+TQM=", + "dev": true, + "requires": { + "mingo": "^2.2.0", + "sharedb": "^1.0.0-beta" + } + }, + "shelljs": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.3.0.tgz", + "integrity": "sha1-NZbmMHp4FUT1kfN9phg2DzHbV7E=", + "dev": true + }, + "sntp": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/sntp/-/sntp-1.0.9.tgz", + "integrity": "sha1-ZUEYTMkK7qbG57NeJlkIJEPGYZg=", + "dev": true, + "requires": { + "hoek": "2.x.x" + } + }, + "source-map": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.2.0.tgz", + "integrity": "sha1-2rc/vPwrqBm03gO9b26qSBZLP50=", + "dev": true, + "optional": true, + "requires": { + "amdefine": ">=0.0.4" + } + }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", + "dev": true + }, + "sshpk": { + "version": "1.14.2", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.14.2.tgz", + "integrity": "sha1-xvxhZIo9nE52T9P8306hBeSSupg=", + "dev": true, + "requires": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", + "dev": true + } + } + }, + "string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=", + "dev": true + }, + "stringstream": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/stringstream/-/stringstream-0.0.6.tgz", + "integrity": "sha512-87GEBAkegbBcweToUrdzf3eLhWNg06FJTebl4BVJz/JgWy8CvEr9dRtX5qWphiynMSQlxxi+QqN0z5T32SLlhA==", + "dev": true + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "strip-json-comments": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-1.0.4.tgz", + "integrity": "sha1-HhX7ysl9Pumb8tc7TGVrCCu6+5E=", + "dev": true + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true + }, + "tough-cookie": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.4.tgz", + "integrity": "sha512-TZ6TTfI5NtZnuyy/Kecv+CnoROnyXn2DN97LontgQpCwsX2XyLYCC0ENhYkehSOwAp8rTQKc/NUIF7BkQ5rKLA==", + "dev": true, + "requires": { + "punycode": "^1.4.1" + } + }, + "tunnel-agent": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.4.3.tgz", + "integrity": "sha1-Y3PbdpCf5XDgjXNYM2Xtgop07us=", + "dev": true + }, + "tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", + "dev": true, + "optional": true + }, + "type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=", + "dev": true, + "requires": { + "prelude-ls": "~1.1.2" + } + }, + "uglify-js": { + "version": "2.8.29", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.8.29.tgz", + "integrity": "sha1-KcVzMUgFe7Th913zW3qcty5qWd0=", + "dev": true, + "optional": true, + "requires": { + "source-map": "~0.5.1", + "uglify-to-browserify": "~1.0.0", + "yargs": "~3.10.0" + }, + "dependencies": { + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true, + "optional": true + } + } + }, + "uglify-to-browserify": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz", + "integrity": "sha1-bgkk1r2mta/jSeOabWMoUKD4grc=", + "dev": true, + "optional": true + }, + "uuid": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.2.1.tgz", + "integrity": "sha512-jZnMwlb9Iku/O3smGWvZhauCf6cvvpKi4BKRiliS3cxnI+Gz9j5MEpTz2UFuXiKPJocb7gnsLHwiS05ige5BEA==", + "dev": true + }, + "verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", + "dev": true + } + } + }, + "which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "window-size": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz", + "integrity": "sha1-VDjNLqk7IC76Ohn+iIeu58lPnJ0=", + "dev": true, + "optional": true + }, + "wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=", + "dev": true + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true + }, + "xtend": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz", + "integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68=", + "dev": true + }, + "yargs": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.10.0.tgz", + "integrity": "sha1-9+572FfdfB0tOMDnTvvWgdFDH9E=", + "dev": true, + "optional": true, + "requires": { + "camelcase": "^1.0.2", + "cliui": "^2.1.0", + "decamelize": "^1.0.0", + "window-size": "0.1.0" + } + } + } +} diff --git a/package.json b/package.json index 35fc64bc6..4b96159f5 100644 --- a/package.json +++ b/package.json @@ -12,11 +12,14 @@ "ot-json0": "^1.0.1" }, "devDependencies": { + "@teamwork/ot-rich-text": "^6.3.3", "coveralls": "^2.11.8", "expect.js": "^0.3.1", "istanbul": "^0.4.2", "jshint": "^2.9.2", "mocha": "^3.2.0", + "ot-text": "^1.0.1", + "rich-text": "^3.1.0", "sharedb-mingo-memory": "^1.0.0-beta" }, "scripts": { diff --git a/test/client/invertible-type.js b/test/client/invertible-type.js new file mode 100644 index 000000000..34a4f3fd6 --- /dev/null +++ b/test/client/invertible-type.js @@ -0,0 +1,80 @@ +// A simple type for testing undo/redo, where: +// +// - snapshot is an integer +// - operation is an integer +exports.type = { + name: 'invertible-type', + uri: 'http://sharejs.org/types/invertible-type', + create: create, + apply: apply, + transform: transform, + invert: invert +}; + +exports.typeWithDiff = { + name: 'invertible-type-with-diff', + uri: 'http://sharejs.org/types/invertible-type-with-diff', + create: create, + apply: apply, + transform: transform, + invert: invert, + diff: diff +}; + +exports.typeWithDiffX = { + name: 'invertible-type-with-diffX', + uri: 'http://sharejs.org/types/invertible-type-with-diffX', + create: create, + apply: apply, + transform: transform, + invert: invert, + diffX: diffX +}; + +exports.typeWithDiffAndDiffX = { + name: 'invertible-type-with-diff-and-diffX', + uri: 'http://sharejs.org/types/invertible-type-with-diff-and-diffX', + create: create, + apply: apply, + transform: transform, + invert: invert, + diff: diff, + diffX: diffX +}; + +exports.typeWithTransformX = { + name: 'invertible-type-with-transformX', + uri: 'http://sharejs.org/types/invertible-type-with-transformX', + create: create, + apply: apply, + transformX: transformX, + invert: invert +}; + +function create(data) { + return data | 0; +} + +function apply(snapshot, op) { + return snapshot + op; +} + +function transform(op1, op2, side) { + return op1; +} + +function transformX(op1, op2) { + return [ op1, op2 ]; +} + +function invert(op) { + return -op; +} + +function diff(oldSnapshot, newSnapshot) { + return newSnapshot - oldSnapshot; +} + +function diffX(oldSnapshot, newSnapshot) { + return [ oldSnapshot - newSnapshot, newSnapshot - oldSnapshot ]; +} diff --git a/test/client/undo-redo.js b/test/client/undo-redo.js new file mode 100644 index 000000000..6ba6a9866 --- /dev/null +++ b/test/client/undo-redo.js @@ -0,0 +1,1376 @@ +var async = require('async'); +var util = require('../util'); +var errorHandler = util.errorHandler; +var Backend = require('../../lib/backend'); +var ShareDBError = require('../../lib/error'); +var expect = require('expect.js'); +var types = require('../../lib/types'); +var otText = require('ot-text'); +var otRichText = require('@teamwork/ot-rich-text'); +var invertibleType = require('./invertible-type'); + +types.register(otText.type); +types.register(otRichText.type); +types.register(invertibleType.type); +types.register(invertibleType.typeWithDiff); +types.register(invertibleType.typeWithDiffX); +types.register(invertibleType.typeWithDiffAndDiffX); +types.register(invertibleType.typeWithTransformX); + +describe('client undo/redo', function() { + beforeEach(function() { + this.backend = new Backend(); + this.connection = this.backend.connect(); + this.connection2 = this.backend.connect(); + this.doc = this.connection.get('dogs', 'fido'); + this.doc2 = this.connection2.get('dogs', 'fido'); + }); + + afterEach(function(done) { + this.backend.close(done); + }); + + it('submits a fixed operation', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, { test: 5 }), + this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ]), + function(done) { + expect(this.doc.version).to.equal(2); + expect(this.doc.data).to.eql({ test: 7 }); + expect(this.doc.canUndo()).to.equal(false); + expect(this.doc.canRedo()).to.equal(false); + done(); + }.bind(this) + ], allDone); + }); + + it('receives a remote operation', function(allDone) { + async.series([ + this.doc.subscribe.bind(this.doc), + this.doc2.create.bind(this.doc2, { test: 5 }), + this.doc2.submitOp.bind(this.doc2, [ { p: [ 'test' ], na: 2 } ]), + setTimeout, + function(done) { + expect(this.doc.version).to.equal(2); + expect(this.doc.data).to.eql({ test: 7 }); + expect(this.doc.canUndo()).to.equal(false); + expect(this.doc.canRedo()).to.equal(false); + done(); + }.bind(this) + ], allDone); + }); + + it('submits an undoable operation', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, { test: 5 }), + this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ], { undoable: true }), + function(done) { + expect(this.doc.version).to.equal(2); + expect(this.doc.data).to.eql({ test: 7 }); + expect(this.doc.canUndo()).to.equal(true); + expect(this.doc.canRedo()).to.equal(false); + done(); + }.bind(this) + ], allDone); + }); + + it('undoes an operation', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, { test: 5 }), + this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ], { undoable: true }), + this.doc.undo.bind(this.doc), + function(done) { + expect(this.doc.version).to.equal(3); + expect(this.doc.data).to.eql({ test: 5 }); + expect(this.doc.canUndo()).to.equal(false); + expect(this.doc.canRedo()).to.equal(true); + done(); + }.bind(this) + ], allDone); + }); + + it('redoes an operation', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, { test: 5 }), + this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ], { undoable: true }), + this.doc.undo.bind(this.doc), + this.doc.redo.bind(this.doc), + function(done) { + expect(this.doc.version).to.equal(4); + expect(this.doc.data).to.eql({ test: 7 }); + expect(this.doc.canUndo()).to.equal(true); + expect(this.doc.canRedo()).to.equal(false); + done(); + }.bind(this) + ], allDone); + }); + + it('performs a series of undo and redo operations', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, { test: 5 }), + this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ], { undoable: true }), + this.doc.undo.bind(this.doc), + this.doc.redo.bind(this.doc), + this.doc.undo.bind(this.doc), + this.doc.redo.bind(this.doc), + this.doc.undo.bind(this.doc), + this.doc.redo.bind(this.doc), + function(done) { + expect(this.doc.version).to.equal(8); + expect(this.doc.data).to.eql({ test: 7 }); + expect(this.doc.canUndo()).to.equal(true); + expect(this.doc.canRedo()).to.equal(false); + done(); + }.bind(this) + ], allDone); + }); + + it('performs a series of undo and redo operations synchronously', function() { + this.doc.create({ test: 5 }), + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }), + expect(this.doc.data).to.eql({ test: 7 }); + this.doc.undo(), + expect(this.doc.data).to.eql({ test: 5 }); + this.doc.redo(), + expect(this.doc.data).to.eql({ test: 7 }); + this.doc.undo(), + expect(this.doc.data).to.eql({ test: 5 }); + this.doc.redo(), + expect(this.doc.data).to.eql({ test: 7 }); + this.doc.undo(), + expect(this.doc.data).to.eql({ test: 5 }); + this.doc.redo(), + expect(this.doc.data).to.eql({ test: 7 }); + expect(this.doc.canUndo()).to.equal(true); + expect(this.doc.canRedo()).to.equal(false); + }); + + it('undoes one of two operations', function(allDone) { + this.doc.undoComposeTimeout = -1; + async.series([ + this.doc.create.bind(this.doc, { test: 5 }), + this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ], { undoable: true }), + this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 3 } ], { undoable: true }), + this.doc.undo.bind(this.doc), + function(done) { + expect(this.doc.version).to.equal(4); + expect(this.doc.data).to.eql({ test: 7 }); + expect(this.doc.canUndo()).to.equal(true); + expect(this.doc.canRedo()).to.equal(true); + done(); + }.bind(this) + ], allDone); + }); + + it('undoes two of two operations', function(allDone) { + this.doc.undoComposeTimeout = -1; + async.series([ + this.doc.create.bind(this.doc, { test: 5 }), + this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ], { undoable: true }), + this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 3 } ], { undoable: true }), + this.doc.undo.bind(this.doc), + this.doc.undo.bind(this.doc), + function(done) { + expect(this.doc.version).to.equal(5); + expect(this.doc.data).to.eql({ test: 5 }); + expect(this.doc.canUndo()).to.equal(false); + expect(this.doc.canRedo()).to.equal(true); + done(); + }.bind(this) + ], allDone); + }); + + it('reoes one of two operations', function(allDone) { + this.doc.undoComposeTimeout = -1; + async.series([ + this.doc.create.bind(this.doc, { test: 5 }), + this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ], { undoable: true }), + this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 3 } ], { undoable: true }), + this.doc.undo.bind(this.doc), + this.doc.undo.bind(this.doc), + this.doc.redo.bind(this.doc), + function(done) { + expect(this.doc.version).to.equal(6); + expect(this.doc.data).to.eql({ test: 7 }); + expect(this.doc.canUndo()).to.equal(true); + expect(this.doc.canRedo()).to.equal(true); + done(); + }.bind(this) + ], allDone); + }); + + it('reoes two of two operations', function(allDone) { + this.doc.undoComposeTimeout = -1; + async.series([ + this.doc.create.bind(this.doc, { test: 5 }), + this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ], { undoable: true }), + this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 3 } ], { undoable: true }), + this.doc.undo.bind(this.doc), + this.doc.undo.bind(this.doc), + this.doc.redo.bind(this.doc), + this.doc.redo.bind(this.doc), + function(done) { + expect(this.doc.version).to.equal(7); + expect(this.doc.data).to.eql({ test: 10 }); + expect(this.doc.canUndo()).to.equal(true); + expect(this.doc.canRedo()).to.equal(false); + done(); + }.bind(this) + ], allDone); + }); + + it('calls undo, when canUndo is false', function(done) { + expect(this.doc.canUndo()).to.equal(false); + this.doc.undo(done); + }); + + it('calls undo, when canUndo is false - no callback', function() { + expect(this.doc.canUndo()).to.equal(false); + this.doc.undo(); + }); + + it('calls redo, when canRedo is false', function(done) { + expect(this.doc.canRedo()).to.equal(false); + this.doc.redo(done); + }); + + it('calls redo, when canRedo is false - no callback', function() { + expect(this.doc.canRedo()).to.equal(false); + this.doc.redo(); + }); + + it('preserves source on create', function(done) { + this.doc.on('create', function(source) { + expect(source).to.equal('test source'); + done(); + }); + this.doc.create({ test: 5 }, null, { source: 'test source' }); + }); + + it('preserves source on del', function(done) { + this.doc.on('del', function(oldContent, source) { + expect(source).to.equal('test source'); + done(); + }); + this.doc.create({ test: 5 }); + this.doc.del({ source: 'test source' }); + }); + + it('preserves source on submitOp', function(done) { + this.doc.on('op', function(op, source) { + expect(source).to.equal('test source'); + done(); + }); + this.doc.create({ test: 5 }); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { source: 'test source' }); + }); + + it('preserves source on undo', function(done) { + this.doc.create({ test: 5 }); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + this.doc.on('op', function(op, source) { + expect(source).to.equal('test source'); + done(); + }); + this.doc.undo({ source: 'test source' }); + }); + + it('preserves source on redo', function(done) { + this.doc.create({ test: 5 }); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + this.doc.undo(); + this.doc.on('op', function(op, source) { + expect(source).to.equal('test source'); + done(); + }); + this.doc.redo({ source: 'test source' }); + }); + + it('has source=false on remote operations', function(done) { + this.doc.on('op', function(op, source) { + expect(source).to.equal(false); + done(); + }); + this.doc.subscribe(function() { + this.doc2.preventCompose = true; + this.doc2.create({ test: 5 }); + this.doc2.submitOp([ { p: [ 'test' ], na: 2 } ]); + }.bind(this)); + }); + + it('composes undoable operations within time limit', function(done) { + this.doc.create({ test: 5 }); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + setTimeout(function() { + this.doc.submitOp([ { p: [ 'test' ], na: 3 } ], { undoable: true }); + expect(this.doc.data).to.eql({ test: 10 }); + this.doc.undo(); + expect(this.doc.data).to.eql({ test: 5 }); + expect(this.doc.canUndo()).to.equal(false); + done(); + }.bind(this), 2); + }); + + it('composes undoable operations correctly', function() { + this.doc.create({ a: 1, b: 2 }); + this.doc.submitOp([ { p: [ 'a' ], od: 1 } ], { undoable: true }); + this.doc.submitOp([ { p: [ 'b' ], od: 2 } ], { undoable: true }); + expect(this.doc.data).to.eql({}); + expect(this.doc.canRedo()).to.equal(false); + var opCalled = false; + this.doc.once('op', function(op) { + opCalled = true; + expect(op).to.eql([ { p: [ 'b' ], oi: 2 }, { p: [ 'a' ], oi: 1 } ]); + }); + this.doc.undo(); + expect(opCalled).to.equal(true); + expect(this.doc.data).to.eql({ a: 1, b: 2 }); + expect(this.doc.canUndo()).to.equal(false); + this.doc.redo(); + expect(this.doc.data).to.eql({}); + expect(this.doc.canRedo()).to.equal(false); + }); + + it('does not compose undoable operations outside time limit', function(done) { + this.doc.undoComposeTimeout = 1; + this.doc.create({ test: 5 }); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + setTimeout(function () { + this.doc.submitOp([ { p: [ 'test' ], na: 3 } ], { undoable: true }); + expect(this.doc.data).to.eql({ test: 10 }); + this.doc.undo(); + expect(this.doc.data).to.eql({ test: 7 }); + expect(this.doc.canUndo()).to.equal(true); + this.doc.undo(); + expect(this.doc.data).to.eql({ test: 5 }); + expect(this.doc.canUndo()).to.equal(false); + done(); + }.bind(this), 3); + }); + + it('does not compose undoable operations, if undoComposeTimeout < 0', function() { + this.doc.undoComposeTimeout = -1; + this.doc.create({ test: 5 }); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + this.doc.submitOp([ { p: [ 'test' ], na: 3 } ], { undoable: true }); + expect(this.doc.data).to.eql({ test: 10 }); + this.doc.undo(); + expect(this.doc.data).to.eql({ test: 7 }); + expect(this.doc.canUndo()).to.equal(true); + this.doc.undo(); + expect(this.doc.data).to.eql({ test: 5 }); + expect(this.doc.canUndo()).to.equal(false); + }); + + it('does not compose undoable operations, if type does not support compose nor composeSimilar', function() { + this.doc.create(5, invertibleType.type.uri); + this.doc.submitOp(2, { undoable: true }); + expect(this.doc.data).to.equal(7); + this.doc.submitOp(2, { undoable: true }); + expect(this.doc.data).to.equal(9); + this.doc.undo(); + expect(this.doc.data).to.equal(7); + this.doc.undo(); + expect(this.doc.data).to.equal(5); + expect(this.doc.canUndo()).to.equal(false); + this.doc.redo(); + expect(this.doc.data).to.equal(7); + this.doc.redo(); + expect(this.doc.data).to.equal(9); + expect(this.doc.canRedo()).to.equal(false); + }); + + it('uses applyAndInvert, if available', function() { + this.doc.undoComposeTimeout = -1; + this.doc.create([], otRichText.type.uri); + this.doc.submitOp([ otRichText.Action.createInsertText('two') ], { undoable: true }); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('two') ]); + this.doc.submitOp([ otRichText.Action.createInsertText('one') ], { undoable: true }); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('onetwo') ]); + this.doc.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('two') ]); + this.doc.undo(); + expect(this.doc.data).to.eql([]); + this.doc.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('two') ]); + this.doc.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('onetwo') ]); + }); + + it('does not add an undo level, if type is not invertible', function() { + this.doc.create('two', otText.type.uri); + this.doc.submitOp([ 'one' ], { undoable: true }); + expect(this.doc.data).to.eql('onetwo'); + expect(this.doc.canUndo()).to.equal(false); + expect(this.doc.canRedo()).to.equal(false); + this.doc.undo(); + expect(this.doc.data).to.eql('onetwo'); + expect(this.doc.canUndo()).to.equal(false); + expect(this.doc.canRedo()).to.equal(false); + }); + + it('composes similar operations', function() { + this.doc.create([], otRichText.type.uri); + this.doc.submitOp([ + otRichText.Action.createInsertText('one') + ], { undoable: true }); + this.doc.submitOp([ + otRichText.Action.createRetain(3), + otRichText.Action.createInsertText('two') + ], { undoable: true }); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('onetwo') ]); + expect(this.doc.canRedo()).to.equal(false); + this.doc.undo(); + expect(this.doc.data).to.eql([]); + expect(this.doc.canUndo()).to.equal(false); + this.doc.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('onetwo') ]); + expect(this.doc.canRedo()).to.equal(false); + }); + + it('does not compose dissimilar operations', function() { + this.doc.create([ + otRichText.Action.createInsertText(' ') + ], otRichText.type.uri); + + this.doc.submitOp([ + otRichText.Action.createRetain(1), + otRichText.Action.createInsertText('two') + ], { undoable: true }); + expect(this.doc.data).to.eql([ + otRichText.Action.createInsertText(' two') + ]); + + this.doc.submitOp([ + otRichText.Action.createInsertText('one') + ], { undoable: true }); + expect(this.doc.data).to.eql([ + otRichText.Action.createInsertText('one two') + ]); + + this.doc.undo(); + expect(this.doc.data).to.eql([ + otRichText.Action.createInsertText(' two') + ]); + + this.doc.undo(); + expect(this.doc.data).to.eql([ + otRichText.Action.createInsertText(' ') + ]); + + this.doc.redo(); + expect(this.doc.data).to.eql([ + otRichText.Action.createInsertText(' two') + ]); + + this.doc.redo(); + expect(this.doc.data).to.eql([ + otRichText.Action.createInsertText('one two') + ]); + }); + + it('does not add no-ops to the undo stack on undoable operation', function() { + var opCalled = false; + this.doc.create([ otRichText.Action.createInsertText('test', [ 'key', 'value' ]) ], otRichText.type.uri); + this.doc.on('op', function(op, source) { + expect(op).to.eql([ otRichText.Action.createRetain(4, [ 'key', 'value' ]) ]); + opCalled = true; + }); + this.doc.submitOp([ otRichText.Action.createRetain(4, [ 'key', 'value' ]) ], { undoable: true }); + expect(opCalled).to.equal(true); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('test', [ 'key', 'value' ]) ]); + expect(this.doc.canUndo()).to.eql(false); + expect(this.doc.canRedo()).to.eql(false); + }); + + it('limits the size of the undo stack', function() { + this.doc.undoLimit = 2; + this.doc.undoComposeTimeout = -1; + this.doc.create({ test: 5 }); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + expect(this.doc.data).to.eql({ test: 11 }); + expect(this.doc.canUndo()).to.equal(true); + this.doc.undo(); + expect(this.doc.canUndo()).to.equal(true); + this.doc.undo(); + expect(this.doc.canUndo()).to.equal(false); + this.doc.undo(); + expect(this.doc.data).to.eql({ test: 7 }); + }); + + it('limits the size of the undo stack, after adjusting the limit', function() { + this.doc.undoLimit = 100; + this.doc.undoComposeTimeout = -1; + this.doc.create({ test: 5 }); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + this.doc.undoLimit = 2; + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + expect(this.doc.data).to.eql({ test: 15 }); + expect(this.doc.canUndo()).to.equal(true); + this.doc.undo(); + expect(this.doc.canUndo()).to.equal(true); + this.doc.undo(); + expect(this.doc.canUndo()).to.equal(false); + this.doc.undo(); + expect(this.doc.data).to.eql({ test: 11 }); + }); + + it('does not limit the size of the stacks on undo and redo operations', function() { + this.doc.undoLimit = 100; + this.doc.undoComposeTimeout = -1; + this.doc.create({ test: 5 }); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + this.doc.undoLimit = 2; + expect(this.doc.data).to.eql({ test: 15 }); + this.doc.undo(); + this.doc.undo(); + this.doc.undo(); + this.doc.undo(); + this.doc.undo(); + expect(this.doc.data).to.eql({ test: 5 }); + this.doc.redo(); + this.doc.redo(); + this.doc.redo(); + this.doc.redo(); + this.doc.redo(); + expect(this.doc.data).to.eql({ test: 15 }); + this.doc.undo(); + this.doc.undo(); + this.doc.undo(); + this.doc.undo(); + this.doc.undo(); + expect(this.doc.data).to.eql({ test: 5 }); + }); + + it('does not compose the next operation after undo', function() { + this.doc.create({ test: 5 }); + this.doc.undoComposeTimeout = -1; + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); // not composed + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); // not composed + this.doc.undoComposeTimeout = 1000; + this.doc.undo(); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); // not composed + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); // composed + expect(this.doc.data).to.eql({ test: 11 }); + expect(this.doc.canUndo()).to.equal(true); + + this.doc.undo(); + expect(this.doc.data).to.eql({ test: 7 }); + expect(this.doc.canUndo()).to.equal(true); + + this.doc.undo(); + expect(this.doc.data).to.eql({ test: 5 }); + expect(this.doc.canUndo()).to.equal(false); + }); + + it('does not compose the next operation after undo and redo', function() { + this.doc.create({ test: 5 }); + this.doc.undoComposeTimeout = -1; + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); // not composed + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); // not composed + this.doc.undoComposeTimeout = 1000; + this.doc.undo(); + this.doc.redo(); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); // not composed + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); // composed + expect(this.doc.data).to.eql({ test: 13 }); + expect(this.doc.canUndo()).to.equal(true); + + this.doc.undo(); + expect(this.doc.data).to.eql({ test: 9 }); + expect(this.doc.canUndo()).to.equal(true); + + this.doc.undo(); + expect(this.doc.data).to.eql({ test: 7 }); + expect(this.doc.canUndo()).to.equal(true); + + this.doc.undo(); + expect(this.doc.data).to.eql({ test: 5 }); + expect(this.doc.canUndo()).to.equal(false); + }); + + it('clears stacks on del', function() { + this.doc.undoComposeTimeout = -1; + this.doc.create({ test: 5 }); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + this.doc.undo(); + expect(this.doc.canUndo()).to.equal(true); + expect(this.doc.canRedo()).to.equal(true); + this.doc.del(); + expect(this.doc.canUndo()).to.equal(false); + expect(this.doc.canRedo()).to.equal(false); + }); + + it('transforms the stacks by remote operations', function(done) { + this.doc2.subscribe(); + this.doc.subscribe(); + this.doc.undoComposeTimeout = -1; + this.doc.create([], otRichText.type.uri); + this.doc.submitOp([ otRichText.Action.createInsertText('4') ], { undoable: true }); + this.doc.submitOp([ otRichText.Action.createInsertText('3') ], { undoable: true }); + this.doc.submitOp([ otRichText.Action.createInsertText('2') ], { undoable: true }); + this.doc.submitOp([ otRichText.Action.createInsertText('1') ], { undoable: true }); + this.doc.undo(); + this.doc.undo(); + setTimeout(function() { + this.doc.once('op', function(op, source) { + expect(source).to.equal(false); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC34') ]); + this.doc.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC4') ]); + this.doc.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC') ]); + this.doc.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC4') ]); + this.doc.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC34') ]); + this.doc.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC234') ]); + this.doc.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC1234') ]); + done(); + }.bind(this)); + this.doc2.submitOp([ otRichText.Action.createInsertText('ABC') ]); + }.bind(this)); + }); + + it('transforms the stacks by remote operations and removes no-ops', function(done) { + this.doc2.subscribe(); + this.doc.subscribe(); + this.doc.undoComposeTimeout = -1; + this.doc.create([], otRichText.type.uri); + this.doc.submitOp([ otRichText.Action.createInsertText('4') ], { undoable: true }); + this.doc.submitOp([ otRichText.Action.createInsertText('3') ], { undoable: true }); + this.doc.submitOp([ otRichText.Action.createInsertText('2') ], { undoable: true }); + this.doc.submitOp([ otRichText.Action.createInsertText('1') ], { undoable: true }); + this.doc.undo(); + this.doc.undo(); + setTimeout(function() { + this.doc.once('op', function(op, source) { + expect(source).to.equal(false); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('4') ]); + this.doc.undo(); + expect(this.doc.data).to.eql([]); + expect(this.doc.canUndo()).to.equal(false); + this.doc.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('4') ]); + this.doc.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('24') ]); + this.doc.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('124') ]); + expect(this.doc.canRedo()).to.equal(false); + done(); + }.bind(this)); + this.doc2.submitOp([ otRichText.Action.createDelete(1) ]); + }.bind(this)); + }); + + it('transforms the stacks by a local FIXED operation', function() { + this.doc.undoComposeTimeout = -1; + this.doc.create([], otRichText.type.uri); + this.doc.submitOp([ otRichText.Action.createInsertText('4') ], { undoable: true }); + this.doc.submitOp([ otRichText.Action.createInsertText('3') ], { undoable: true }); + this.doc.submitOp([ otRichText.Action.createInsertText('2') ], { undoable: true }); + this.doc.submitOp([ otRichText.Action.createInsertText('1') ], { undoable: true }); + this.doc.undo(); + this.doc.undo(); + this.doc.submitOp([ otRichText.Action.createInsertText('ABC') ]); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC34') ]); + this.doc.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC4') ]); + this.doc.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC') ]); + this.doc.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC4') ]); + this.doc.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC34') ]); + this.doc.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC234') ]); + this.doc.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC1234') ]); + }); + + it('transforms the stacks by a local FIXED operation and removes no-ops', function() { + this.doc.undoComposeTimeout = -1; + this.doc.create([], otRichText.type.uri); + this.doc.submitOp([ otRichText.Action.createInsertText('4') ], { undoable: true }); + this.doc.submitOp([ otRichText.Action.createInsertText('3') ], { undoable: true }); + this.doc.submitOp([ otRichText.Action.createInsertText('2') ], { undoable: true }); + this.doc.submitOp([ otRichText.Action.createInsertText('1') ], { undoable: true }); + this.doc.undo(); + this.doc.undo(); + this.doc.submitOp([ otRichText.Action.createDelete(1) ]); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('4') ]); + this.doc.undo(); + expect(this.doc.data).to.eql([]); + expect(this.doc.canUndo()).to.equal(false); + this.doc.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('4') ]); + this.doc.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('24') ]); + this.doc.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('124') ]); + expect(this.doc.canRedo()).to.equal(false); + }); + + it('transforms the stacks using transform', function() { + this.doc.undoComposeTimeout = -1; + this.doc.create(0, invertibleType.type.uri); + this.doc.submitOp(1, { undoable: true }); + this.doc.submitOp(10, { undoable: true }); + this.doc.submitOp(100, { undoable: true }); + this.doc.submitOp(1000, { undoable: true }); + this.doc.undo(); + this.doc.undo(); + expect(this.doc.data).to.equal(11); + this.doc.submitOp(10000); + this.doc.undo(); + expect(this.doc.data).to.equal(10001); + this.doc.undo(); + expect(this.doc.data).to.equal(10000); + this.doc.redo(); + expect(this.doc.data).to.equal(10001); + this.doc.redo(); + expect(this.doc.data).to.equal(10011); + this.doc.redo(); + expect(this.doc.data).to.equal(10111); + this.doc.redo(); + expect(this.doc.data).to.equal(11111); + }); + + it('transforms the stacks using transformX', function() { + this.doc.undoComposeTimeout = -1; + this.doc.create(0, invertibleType.typeWithTransformX.uri); + this.doc.submitOp(1, { undoable: true }); + this.doc.submitOp(10, { undoable: true }); + this.doc.submitOp(100, { undoable: true }); + this.doc.submitOp(1000, { undoable: true }); + this.doc.undo(); + this.doc.undo(); + expect(this.doc.data).to.equal(11); + this.doc.submitOp(10000); + this.doc.undo(); + expect(this.doc.data).to.equal(10001); + this.doc.undo(); + expect(this.doc.data).to.equal(10000); + this.doc.redo(); + expect(this.doc.data).to.equal(10001); + this.doc.redo(); + expect(this.doc.data).to.equal(10011); + this.doc.redo(); + expect(this.doc.data).to.equal(10111); + this.doc.redo(); + expect(this.doc.data).to.equal(11111); + }); + + it('skips processing when submitting a no-op (no callback)', function(done) { + this.doc.on('op', function() { + done(new Error('Should not emit `op`')); + }); + this.doc.create([ otRichText.Action.createInsertText('test') ], otRichText.type.uri); + this.doc.submitOp([]); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('test') ]); + done(); + }); + + it('skips processing when submitting a no-op (with callback)', function(done) { + this.doc.on('op', function() { + done(new Error('Should not emit `op`')); + }); + this.doc.create([ otRichText.Action.createInsertText('test') ], otRichText.type.uri); + this.doc.submitOp([], done); + }); + + it('skips processing when submitting an identical snapshot (no callback)', function(done) { + this.doc.on('op', function() { + done(new Error('Should not emit `op`')); + }); + this.doc.create([ otRichText.Action.createInsertText('test') ], otRichText.type.uri); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('test') ]); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('test') ]); + done(); + }); + + it('skips processing when submitting an identical snapshot (with callback)', function(done) { + this.doc.on('op', function() { + done(new Error('Should not emit `op`')); + }); + this.doc.create([ otRichText.Action.createInsertText('test') ], otRichText.type.uri); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('test') ], done); + }); + + describe('operationType', function() { + it('reports UNDOABLE operationType', function(done) { + this.doc.create({ test: 5 }); + this.doc.on('op', function(op, source, operationType) { + expect(source).to.equal(true); + expect(operationType).to.equal('UNDOABLE'); + done(); + }); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + }); + + it('reports UNDO operationType', function(done) { + this.doc.create({ test: 5 }); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + this.doc.on('op', function(op, source, operationType) { + expect(source).to.equal(true); + expect(operationType).to.equal('UNDO'); + done(); + }); + this.doc.undo(); + }); + + it('reports REDO operationType', function(done) { + this.doc.create({ test: 5 }); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + this.doc.undo(); + this.doc.on('op', function(op, source, operationType) { + expect(source).to.equal(true); + expect(operationType).to.equal('REDO'); + done(); + }); + this.doc.redo(); + }); + + it('reports FIXED operationType (local operation, undoable=false)', function(done) { + this.doc.create({ test: 5 }); + this.doc.on('op', function(op, source, operationType) { + expect(source).to.equal(true); + expect(operationType).to.equal('FIXED'); + done(); + }); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ]); + }); + + it('reports FIXED operationType (local operation, undoable=true but type is not invertible)', function(done) { + this.doc.create('', otText.type.uri); + this.doc.on('op', function(op, source, operationType) { + expect(source).to.equal(true); + expect(operationType).to.equal('FIXED'); + done(); + }); + this.doc.submitOp([ 'test' ], { undoable: true }); + }); + + it('reports FIXED operationType (remote operation, undoable=false)', function(done) { + this.doc.subscribe(); + this.doc.on('op', function(op, source, operationType) { + expect(source).to.equal(false); + expect(operationType).to.equal('FIXED'); + done(); + }); + this.doc2.preventCompose = true; + this.doc2.create({ test: 5 }); + this.doc2.submitOp([ { p: [ 'test' ], na: 2 } ]); + }); + + it('reports FIXED operationType (remote operation, undoable=true)', function(done) { + this.doc.subscribe(); + this.doc.on('op', function(op, source, operationType) { + expect(source).to.equal(false); + expect(operationType).to.equal('FIXED'); + done(); + }); + this.doc2.preventCompose = true; + this.doc2.create({ test: 5 }); + this.doc2.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + }); + }); + + describe('fixup operations', function() { + describe('basic tests', function() { + beforeEach(function() { + this.assert = function(text) { + var expected = text ? [ otRichText.Action.createInsertText(text) ] : []; + expect(this.doc.data).to.eql(expected); + return this; + }; + this.submitOp = function(op, options) { + this.doc.submitOp([ otRichText.Action.createInsertText(op) ], options); + return this; + }; + this.submitSnapshot = function(snapshot, options) { + this.doc.submitSnapshot([ otRichText.Action.createInsertText(snapshot) ], options); + return this; + }; + this.undo = function() { + this.doc.undo(); + return this; + }; + this.redo = function() { + this.doc.redo(); + return this; + }; + + this.doc.undoComposeTimeout = -1; + this.doc.create([], otRichText.type.uri); + this.submitOp('d', { undoable: true }).assert('d'); + this.submitOp('c', { undoable: true }).assert('cd'); + this.submitOp('b', { undoable: true }).assert('bcd'); + this.submitOp('a', { undoable: true }).assert('abcd'); + this.undo().assert('bcd'); + this.undo().assert('cd'); + expect(this.doc.canUndo()).to.equal(true); + expect(this.doc.canRedo()).to.equal(true); + }); + + it('submits an operation (transforms undo stack, transforms redo stack)', function() { + this.submitOp('!').assert('!cd'); + this.undo().assert('!d'); + this.undo().assert('!'); + this.redo().assert('!d'); + this.redo().assert('!cd'); + this.redo().assert('!bcd'); + this.redo().assert('!abcd'); + }); + + it('submits an operation (fixes up undo stack, transforms redo stack)', function() { + this.submitOp('!', { fixUpUndoStack: true }).assert('!cd'); + this.undo().assert('d'); + this.undo().assert(''); + this.redo().assert('d'); + this.redo().assert('!cd'); + this.redo().assert('!bcd'); + this.redo().assert('!abcd'); + }); + + it('submits an operation (transforms undo stack, fixes up redo stack)', function() { + this.submitOp('!', { fixUpRedoStack: true }).assert('!cd'); + this.undo().assert('!d'); + this.undo().assert('!'); + this.redo().assert('!d'); + this.redo().assert('!cd'); + this.redo().assert('bcd'); + this.redo().assert('abcd'); + }); + + it('submits an operation (fixes up undo stack, fixes up redo stack)', function() { + this.submitOp('!', { fixUpUndoStack: true, fixUpRedoStack: true }).assert('!cd'); + this.undo().assert('d'); + this.undo().assert(''); + this.redo().assert('d'); + this.redo().assert('!cd'); + this.redo().assert('bcd'); + this.redo().assert('abcd'); + }); + + it('submits a snapshot (transforms undo stack, transforms redo stack)', function() { + this.submitSnapshot('!cd').assert('!cd'); + this.undo().assert('!d'); + this.undo().assert('!'); + this.redo().assert('!d'); + this.redo().assert('!cd'); + this.redo().assert('!bcd'); + this.redo().assert('!abcd'); + }); + + it('submits a snapshot (fixes up undo stack, transforms redo stack)', function() { + this.submitSnapshot('!cd', { fixUpUndoStack: true }).assert('!cd'); + this.undo().assert('d'); + this.undo().assert(''); + this.redo().assert('d'); + this.redo().assert('!cd'); + this.redo().assert('!bcd'); + this.redo().assert('!abcd'); + }); + + it('submits a snapshot (transforms undo stack, fixes up redo stack)', function() { + this.submitSnapshot('!cd', { fixUpRedoStack: true }).assert('!cd'); + this.undo().assert('!d'); + this.undo().assert('!'); + this.redo().assert('!d'); + this.redo().assert('!cd'); + this.redo().assert('bcd'); + this.redo().assert('abcd'); + }); + + it('submits a snapshot (fixes up undo stack, fixes up redo stack)', function() { + this.submitSnapshot('!cd', { fixUpUndoStack: true, fixUpRedoStack: true }).assert('!cd'); + this.undo().assert('d'); + this.undo().assert(''); + this.redo().assert('d'); + this.redo().assert('!cd'); + this.redo().assert('bcd'); + this.redo().assert('abcd'); + }); + }); + + describe('no-ops', function() { + it('removes a no-op from the undo stack', function() { + this.doc.undoComposeTimeout = -1; + this.doc.create([], otRichText.type.uri); + this.doc.submitOp([ otRichText.Action.createInsertText('d') ], { undoable: true }); + this.doc.submitOp([ otRichText.Action.createInsertText('c') ], { undoable: true }); + this.doc.submitOp([ otRichText.Action.createInsertText('b') ], { undoable: true }); + this.doc.submitOp([ otRichText.Action.createInsertText('a') ], { undoable: true }); + this.doc.undo(); + this.doc.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('cd') ]); + this.doc.submitOp([ otRichText.Action.createDelete(1) ], { fixUpUndoStack: true }); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('d') ]); + this.doc.undo(); + expect(this.doc.data).to.eql([]); + expect(this.doc.canUndo()).to.equal(false); + this.doc.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('d') ]); + this.doc.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('bd') ]); + this.doc.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abd') ]); + expect(this.doc.canRedo()).to.equal(false); + }); + + it('removes a no-op from the redo stack', function() { + this.doc.undoComposeTimeout = -1; + this.doc.create([ otRichText.Action.createInsertText('abcd') ], otRichText.type.uri); + this.doc.submitOp([ otRichText.Action.createDelete(1) ], { undoable: true }); + this.doc.submitOp([ otRichText.Action.createDelete(1) ], { undoable: true }); + this.doc.submitOp([ otRichText.Action.createDelete(1) ], { undoable: true }); + this.doc.submitOp([ otRichText.Action.createDelete(1) ], { undoable: true }); + this.doc.undo(); + this.doc.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('cd') ]); + this.doc.submitOp([ otRichText.Action.createDelete(1) ], { fixUpRedoStack: true }); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('d') ]); + this.doc.redo(); + expect(this.doc.data).to.eql([]); + expect(this.doc.canRedo()).to.equal(false); + this.doc.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('d') ]); + this.doc.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('bd') ]); + this.doc.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abd') ]); + expect(this.doc.canUndo()).to.equal(false); + }); + }); + }); + + describe('submitSnapshot', function() { + describe('basic tests', function() { + it('submits a snapshot when document is not created (no callback, no options)', function(done) { + this.doc.on('error', function(error) { + expect(error).to.be.an(Error); + expect(error.code).to.equal(4015); + done(); + }); + this.doc.submitSnapshot(7); + }); + + it('submits a snapshot when document is not created (no callback, with options)', function(done) { + this.doc.on('error', function(error) { + expect(error).to.be.an(Error); + expect(error.code).to.equal(4015); + done(); + }); + this.doc.submitSnapshot(7, { source: 'test' }); + }); + + it('submits a snapshot when document is not created (with callback, no options)', function(done) { + this.doc.on('error', done); + this.doc.submitSnapshot(7, function(error) { + expect(error).to.be.an(Error); + expect(error.code).to.equal(4015); + done(); + }); + }); + + it('submits a snapshot when document is not created (with callback, with options)', function(done) { + this.doc.on('error', done); + this.doc.submitSnapshot(7, { source: 'test' }, function(error) { + expect(error).to.be.an(Error); + expect(error.code).to.equal(4015); + done(); + }); + }); + + it('submits a snapshot with source (no callback)', function(done) { + this.doc.on('op', function(op, source) { + expect(op).to.eql([ otRichText.Action.createInsertText('abc') ]); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcdef') ]); + expect(this.doc.canUndo()).to.equal(false); + expect(this.doc.canRedo()).to.equal(false); + expect(source).to.equal('test'); + done(); + }.bind(this)); + this.doc.create([ otRichText.Action.createInsertText('def') ], otRichText.type.uri); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('abcdef') ], { source: 'test' }); + }); + + it('submits a snapshot with source (with callback)', function(done) { + var opEmitted = false; + this.doc.on('op', function(op, source) { + expect(op).to.eql([ otRichText.Action.createInsertText('abc') ]); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcdef') ]); + expect(source).to.equal('test'); + expect(this.doc.canUndo()).to.equal(false); + expect(this.doc.canRedo()).to.equal(false); + opEmitted = true; + }.bind(this)); + this.doc.create([ otRichText.Action.createInsertText('def') ], otRichText.type.uri); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('abcdef') ], { source: 'test' }, function(error) { + expect(opEmitted).to.equal(true); + done(error); + }); + }); + + it('submits a snapshot without source (no callback)', function(done) { + this.doc.on('op', function(op, source) { + expect(op).to.eql([ otRichText.Action.createInsertText('abc') ]); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcdef') ]); + expect(this.doc.canUndo()).to.equal(false); + expect(this.doc.canRedo()).to.equal(false); + expect(source).to.equal(true); + done(); + }.bind(this)); + this.doc.create([ otRichText.Action.createInsertText('def') ], otRichText.type.uri); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('abcdef') ]); + }); + + it('submits a snapshot without source (with callback)', function(done) { + var opEmitted = false; + this.doc.on('op', function(op, source) { + expect(op).to.eql([ otRichText.Action.createInsertText('abc') ]); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcdef') ]); + expect(source).to.equal(true); + expect(this.doc.canUndo()).to.equal(false); + expect(this.doc.canRedo()).to.equal(false); + opEmitted = true; + }.bind(this)); + this.doc.create([ otRichText.Action.createInsertText('def') ], otRichText.type.uri); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('abcdef') ], function(error) { + expect(opEmitted).to.equal(true); + done(error); + }); + }); + + it('submits snapshots and supports undo and redo', function() { + this.doc.undoComposeTimeout = -1; + this.doc.create([ otRichText.Action.createInsertText('ghi') ], otRichText.type.uri); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('defghi') ], { undoable: true }); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('defghi') ]); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('abcdefghi') ], { undoable: true }); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcdefghi') ]); + this.doc.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('defghi') ]); + this.doc.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ghi') ]); + expect(this.doc.canUndo()).to.equal(false); + this.doc.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('defghi') ]); + this.doc.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcdefghi') ]); + expect(this.doc.canRedo()).to.equal(false); + this.doc.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('defghi') ]); + this.doc.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ghi') ]); + expect(this.doc.canUndo()).to.equal(false); + }); + + it('submits snapshots and composes operations', function() { + this.doc.create([ otRichText.Action.createInsertText('ghi') ], otRichText.type.uri); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('defghi') ], { undoable: true }); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('defghi') ]); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('abcdefghi') ], { undoable: true }); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcdefghi') ]); + this.doc.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ghi') ]); + expect(this.doc.canUndo()).to.equal(false); + this.doc.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcdefghi') ]); + expect(this.doc.canRedo()).to.equal(false); + this.doc.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ghi') ]); + expect(this.doc.canUndo()).to.equal(false); + }); + + it('submits a snapshot and syncs it', function(done) { + this.doc2.on('create', function() { + this.doc2.submitSnapshot([ otRichText.Action.createInsertText('abcdef') ]); + }.bind(this)); + this.doc.on('op', function(op, source) { + expect(op).to.eql([ otRichText.Action.createInsertText('abc') ]); + expect(source).to.equal(false); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcdef') ]); + done(); + }.bind(this)); + this.doc2.subscribe(); + this.doc.subscribe(); + this.doc.create([ otRichText.Action.createInsertText('def') ], otRichText.type.uri); + }); + + it('submits undoable and fixed operations', function() { + this.doc.undoComposeTimeout = -1; + this.doc.create([], otRichText.type.uri); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('a') ], { undoable: true }); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('ab') ], { undoable: true }); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('abc') ], { undoable: true }); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('abcd') ], { undoable: true }); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('abcde') ], { undoable: true }); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('abcdef') ], { undoable: true }); + this.doc.undo(); + this.doc.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcd') ]); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('abc123d') ]); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abc123d') ]); + this.doc.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abc123') ]); + this.doc.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ab123') ]); + this.doc.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('a123') ]); + this.doc.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('123') ]); + this.doc.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('a123') ]); + this.doc.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ab123') ]); + this.doc.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abc123') ]); + this.doc.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abc123d') ]); + this.doc.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abc123de') ]); + this.doc.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abc123def') ]); + }); + + it('submits a snapshot without a diffHint', function() { + var opCalled = 0; + this.doc.create([ otRichText.Action.createInsertText('aaaa') ], otRichText.type.uri); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('aaaaa') ], { undoable: true }); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('aaaaa') ]); + + this.doc.once('op', function(op) { + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('aaaa') ]); + expect(op).to.eql([ otRichText.Action.createDelete(1) ]); + opCalled++; + }.bind(this)); + this.doc.undo(); + + this.doc.once('op', function(op) { + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('aaaaa') ]); + expect(op).to.eql([ otRichText.Action.createInsertText('a') ]); + opCalled++; + }.bind(this)); + this.doc.redo(); + + expect(opCalled).to.equal(2); + }); + + it('submits a snapshot with a diffHint', function() { + var opCalled = 0; + this.doc.create([ otRichText.Action.createInsertText('aaaa') ], otRichText.type.uri); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('aaaaa') ], { undoable: true, diffHint: 2 }); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('aaaaa') ]); + + this.doc.once('op', function(op) { + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('aaaa') ]); + expect(op).to.eql([ otRichText.Action.createRetain(2), otRichText.Action.createDelete(1) ]); + opCalled++; + }.bind(this)); + this.doc.undo(); + + this.doc.once('op', function(op) { + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('aaaaa') ]); + expect(op).to.eql([ otRichText.Action.createRetain(2), otRichText.Action.createInsertText('a') ]); + opCalled++; + }.bind(this)); + this.doc.redo(); + + expect(opCalled).to.equal(2); + }); + }); + + describe('no diff nor diffX', function() { + it('submits a snapshot (no callback)', function(done) { + this.doc.on('error', function(error) { + expect(error).to.be.an(Error); + expect(error.code).to.equal(4024); + done(); + }); + this.doc.create(5, invertibleType.type.uri); + this.doc.submitSnapshot(7); + }); + + it('submits a snapshot (with callback)', function(done) { + this.doc.on('error', done); + this.doc.create(5, invertibleType.type.uri); + this.doc.submitSnapshot(7, function(error) { + expect(error).to.be.an(Error); + expect(error.code).to.equal(4024); + done(); + }); + }); + }); + + describe('with diff', function () { + it('submits a snapshot (non-undoable)', function() { + this.doc.create(5, invertibleType.typeWithDiff.uri); + this.doc.submitSnapshot(7); + expect(this.doc.data).to.equal(7); + expect(this.doc.canUndo()).to.equal(false); + expect(this.doc.canRedo()).to.equal(false); + }); + it('submits a snapshot (undoable)', function() { + this.doc.create(5, invertibleType.typeWithDiff.uri); + this.doc.submitSnapshot(7, { undoable: true }); + expect(this.doc.data).to.equal(7); + this.doc.undo(); + expect(this.doc.data).to.equal(5); + this.doc.redo(); + expect(this.doc.data).to.equal(7); + }); + }); + + describe('with diffX', function () { + it('submits a snapshot (non-undoable)', function() { + this.doc.create(5, invertibleType.typeWithDiffX.uri); + this.doc.submitSnapshot(7); + expect(this.doc.data).to.equal(7); + expect(this.doc.canUndo()).to.equal(false); + expect(this.doc.canRedo()).to.equal(false); + }); + it('submits a snapshot (undoable)', function() { + this.doc.create(5, invertibleType.typeWithDiffX.uri); + this.doc.submitSnapshot(7, { undoable: true }); + expect(this.doc.data).to.equal(7); + this.doc.undo(); + expect(this.doc.data).to.equal(5); + this.doc.redo(); + expect(this.doc.data).to.equal(7); + }); + }); + + describe('with diff and diffX', function () { + it('submits a snapshot (non-undoable)', function() { + this.doc.create(5, invertibleType.typeWithDiffAndDiffX.uri); + this.doc.submitSnapshot(7); + expect(this.doc.data).to.equal(7); + expect(this.doc.canUndo()).to.equal(false); + expect(this.doc.canRedo()).to.equal(false); + }); + it('submits a snapshot (undoable)', function() { + this.doc.create(5, invertibleType.typeWithDiffAndDiffX.uri); + this.doc.submitSnapshot(7, { undoable: true }); + expect(this.doc.data).to.equal(7); + this.doc.undo(); + expect(this.doc.data).to.equal(5); + this.doc.redo(); + expect(this.doc.data).to.equal(7); + }); + }); + }); +}); From 927b4eb457c7ebd0898953af9847a503f214eab0 Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Mon, 25 Jun 2018 17:10:59 +0100 Subject: [PATCH 18/45] Allow snapshot and op to be a non-object --- lib/ot.js | 2 +- package-lock.json | 1481 ++++++++++++++++++++++++++++++++++++ test/client/number-type.js | 23 + test/client/submit.js | 15 + 4 files changed, 1520 insertions(+), 1 deletion(-) create mode 100644 package-lock.json create mode 100644 test/client/number-type.js diff --git a/lib/ot.js b/lib/ot.js index 1dc89bcfc..8cf708521 100644 --- a/lib/ot.js +++ b/lib/ot.js @@ -102,7 +102,7 @@ exports.apply = function(snapshot, op) { function applyOpEdit(snapshot, edit) { if (!snapshot.type) return {code: 4015, message: 'Document does not exist'}; - if (typeof edit !== 'object') return {code: 5004, message: 'Missing op'}; + if (edit == null) return {code: 5004, message: 'Missing op'}; var type = types[snapshot.type]; if (!type) return {code: 4008, message: 'Unknown type'}; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..0df7dc86a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1481 @@ +{ + "name": "sharedb", + "version": "1.0.0-beta.9", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "abbrev": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.0.9.tgz", + "integrity": "sha1-kbR5JYinc4wl813W9jdSovh3YTU=", + "dev": true + }, + "align-text": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz", + "integrity": "sha1-DNkKVhCT810KmSVsIrcGlDP60Rc=", + "dev": true, + "requires": { + "kind-of": "^3.0.2", + "longest": "^1.0.1", + "repeat-string": "^1.5.2" + } + }, + "amdefine": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", + "integrity": "sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU=", + "dev": true + }, + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + }, + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "dev": true + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "arraydiff": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/arraydiff/-/arraydiff-0.1.3.tgz", + "integrity": "sha1-hqVDbXty8b3aX9bXTock5C+Dzk0=" + }, + "asn1": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz", + "integrity": "sha1-2sh4dxPJlmhJ/IGAd36+nB3fO4Y=", + "dev": true + }, + "assert-plus": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-0.2.0.tgz", + "integrity": "sha1-104bh+ev/A24qttwIfP+SBAasjQ=", + "dev": true + }, + "async": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", + "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=" + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", + "dev": true + }, + "aws-sign2": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.6.0.tgz", + "integrity": "sha1-FDQt0428yU0OW4fXY81jYSwOeU8=", + "dev": true + }, + "aws4": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.7.0.tgz", + "integrity": "sha512-32NDda82rhwD9/JBCCkB+MRYDp0oSvlo2IL6rQWA10PQi7tDUM3eqMSltXmY+Oyl/7N3P3qNtAlv7X0d9bI28w==", + "dev": true + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true + }, + "bcrypt-pbkdf": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.1.tgz", + "integrity": "sha1-Y7xdy2EzG5K8Bf1SiVPDNGKgb40=", + "dev": true, + "optional": true, + "requires": { + "tweetnacl": "^0.14.3" + } + }, + "boom": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/boom/-/boom-2.10.1.tgz", + "integrity": "sha1-OciRjO/1eZ+D+UkqhI9iWt0Mdm8=", + "dev": true, + "requires": { + "hoek": "2.x.x" + } + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "browser-stdout": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.0.tgz", + "integrity": "sha1-81HTKWnTL6XXpVZxVCY9korjvR8=", + "dev": true + }, + "camelcase": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz", + "integrity": "sha1-m7UwTS4LVmmLLHWLCKPqqdqlijk=", + "dev": true, + "optional": true + }, + "caseless": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.11.0.tgz", + "integrity": "sha1-cVuW6phBWTzDMGeSP17GDr2k99c=", + "dev": true + }, + "center-align": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/center-align/-/center-align-0.1.3.tgz", + "integrity": "sha1-qg0yYptu6XIgBBHL1EYckHvCt60=", + "dev": true, + "optional": true, + "requires": { + "align-text": "^0.1.3", + "lazy-cache": "^1.0.3" + } + }, + "chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, + "requires": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + } + }, + "cli": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cli/-/cli-1.0.1.tgz", + "integrity": "sha1-IoF1NPJL+klQw01TLUjsvGIbjBQ=", + "dev": true, + "requires": { + "exit": "0.1.2", + "glob": "^7.1.1" + }, + "dependencies": { + "glob": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", + "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + } + } + }, + "cliui": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-2.1.0.tgz", + "integrity": "sha1-S0dXYP+AJkx2LDoXGQMukcf+oNE=", + "dev": true, + "optional": true, + "requires": { + "center-align": "^0.1.1", + "right-align": "^0.1.1", + "wordwrap": "0.0.2" + }, + "dependencies": { + "wordwrap": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz", + "integrity": "sha1-t5Zpu0LstAn4PVg8rVLKF+qhZD8=", + "dev": true, + "optional": true + } + } + }, + "combined-stream": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.6.tgz", + "integrity": "sha1-cj599ugBrFYTETp+RFqbactjKBg=", + "dev": true, + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "commander": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.15.1.tgz", + "integrity": "sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag==", + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "console-browserify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.1.0.tgz", + "integrity": "sha1-8CQcRXMKn8YyOyBtvzjtx0HQuxA=", + "dev": true, + "requires": { + "date-now": "^0.1.4" + } + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", + "dev": true + }, + "coveralls": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/coveralls/-/coveralls-2.13.3.tgz", + "integrity": "sha512-iiAmn+l1XqRwNLXhW8Rs5qHZRFMYp9ZIPjEOVRpC/c4so6Y/f4/lFi0FfR5B9cCqgyhkJ5cZmbvcVRfP8MHchw==", + "dev": true, + "requires": { + "js-yaml": "3.6.1", + "lcov-parse": "0.0.10", + "log-driver": "1.2.5", + "minimist": "1.2.0", + "request": "2.79.0" + } + }, + "cryptiles": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/cryptiles/-/cryptiles-2.0.5.tgz", + "integrity": "sha1-O9/s3GCBR8HGcgL6KR59ylnqo7g=", + "dev": true, + "requires": { + "boom": "2.x.x" + } + }, + "dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0" + }, + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", + "dev": true + } + } + }, + "date-now": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/date-now/-/date-now-0.1.4.tgz", + "integrity": "sha1-6vQ5/U1ISK105cx9vvIAZyueNFs=", + "dev": true + }, + "debug": { + "version": "2.6.8", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.8.tgz", + "integrity": "sha1-5zFTHKLt4n0YgiJCfaF4IdaP9Pw=", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=", + "dev": true, + "optional": true + }, + "deep-is": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", + "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=" + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", + "dev": true + }, + "diff": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.2.0.tgz", + "integrity": "sha1-yc45Okt8vQsFinJck98pkCeGj/k=", + "dev": true + }, + "dom-serializer": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.1.0.tgz", + "integrity": "sha1-BzxpdUbOB4DOI75KKOKT5AvDDII=", + "dev": true, + "requires": { + "domelementtype": "~1.1.1", + "entities": "~1.1.1" + }, + "dependencies": { + "domelementtype": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.1.3.tgz", + "integrity": "sha1-vSh3PiZCiBrsUVRJJCmcXNgiGFs=", + "dev": true + }, + "entities": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.1.tgz", + "integrity": "sha1-blwtClYhtdra7O+AuQ7ftc13cvA=", + "dev": true + } + } + }, + "domelementtype": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.0.tgz", + "integrity": "sha1-sXrtguirWeUt2cGbF1bg/BhyBMI=", + "dev": true + }, + "domhandler": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.3.0.tgz", + "integrity": "sha1-LeWaCCLVAn+r/28DLCsloqir5zg=", + "dev": true, + "requires": { + "domelementtype": "1" + } + }, + "domutils": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz", + "integrity": "sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8=", + "dev": true, + "requires": { + "dom-serializer": "0", + "domelementtype": "1" + } + }, + "ecc-jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz", + "integrity": "sha1-D8c6ntXw1Tw4GTOYUj735UN3dQU=", + "dev": true, + "optional": true, + "requires": { + "jsbn": "~0.1.0" + } + }, + "entities": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-1.0.0.tgz", + "integrity": "sha1-sph6o4ITR/zeZCsk/fyeT7cSvyY=", + "dev": true + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "escodegen": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.8.1.tgz", + "integrity": "sha1-WltTr0aTEQvrsIZ6o0MN07cKEBg=", + "dev": true, + "requires": { + "esprima": "^2.7.1", + "estraverse": "^1.9.1", + "esutils": "^2.0.2", + "optionator": "^0.8.1", + "source-map": "~0.2.0" + } + }, + "esprima": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-2.7.3.tgz", + "integrity": "sha1-luO3DVd59q1JzQMmc9HDEnZ7pYE=", + "dev": true + }, + "estraverse": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-1.9.3.tgz", + "integrity": "sha1-r2fy3JIlgkFZUJJgkaQAXSnJu0Q=", + "dev": true + }, + "esutils": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz", + "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=", + "dev": true + }, + "exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha1-BjJjj42HfMghB9MKD/8aF8uhzQw=", + "dev": true + }, + "expect.js": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/expect.js/-/expect.js-0.3.1.tgz", + "integrity": "sha1-sKWaDS7/VDdUTr8M6qYBWEHQm1s=", + "dev": true + }, + "extend": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.1.tgz", + "integrity": "sha1-p1Xqe8Gt/MWjHOfnYtuq3F5jZEQ=", + "dev": true + }, + "extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=", + "dev": true + }, + "fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", + "dev": true + }, + "forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=", + "dev": true + }, + "form-data": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.1.4.tgz", + "integrity": "sha1-M8GDrPGTJ27KqYFDpp6Uv+4XUNE=", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.5", + "mime-types": "^2.1.12" + } + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true + }, + "generate-function": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.0.0.tgz", + "integrity": "sha1-aFj+fAlpt9TpCTM3ZHrHn2DfvnQ=", + "dev": true + }, + "generate-object-property": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/generate-object-property/-/generate-object-property-1.2.0.tgz", + "integrity": "sha1-nA4cQDCM6AT0eDYYuTf6iPmdUNA=", + "dev": true, + "requires": { + "is-property": "^1.0.0" + } + }, + "getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0" + }, + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", + "dev": true + } + } + }, + "glob": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz", + "integrity": "sha1-G8k2ueAvSmA/zCIuz3Yz0wuLk7E=", + "dev": true, + "requires": { + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "2 || 3", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "graceful-readlink": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz", + "integrity": "sha1-TK+tdrxi8C+gObL5Tpo906ORpyU=", + "dev": true + }, + "growl": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/growl/-/growl-1.9.2.tgz", + "integrity": "sha1-Dqd0NxXbjY3ixe3hd14bRayFwC8=", + "dev": true + }, + "handlebars": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.0.11.tgz", + "integrity": "sha1-Ywo13+ApS8KB7a5v/F0yn8eYLcw=", + "dev": true, + "requires": { + "async": "^1.4.0", + "optimist": "^0.6.1", + "source-map": "^0.4.4", + "uglify-js": "^2.6" + }, + "dependencies": { + "source-map": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz", + "integrity": "sha1-66T12pwNyZneaAMti092FzZSA2s=", + "dev": true, + "requires": { + "amdefine": ">=0.0.4" + } + } + } + }, + "har-validator": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-2.0.6.tgz", + "integrity": "sha1-zcvAgYgmWtEZtqWnyKtw7s+10n0=", + "dev": true, + "requires": { + "chalk": "^1.1.1", + "commander": "^2.9.0", + "is-my-json-valid": "^2.12.4", + "pinkie-promise": "^2.0.0" + } + }, + "has-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "has-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz", + "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo=", + "dev": true + }, + "hat": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/hat/-/hat-0.0.3.tgz", + "integrity": "sha1-uwFKnmSzeIrtgAWRdBPU/z1QLYo=" + }, + "hawk": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/hawk/-/hawk-3.1.3.tgz", + "integrity": "sha1-B4REvXwWQLD+VA0sm3PVlnjo4cQ=", + "dev": true, + "requires": { + "boom": "2.x.x", + "cryptiles": "2.x.x", + "hoek": "2.x.x", + "sntp": "1.x.x" + } + }, + "he": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz", + "integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0=", + "dev": true + }, + "hoek": { + "version": "2.16.3", + "resolved": "https://registry.npmjs.org/hoek/-/hoek-2.16.3.tgz", + "integrity": "sha1-ILt0A9POo5jpHcRxCo/xuCdKJe0=", + "dev": true + }, + "htmlparser2": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.8.3.tgz", + "integrity": "sha1-mWwosZFRaovoZQGn15dX5ccMEGg=", + "dev": true, + "requires": { + "domelementtype": "1", + "domhandler": "2.3", + "domutils": "1.5", + "entities": "1.0", + "readable-stream": "1.1" + } + }, + "http-signature": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.1.1.tgz", + "integrity": "sha1-33LiZwZs0Kxn+3at+OE0qPvPkb8=", + "dev": true, + "requires": { + "assert-plus": "^0.2.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + } + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", + "dev": true + }, + "is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true + }, + "is-my-ip-valid": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-my-ip-valid/-/is-my-ip-valid-1.0.0.tgz", + "integrity": "sha512-gmh/eWXROncUzRnIa1Ubrt5b8ep/MGSnfAUI3aRp+sqTCs1tv1Isl8d8F6JmkN3dXKc3ehZMrtiPN9eL03NuaQ==", + "dev": true + }, + "is-my-json-valid": { + "version": "2.17.2", + "resolved": "https://registry.npmjs.org/is-my-json-valid/-/is-my-json-valid-2.17.2.tgz", + "integrity": "sha512-IBhBslgngMQN8DDSppmgDv7RNrlFotuuDsKcrCP3+HbFaVivIBU7u9oiiErw8sH4ynx3+gOGQ3q2otkgiSi6kg==", + "dev": true, + "requires": { + "generate-function": "^2.0.0", + "generate-object-property": "^1.1.0", + "is-my-ip-valid": "^1.0.0", + "jsonpointer": "^4.0.0", + "xtend": "^4.0.0" + } + }, + "is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha1-V/4cTkhHTt1lsJkR8msc1Ald2oQ=", + "dev": true + }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", + "dev": true + }, + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", + "dev": true + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true + }, + "isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=", + "dev": true + }, + "istanbul": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/istanbul/-/istanbul-0.4.5.tgz", + "integrity": "sha1-ZcfXPUxNqE1POsMQuRj7C4Azczs=", + "dev": true, + "requires": { + "abbrev": "1.0.x", + "async": "1.x", + "escodegen": "1.8.x", + "esprima": "2.7.x", + "glob": "^5.0.15", + "handlebars": "^4.0.1", + "js-yaml": "3.x", + "mkdirp": "0.5.x", + "nopt": "3.x", + "once": "1.x", + "resolve": "1.1.x", + "supports-color": "^3.1.0", + "which": "^1.1.1", + "wordwrap": "^1.0.0" + }, + "dependencies": { + "supports-color": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz", + "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=", + "dev": true, + "requires": { + "has-flag": "^1.0.0" + } + } + } + }, + "js-yaml": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.6.1.tgz", + "integrity": "sha1-bl/mfYsgXOTSL60Ft3geja3MSzA=", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^2.6.0" + } + }, + "jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", + "dev": true, + "optional": true + }, + "jshint": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/jshint/-/jshint-2.9.5.tgz", + "integrity": "sha1-HnJSkVzmgbQIJ+4UJIxG006apiw=", + "dev": true, + "requires": { + "cli": "~1.0.0", + "console-browserify": "1.1.x", + "exit": "0.1.x", + "htmlparser2": "3.8.x", + "lodash": "3.7.x", + "minimatch": "~3.0.2", + "shelljs": "0.3.x", + "strip-json-comments": "1.0.x" + } + }, + "json-schema": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", + "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=", + "dev": true + }, + "json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", + "dev": true + }, + "json3": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/json3/-/json3-3.3.2.tgz", + "integrity": "sha1-PAQ0dD35Pi9cQq7nsZvLSDV19OE=", + "dev": true + }, + "jsonpointer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-4.0.1.tgz", + "integrity": "sha1-T9kss04OnbPInIYi7PUfm5eMbLk=", + "dev": true + }, + "jsprim": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", + "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", + "dev": true, + "requires": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.2.3", + "verror": "1.10.0" + }, + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", + "dev": true + } + } + }, + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + }, + "lazy-cache": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz", + "integrity": "sha1-odePw6UEdMuAhF07O24dpJpEbo4=", + "dev": true, + "optional": true + }, + "lcov-parse": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/lcov-parse/-/lcov-parse-0.0.10.tgz", + "integrity": "sha1-GwuP+ayceIklBYK3C3ExXZ2m2aM=", + "dev": true + }, + "levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=", + "dev": true, + "requires": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + } + }, + "lodash": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.7.0.tgz", + "integrity": "sha1-Nni9irmVBXwHreg27S7wh9qBHUU=", + "dev": true + }, + "lodash._baseassign": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/lodash._baseassign/-/lodash._baseassign-3.2.0.tgz", + "integrity": "sha1-jDigmVAPIVrQnlnxci/QxSv+Ck4=", + "dev": true, + "requires": { + "lodash._basecopy": "^3.0.0", + "lodash.keys": "^3.0.0" + } + }, + "lodash._basecopy": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash._basecopy/-/lodash._basecopy-3.0.1.tgz", + "integrity": "sha1-jaDmqHbPNEwK2KVIghEd08XHyjY=", + "dev": true + }, + "lodash._basecreate": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash._basecreate/-/lodash._basecreate-3.0.3.tgz", + "integrity": "sha1-G8ZhYU2qf8MRt9A78WgGoCE8+CE=", + "dev": true + }, + "lodash._getnative": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/lodash._getnative/-/lodash._getnative-3.9.1.tgz", + "integrity": "sha1-VwvH3t5G1hzc3mh9ZdPuy6o6r/U=", + "dev": true + }, + "lodash._isiterateecall": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash._isiterateecall/-/lodash._isiterateecall-3.0.9.tgz", + "integrity": "sha1-UgOte6Ql+uhCRg5pbbnPPmqsBXw=", + "dev": true + }, + "lodash.create": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lodash.create/-/lodash.create-3.1.1.tgz", + "integrity": "sha1-1/KEnw29p+BGgruM1yqwIkYd6+c=", + "dev": true, + "requires": { + "lodash._baseassign": "^3.0.0", + "lodash._basecreate": "^3.0.0", + "lodash._isiterateecall": "^3.0.0" + } + }, + "lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo=", + "dev": true + }, + "lodash.isarray": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/lodash.isarray/-/lodash.isarray-3.0.4.tgz", + "integrity": "sha1-eeTriMNqgSKvhvhEqpvNhRtfu1U=", + "dev": true + }, + "lodash.keys": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-3.1.2.tgz", + "integrity": "sha1-TbwEcrFWvlCgsoaFXRvQsMZWCYo=", + "dev": true, + "requires": { + "lodash._getnative": "^3.0.0", + "lodash.isarguments": "^3.0.0", + "lodash.isarray": "^3.0.0" + } + }, + "log-driver": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/log-driver/-/log-driver-1.2.5.tgz", + "integrity": "sha1-euTsJXMC/XkNVXyxDJcQDYV7AFY=", + "dev": true + }, + "longest": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz", + "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=", + "dev": true + }, + "make-error": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.4.tgz", + "integrity": "sha512-0Dab5btKVPhibSalc9QGXb559ED7G7iLjFXBaj9Wq8O3vorueR5K5jaE3hkG6ZQINyhA/JgG6Qk4qdFQjsYV6g==" + }, + "mime-db": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", + "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==", + "dev": true + }, + "mime-types": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz", + "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", + "dev": true, + "requires": { + "mime-db": "~1.33.0" + } + }, + "mingo": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/mingo/-/mingo-2.2.2.tgz", + "integrity": "sha1-vmnUhq5uCsVLl53F9EEtshhR9pM=", + "dev": true + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", + "dev": true + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "dev": true, + "requires": { + "minimist": "0.0.8" + }, + "dependencies": { + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", + "dev": true + } + } + }, + "mocha": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-3.5.3.tgz", + "integrity": "sha512-/6na001MJWEtYxHOV1WLfsmR4YIynkUEhBwzsb+fk2qmQ3iqsi258l/Q2MWHJMImAcNpZ8DEdYAK72NHoIQ9Eg==", + "dev": true, + "requires": { + "browser-stdout": "1.3.0", + "commander": "2.9.0", + "debug": "2.6.8", + "diff": "3.2.0", + "escape-string-regexp": "1.0.5", + "glob": "7.1.1", + "growl": "1.9.2", + "he": "1.1.1", + "json3": "3.3.2", + "lodash.create": "3.1.1", + "mkdirp": "0.5.1", + "supports-color": "3.1.2" + }, + "dependencies": { + "commander": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.9.0.tgz", + "integrity": "sha1-nJkJQXbhIkDLItbFFGCYQA/g99Q=", + "dev": true, + "requires": { + "graceful-readlink": ">= 1.0.0" + } + }, + "glob": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.1.tgz", + "integrity": "sha1-gFIR3wT6rxxjo2ADBs31reULLsg=", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.2", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "supports-color": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.1.2.tgz", + "integrity": "sha1-cqJiiU2dQIuVbKBf83su2KbiotU=", + "dev": true, + "requires": { + "has-flag": "^1.0.0" + } + } + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + }, + "nopt": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz", + "integrity": "sha1-xkZdvwirzU2zWTF/eaxopkayj/k=", + "dev": true, + "requires": { + "abbrev": "1" + } + }, + "oauth-sign": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz", + "integrity": "sha1-Rqarfwrq2N6unsBWV4C31O/rnUM=", + "dev": true + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "optimist": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", + "integrity": "sha1-2j6nRob6IaGaERwybpDrFaAZZoY=", + "dev": true, + "requires": { + "minimist": "~0.0.1", + "wordwrap": "~0.0.2" + }, + "dependencies": { + "minimist": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz", + "integrity": "sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8=", + "dev": true + }, + "wordwrap": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", + "integrity": "sha1-o9XabNXAvAAI03I0u68b7WMFkQc=", + "dev": true + } + } + }, + "optionator": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.2.tgz", + "integrity": "sha1-NkxeQJ0/TWMB1sC0wFu6UBgK62Q=", + "dev": true, + "requires": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.4", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "wordwrap": "~1.0.0" + } + }, + "ot-json0": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ot-json0/-/ot-json0-1.1.0.tgz", + "integrity": "sha512-wf5fci7GGpMYRDnbbdIFQymvhsbFACMHtxjivQo5KgvAHlxekyfJ9aPsRr6YfFQthQkk4bmsl5yESrZwC/oMYQ==" + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true + }, + "pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=", + "dev": true + }, + "pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=", + "dev": true, + "requires": { + "pinkie": "^2.0.0" + } + }, + "prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=", + "dev": true + }, + "punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=", + "dev": true + }, + "qs": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.3.2.tgz", + "integrity": "sha1-51vV9uJoEioqDgvaYwslUMFmUCw=", + "dev": true + }, + "readable-stream": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", + "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=", + "dev": true + }, + "request": { + "version": "2.79.0", + "resolved": "https://registry.npmjs.org/request/-/request-2.79.0.tgz", + "integrity": "sha1-Tf5b9r6LjNw3/Pk+BLZVd3InEN4=", + "dev": true, + "requires": { + "aws-sign2": "~0.6.0", + "aws4": "^1.2.1", + "caseless": "~0.11.0", + "combined-stream": "~1.0.5", + "extend": "~3.0.0", + "forever-agent": "~0.6.1", + "form-data": "~2.1.1", + "har-validator": "~2.0.6", + "hawk": "~3.1.3", + "http-signature": "~1.1.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.7", + "oauth-sign": "~0.8.1", + "qs": "~6.3.0", + "stringstream": "~0.0.4", + "tough-cookie": "~2.3.0", + "tunnel-agent": "~0.4.1", + "uuid": "^3.0.0" + } + }, + "resolve": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.1.7.tgz", + "integrity": "sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs=", + "dev": true + }, + "right-align": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/right-align/-/right-align-0.1.3.tgz", + "integrity": "sha1-YTObci/mo1FWiSENJOFMlhSGE+8=", + "dev": true, + "optional": true, + "requires": { + "align-text": "^0.1.1" + } + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, + "sharedb": { + "version": "1.0.0-beta.9", + "resolved": "https://registry.npmjs.org/sharedb/-/sharedb-1.0.0-beta.9.tgz", + "integrity": "sha1-LX20J83hIJJNLasIzpLZq6134As=", + "dev": true, + "requires": { + "arraydiff": "^0.1.1", + "async": "^1.4.2", + "deep-is": "^0.1.3", + "hat": "0.0.3", + "make-error": "^1.1.1", + "ot-json0": "^1.0.1" + } + }, + "sharedb-mingo-memory": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/sharedb-mingo-memory/-/sharedb-mingo-memory-1.0.0.tgz", + "integrity": "sha1-vS5171YTCrheE5uMlMVSFv4+TQM=", + "dev": true, + "requires": { + "mingo": "^2.2.0", + "sharedb": "^1.0.0-beta" + } + }, + "shelljs": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.3.0.tgz", + "integrity": "sha1-NZbmMHp4FUT1kfN9phg2DzHbV7E=", + "dev": true + }, + "sntp": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/sntp/-/sntp-1.0.9.tgz", + "integrity": "sha1-ZUEYTMkK7qbG57NeJlkIJEPGYZg=", + "dev": true, + "requires": { + "hoek": "2.x.x" + } + }, + "source-map": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.2.0.tgz", + "integrity": "sha1-2rc/vPwrqBm03gO9b26qSBZLP50=", + "dev": true, + "optional": true, + "requires": { + "amdefine": ">=0.0.4" + } + }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", + "dev": true + }, + "sshpk": { + "version": "1.14.2", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.14.2.tgz", + "integrity": "sha1-xvxhZIo9nE52T9P8306hBeSSupg=", + "dev": true, + "requires": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", + "dev": true + } + } + }, + "string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=", + "dev": true + }, + "stringstream": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/stringstream/-/stringstream-0.0.6.tgz", + "integrity": "sha512-87GEBAkegbBcweToUrdzf3eLhWNg06FJTebl4BVJz/JgWy8CvEr9dRtX5qWphiynMSQlxxi+QqN0z5T32SLlhA==", + "dev": true + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "strip-json-comments": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-1.0.4.tgz", + "integrity": "sha1-HhX7ysl9Pumb8tc7TGVrCCu6+5E=", + "dev": true + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true + }, + "tough-cookie": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.4.tgz", + "integrity": "sha512-TZ6TTfI5NtZnuyy/Kecv+CnoROnyXn2DN97LontgQpCwsX2XyLYCC0ENhYkehSOwAp8rTQKc/NUIF7BkQ5rKLA==", + "dev": true, + "requires": { + "punycode": "^1.4.1" + } + }, + "tunnel-agent": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.4.3.tgz", + "integrity": "sha1-Y3PbdpCf5XDgjXNYM2Xtgop07us=", + "dev": true + }, + "tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", + "dev": true, + "optional": true + }, + "type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=", + "dev": true, + "requires": { + "prelude-ls": "~1.1.2" + } + }, + "uglify-js": { + "version": "2.8.29", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.8.29.tgz", + "integrity": "sha1-KcVzMUgFe7Th913zW3qcty5qWd0=", + "dev": true, + "optional": true, + "requires": { + "source-map": "~0.5.1", + "uglify-to-browserify": "~1.0.0", + "yargs": "~3.10.0" + }, + "dependencies": { + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true, + "optional": true + } + } + }, + "uglify-to-browserify": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz", + "integrity": "sha1-bgkk1r2mta/jSeOabWMoUKD4grc=", + "dev": true, + "optional": true + }, + "uuid": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.2.1.tgz", + "integrity": "sha512-jZnMwlb9Iku/O3smGWvZhauCf6cvvpKi4BKRiliS3cxnI+Gz9j5MEpTz2UFuXiKPJocb7gnsLHwiS05ige5BEA==", + "dev": true + }, + "verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", + "dev": true + } + } + }, + "which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "window-size": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz", + "integrity": "sha1-VDjNLqk7IC76Ohn+iIeu58lPnJ0=", + "dev": true, + "optional": true + }, + "wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=", + "dev": true + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true + }, + "xtend": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz", + "integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68=", + "dev": true + }, + "yargs": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.10.0.tgz", + "integrity": "sha1-9+572FfdfB0tOMDnTvvWgdFDH9E=", + "dev": true, + "optional": true, + "requires": { + "camelcase": "^1.0.2", + "cliui": "^2.1.0", + "decamelize": "^1.0.0", + "window-size": "0.1.0" + } + } + } +} diff --git a/test/client/number-type.js b/test/client/number-type.js new file mode 100644 index 000000000..d25401ebc --- /dev/null +++ b/test/client/number-type.js @@ -0,0 +1,23 @@ +// A simple number type, where: +// +// - snapshot is an integer +// - operation is an integer +exports.type = { + name: 'number-type', + uri: 'http://sharejs.org/types/number-type', + create: create, + apply: apply, + transform: transform +}; + +function create(data) { + return data | 0; +} + +function apply(snapshot, op) { + return snapshot + op; +} + +function transform(op1, op2, side) { + return op1; +} diff --git a/test/client/submit.js b/test/client/submit.js index 4e508e66e..b80abe0af 100644 --- a/test/client/submit.js +++ b/test/client/submit.js @@ -2,8 +2,10 @@ var async = require('async'); var expect = require('expect.js'); var types = require('../../lib/types'); var deserializedType = require('./deserialized-type'); +var numberType = require('./number-type'); types.register(deserializedType.type); types.register(deserializedType.type2); +types.register(numberType.type); module.exports = function() { describe('client submit', function() { @@ -1044,6 +1046,19 @@ describe('client submit', function() { }); }); + it('allows snapshot and op to be a non-object', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.create(5, numberType.type.uri, function (err) { + if (err) return done(err); + expect(doc.data).to.equal(5); + doc.submitOp(2, function(err) { + if (err) return done(err); + expect(doc.data).to.equal(7); + done(); + }); + }); + }); + describe('type.deserialize', function() { it('can create a new doc', function(done) { var doc = this.backend.connect().get('dogs', 'fido'); From 15cdd1d3d451a2b5dcbdadd9fb551c912975738d Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Thu, 12 Jul 2018 12:24:25 +0200 Subject: [PATCH 19/45] Update tested nodejs versions --- .travis.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 5c87e1e6d..21efafe46 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,6 @@ language: node_js node_js: - "10" - - "9" - "8" - "6" script: "npm run jshint && npm run test-cover" From cfca37ff92ecfe0cfba0de985e3651d4f3a51ec9 Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Thu, 12 Jul 2018 12:38:21 +0200 Subject: [PATCH 20/45] Make destroy wait for unsubscribe --- lib/client/doc.js | 11 ++++++++--- test/client/subscribe.js | 13 ++++++++++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/lib/client/doc.js b/lib/client/doc.js index 33621cb9c..796df6bd7 100644 --- a/lib/client/doc.js +++ b/lib/client/doc.js @@ -105,10 +105,15 @@ Doc.prototype.destroy = function(callback) { var doc = this; doc.whenNothingPending(function() { if (doc.wantSubscribe) { - doc.unsubscribe(); + doc.unsubscribe(function(err) { + if (!err) doc.connection._destroyDoc(doc); + if (callback) return callback(err); + if (err) this.emit('error', err); + }); + } else { + doc.connection._destroyDoc(doc); + if (callback) callback(); } - doc.connection._destroyDoc(doc); - if (callback) callback(); }); }; diff --git a/test/client/subscribe.js b/test/client/subscribe.js index db2bea1b2..b24a94749 100644 --- a/test/client/subscribe.js +++ b/test/client/subscribe.js @@ -414,7 +414,7 @@ describe('client subscribe', function() { doc2.subscribe(function(err) { if (err) return done(err); doc2.on('op', function(op, context) { - done(); + done(new Error('Should not get op event')); }); doc2.destroy(function(err) { if (err) return done(err); @@ -425,6 +425,17 @@ describe('client subscribe', function() { }); }); + it('doc destroy removes doc from connection when doc is not subscribed', function(done) { + var connection = this.backend.connect(); + var doc = connection.get('dogs', 'fido'); + expect(connection.getExisting('dogs', 'fido')).equal(doc); + doc.destroy(function(err) { + if (err) return done(err); + expect(connection.getExisting('dogs', 'fido')).equal(undefined); + done(); + }); + }); + it('bulk unsubscribe stops op updates', function(done) { var connection = this.backend.connect(); var connection2 = this.backend.connect(); From 5e009d17d0b6b665eb25abc0d538d1bc67e5dc6b Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Thu, 12 Jul 2018 12:52:59 +0200 Subject: [PATCH 21/45] Simplify the code --- lib/client/doc.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/client/doc.js b/lib/client/doc.js index 796df6bd7..d75e83085 100644 --- a/lib/client/doc.js +++ b/lib/client/doc.js @@ -106,9 +106,13 @@ Doc.prototype.destroy = function(callback) { doc.whenNothingPending(function() { if (doc.wantSubscribe) { doc.unsubscribe(function(err) { - if (!err) doc.connection._destroyDoc(doc); - if (callback) return callback(err); - if (err) this.emit('error', err); + if (err) { + if (callback) callback(err); + else this.emit('error', err); + return; + } + doc.connection._destroyDoc(doc); + if (callback) callback(); }); } else { doc.connection._destroyDoc(doc); From 56b726bd0f97fee321ecb363a993ee0e1700f65f Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Thu, 12 Jul 2018 14:30:09 +0200 Subject: [PATCH 22/45] Make hasPending depend on inflightPresence and pendingPresence --- lib/client/doc.js | 5 ++++- test/client/presence.js | 42 ++++++++++++++++++++++++++++++++--------- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/lib/client/doc.js b/lib/client/doc.js index 180f821d7..b07554353 100644 --- a/lib/client/doc.js +++ b/lib/client/doc.js @@ -260,7 +260,9 @@ Doc.prototype.hasPending = function() { this.inflightFetch.length || this.inflightSubscribe.length || this.inflightUnsubscribe.length || - this.pendingFetch.length + this.pendingFetch.length || + this.inflightPresence || + this.pendingPresence ); }; @@ -1077,6 +1079,7 @@ Doc.prototype._handlePresence = function(err, presence) { var called = callbacks && callEach(callbacks, err); if (err && !called) this.emit('error', err); this.flush(); + this._emitNothingPending(); } return; } diff --git a/test/client/presence.js b/test/client/presence.js index ca07c9b8b..4f9a3d31e 100644 --- a/test/client/presence.js +++ b/test/client/presence.js @@ -692,26 +692,50 @@ types.register(presenceType.type3); ], allDone); }); - it.skip('cancels pending presence on destroy', function(allDone) { + it('hasPending is true, if there is pending presence', function(allDone) { async.series([ this.doc.create.bind(this.doc, [ 'a' ], typeName), + this.doc.subscribe.bind(this.doc), + function(done) { + expect(this.doc.hasPending()).to.equal(false); + this.doc.submitPresence(p(0)); + expect(this.doc.hasPending()).to.equal(true); + expect(!!this.doc.pendingPresence).to.equal(true); + expect(!!this.doc.inflightPresence).to.equal(false); + this.doc.whenNothingPending(done); + }.bind(this), function(done) { - this.doc.submitPresence(p(0), done); - console.log(!!this.doc.inflightPresence, !!this.doc.pendingPresence); - this.doc.destroy(errorHandler(done)); + expect(this.doc.hasPending()).to.equal(false); + expect(!!this.doc.pendingPresence).to.equal(false); + expect(!!this.doc.inflightPresence).to.equal(false); + done(); }.bind(this) ], allDone); }); - it.skip('cancels inflight presence on destroy', function(allDone) { + it('hasPending is true, if there is inflight presence', function(allDone) { async.series([ this.doc.create.bind(this.doc, [ 'a' ], typeName), this.doc.subscribe.bind(this.doc), function(done) { - this.doc.submitPresence(p(0), done); - process.nextTick(function() { - this.doc.destroy(errorHandler(done)); - }.bind(this)); + expect(this.doc.hasPending()).to.equal(false); + this.doc.submitPresence(p(0)); + expect(this.doc.hasPending()).to.equal(true); + expect(!!this.doc.pendingPresence).to.equal(true); + expect(!!this.doc.inflightPresence).to.equal(false); + process.nextTick(done); + }.bind(this), + function(done) { + expect(this.doc.hasPending()).to.equal(true); + expect(!!this.doc.pendingPresence).to.equal(false); + expect(!!this.doc.inflightPresence).to.equal(true); + this.doc.whenNothingPending(done); + }.bind(this), + function(done) { + expect(this.doc.hasPending()).to.equal(false); + expect(!!this.doc.pendingPresence).to.equal(false); + expect(!!this.doc.inflightPresence).to.equal(false); + done(); }.bind(this) ], allDone); }); From efd6e6ee9f08f2a337f4e6bced5a5198504a4be2 Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Mon, 16 Jul 2018 16:58:19 +0200 Subject: [PATCH 23/45] Support skipNoop option --- README.md | 2 ++ lib/client/doc.js | 10 +++++++--- test/client/undo-redo.js | 26 ++++++++++++++++++++++---- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 2023a0908..48df450c8 100644 --- a/README.md +++ b/README.md @@ -279,6 +279,7 @@ Apply operation to document and send it to the server. [operations for the default `'ot-json0'` type](https://github.com/ottypes/json0#summary-of-operations). Call this after you've either fetched or subscribed to the document. * `options.source` Argument passed to the `'op'` event locally. This is not sent to the server or other clients. Defaults to `true`. +* `options.skipNoop` Should processing be skipped entirely, if `op` is a no-op. Defaults to `false`. * `options.undoable` Should it be possible to undo this operation, default=false. * `options.fixUpUndoStack` Determines how a non-undoable operation affects the undo stack. If `false` (default), the operation transforms the undo stack, otherwise it is inverted and composed into the last operation on the undo stack. * `options.fixUpRedoStack` Determines how a non-undoable operation affects the redo stack. If `false` (default), the operation transforms the redo stack, otherwise it is inverted and composed into the last operation on the redo stack. @@ -288,6 +289,7 @@ Diff the current and the provided snapshots to generate an operation, apply the `snapshot` structure depends on the document type. Call this after you've either fetched or subscribed to the document. * `options.source` Argument passed to the `'op'` event locally. This is not sent to the server or other clients. Defaults to `true`. +* `options.skipNoop` Should processing be skipped entirely, if the generated operation is a no-op. Defaults to `false`. * `options.undoable` Should it be possible to undo this operation, default=false. * `options.fixUpUndoStack` Determines how a non-undoable operation affects the undo stack. If `false` (default), the operation transforms the undo stack, otherwise it is inverted and composed into the last operation on the undo stack. * `options.fixUpRedoStack` Determines how a non-undoable operation affects the redo stack. If `false` (default), the operation transforms the redo stack, otherwise it is inverted and composed into the last operation on the redo stack. diff --git a/lib/client/doc.js b/lib/client/doc.js index c6b9fa5bb..117d1cb12 100644 --- a/lib/client/doc.js +++ b/lib/client/doc.js @@ -809,7 +809,7 @@ Doc.prototype._sendOp = function() { // @param [op.op] // @param [op.del] // @param [op.create] -// @param options { source, operationType, undoOp, fixUpUndoStack, fixUpRedoStack } +// @param options { source, skipNoop, operationType, undoOp, fixUpUndoStack, fixUpRedoStack } // @param [callback] called when operation is submitted Doc.prototype._submit = function(op, options, callback) { if (!options) options = {}; @@ -827,8 +827,8 @@ Doc.prototype._submit = function(op, options, callback) { } // Try to normalize the op. This removes trailing skip:0's and things like that. if (this.type.normalize) op.op = this.type.normalize(op.op); - // Try to skip processing empty operations. - if (this.type.isNoop && this.type.isNoop(op.op)) { + // Try to skip processing no-ops. + if (options.skipNoop && this.type.isNoop && this.type.isNoop(op.op)) { if (callback) process.nextTick(callback); return; } @@ -923,6 +923,7 @@ Doc.prototype._tryCompose = function(op) { // // @param component operation handled by the OT type // @param options.source passed into 'op' event handler +// @param options.skipNoop should processing be skipped entirely, if `component` is a no-op. // @param options.undoable should the operation be undoable // @param options.fixUpUndoStack Determines how non-undoable op affects undoStack. // If false (default), op transforms undoStack. @@ -941,6 +942,7 @@ Doc.prototype.submitOp = function(component, options, callback) { var op = {op: component}; var submitOptions = { source: options && options.source, + skipNoop: options && options.skipNoop, operationType: options && options.undoable ? OPERATION_TYPES.UNDOABLE : OPERATION_TYPES.FIXED, fixUpUndoStack: options && options.fixUpUndoStack, fixUpRedoStack: options && options.fixUpRedoStack @@ -956,6 +958,7 @@ Doc.prototype.submitOp = function(component, options, callback) { // // @param snapshot new snapshot data // @param options.source passed into 'op' event handler +// @param options.skipNoop should processing be skipped entirely, if the generated operation is a no-op. // @param options.undoable should the operation be undoable // @param options.fixUpUndoStack Determines how non-undoable op affects undoStack. // If false (default), op transforms undoStack. @@ -1001,6 +1004,7 @@ Doc.prototype.submitSnapshot = function(snapshot, options, callback) { var submitOptions = { source: options && options.source, + skipNoop: options && options.skipNoop, operationType: undoable ? OPERATION_TYPES.UNDOABLE : OPERATION_TYPES.FIXED, undoOp: undoOp, fixUpUndoStack: fixUpUndoStack, diff --git a/test/client/undo-redo.js b/test/client/undo-redo.js index 6ba6a9866..d45821ede 100644 --- a/test/client/undo-redo.js +++ b/test/client/undo-redo.js @@ -773,12 +773,30 @@ describe('client undo/redo', function() { expect(this.doc.data).to.equal(11111); }); + it('does not skip processing when submitting a no-op by default', function(done) { + this.doc.on('op', function() { + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('test') ]); + done(); + }.bind(this)); + this.doc.create([ otRichText.Action.createInsertText('test') ], otRichText.type.uri); + this.doc.submitOp([]); + }); + + it('does not skip processing when submitting an identical snapshot by default', function(done) { + this.doc.on('op', function() { + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('test') ]); + done(); + }.bind(this)); + this.doc.create([ otRichText.Action.createInsertText('test') ], otRichText.type.uri); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('test') ]); + }); + it('skips processing when submitting a no-op (no callback)', function(done) { this.doc.on('op', function() { done(new Error('Should not emit `op`')); }); this.doc.create([ otRichText.Action.createInsertText('test') ], otRichText.type.uri); - this.doc.submitOp([]); + this.doc.submitOp([], { skipNoop: true }); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('test') ]); done(); }); @@ -788,7 +806,7 @@ describe('client undo/redo', function() { done(new Error('Should not emit `op`')); }); this.doc.create([ otRichText.Action.createInsertText('test') ], otRichText.type.uri); - this.doc.submitOp([], done); + this.doc.submitOp([], { skipNoop: true }, done); }); it('skips processing when submitting an identical snapshot (no callback)', function(done) { @@ -796,7 +814,7 @@ describe('client undo/redo', function() { done(new Error('Should not emit `op`')); }); this.doc.create([ otRichText.Action.createInsertText('test') ], otRichText.type.uri); - this.doc.submitSnapshot([ otRichText.Action.createInsertText('test') ]); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('test') ], { skipNoop: true }); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('test') ]); done(); }); @@ -806,7 +824,7 @@ describe('client undo/redo', function() { done(new Error('Should not emit `op`')); }); this.doc.create([ otRichText.Action.createInsertText('test') ], otRichText.type.uri); - this.doc.submitSnapshot([ otRichText.Action.createInsertText('test') ], done); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('test') ], { skipNoop: true }, done); }); describe('operationType', function() { From d5f02251cd154c884a134e36a0c52f6e896e54b1 Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Tue, 17 Jul 2018 12:26:03 +0200 Subject: [PATCH 24/45] Fails fast if type in not invertible --- README.md | 5 +- lib/client/doc.js | 39 +++++++------- test/client/undo-redo.js | 108 +++++++++++++++++++++++++++++++++------ 3 files changed, 115 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 48df450c8..a61f38968 100644 --- a/README.md +++ b/README.md @@ -251,8 +251,8 @@ same time as callbacks to `fetch` and `subscribe`. `doc.on('create', function(source) {...})` The document was created. Technically, this means it has a type. `source` will be `false` for ops received from the server and defaults to `true` for ops generated locally. -`doc.on('before op'), function(op, source) {...})` -An operation is about to be applied to the data. `source` will be `false` for ops received from the server and defaults to `true` for ops generated locally. +`doc.on('before op'), function(op, source, operationType) {...})` +An operation is about to be applied to the data. Params are the same as for the `op` event below. `doc.on('op', function(op, source, operationType) {...})` An operation was applied to the data. `source` will be `false` for ops received from the server and defaults to `true` for ops generated locally. `operationType` is one of the following: `"UNDOABLE"` _(local operation that can be undone)_, `"FIXED"` _(local or remote operation that can't be undone nor redone)_, `"UNDO"` _(local undo operation that can be redone)_ and `"REDO"` _(local redo operation that can be undone)_. @@ -398,6 +398,7 @@ Additional fields may be added to the error object for debugging context dependi * 4022 - Database adapter does not support queries * 4023 - Cannot project snapshots of this type * 4024 - OT Type does not support `diff` nor `diffX` +* 4025 - OT Type does not support `invert` nor `applyAndInvert` ### 5000 - Internal error diff --git a/lib/client/doc.js b/lib/client/doc.js index 117d1cb12..e839ee4af 100644 --- a/lib/client/doc.js +++ b/lib/client/doc.js @@ -528,6 +528,10 @@ Doc.prototype._otApply = function(op, options) { var err = new ShareDBError(4015, 'Cannot apply op to uncreated document. ' + this.collection + '.' + this.id); return this.emit('error', err); } + var undoOp = options && options.undoOp || null; + var operationType = options && options.operationType || OPERATION_TYPES.FIXED; + var fixUpUndoStack = options && options.fixUpUndoStack || false; + var fixUpRedoStack = options && options.fixUpRedoStack || false; // Iteratively apply multi-component remote operations and rollback ops // (source === false) for the default JSON0 OT type. It could use @@ -558,9 +562,9 @@ Doc.prototype._otApply = function(op, options) { if (transformErr) return this._hardRollback(transformErr); } // Apply the individual op component - this.emit('before op', componentOp.op, source); - this._applyOp(componentOp, options); - this.emit('op', componentOp.op, source, 'FIXED'); + this.emit('before op', componentOp.op, source, operationType); + this._applyOp(componentOp, undoOp, operationType, fixUpUndoStack, fixUpRedoStack); + this.emit('op', componentOp.op, source, operationType); } // Pop whatever was submitted since we started applying this op this._popApplyStack(stackLength); @@ -569,9 +573,9 @@ Doc.prototype._otApply = function(op, options) { // The 'before op' event enables clients to pull any necessary data out of // the snapshot before it gets changed - this.emit('before op', op.op, source); + this.emit('before op', op.op, source, operationType); // Apply the operation to the local data, mutating it in place - var operationType = this._applyOp(op, options); + this._applyOp(op, undoOp, operationType, fixUpUndoStack, fixUpRedoStack); // Emit an 'op' event once the local data includes the changes from the // op. For locally submitted ops, this will be synchronously with // submission and before the server or other clients have received the op. @@ -603,11 +607,7 @@ Doc.prototype._otApply = function(op, options) { }; // Applies `op` to `this.data` and updates the undo/redo stacks. -Doc.prototype._applyOp = function(op, options) { - var undoOp = options && options.undoOp || null; - var operationType = options && options.operationType || OPERATION_TYPES.FIXED; - var fixUpUndoStack = options && options.fixUpUndoStack; - var fixUpRedoStack = options && options.fixUpRedoStack; +Doc.prototype._applyOp = function(op, undoOp, operationType, fixUpUndoStack, fixUpRedoStack) { var needsUndoOp = operationType !== OPERATION_TYPES.FIXED || fixUpUndoStack || fixUpRedoStack; if (needsUndoOp && undoOp == null) { @@ -615,16 +615,10 @@ Doc.prototype._applyOp = function(op, options) { var result = this.type.applyAndInvert(this.data, op.op); this.data = result[0]; undoOp = { op: result[1] }; - - } else if (this.type.invert) { - this.data = this.type.apply(this.data, op.op); - undoOp = { op: this.type.invert(op.op) }; - } else { this.data = this.type.apply(this.data, op.op); - operationType = OPERATION_TYPES.FIXED; + undoOp = { op: this.type.invert(op.op) }; } - } else { this.data = this.type.apply(this.data, op.op); } @@ -643,8 +637,6 @@ Doc.prototype._applyOp = function(op, options) { this._updateStacksFixed(op, undoOp, fixUpUndoStack, fixUpRedoStack); break; }; - - return operationType; }; Doc.prototype._clearUndoRedo = function() { @@ -825,6 +817,15 @@ Doc.prototype._submit = function(op, options, callback) { if (callback) return callback(err); return this.emit('error', err); } + var notFixedOperation = options.operationType !== OPERATION_TYPES.FIXED; + var fixUpUndoStack = options.fixUpUndoStack; + var fixUpRedoStack = options.fixUpRedoStack; + var needsInvert = notFixedOperation || fixUpUndoStack || fixUpRedoStack; + if (needsInvert && !this.type.invert && !this.type.applyAndInvert) { + var err = new ShareDBError(4025, 'Cannot submit op. OT type does not support invert not applyAndInvert. ' + this.collection + '.' + this.id); + if (callback) return callback(err); + return this.emit('error', err); + } // Try to normalize the op. This removes trailing skip:0's and things like that. if (this.type.normalize) op.op = this.type.normalize(op.op); // Try to skip processing no-ops. diff --git a/test/client/undo-redo.js b/test/client/undo-redo.js index d45821ede..b98a62a58 100644 --- a/test/client/undo-redo.js +++ b/test/client/undo-redo.js @@ -7,9 +7,11 @@ var expect = require('expect.js'); var types = require('../../lib/types'); var otText = require('ot-text'); var otRichText = require('@teamwork/ot-rich-text'); +var richText = require('rich-text'); var invertibleType = require('./invertible-type'); types.register(otText.type); +types.register(richText.type); types.register(otRichText.type); types.register(invertibleType.type); types.register(invertibleType.typeWithDiff); @@ -397,16 +399,58 @@ describe('client undo/redo', function() { expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('onetwo') ]); }); - it('does not add an undo level, if type is not invertible', function() { + it('fails to submit undoable op, if type is not invertible (callback)', function(done) { this.doc.create('two', otText.type.uri); + this.doc.on('error', done); + this.doc.submitOp([ 'one' ], { undoable: true }, function(err) { + expect(err.code).to.equal(4025); + done(); + }); + }); + + it('fails to submit undoable op, if type is not invertible (no callback)', function(done) { + this.doc.create('two', otText.type.uri); + this.doc.on('error', function(err) { + expect(err.code).to.equal(4025); + done(); + }); this.doc.submitOp([ 'one' ], { undoable: true }); - expect(this.doc.data).to.eql('onetwo'); - expect(this.doc.canUndo()).to.equal(false); - expect(this.doc.canRedo()).to.equal(false); - this.doc.undo(); - expect(this.doc.data).to.eql('onetwo'); - expect(this.doc.canUndo()).to.equal(false); - expect(this.doc.canRedo()).to.equal(false); + }); + + it('fails to submit undoable snapshot, if type is not invertible (callback)', function(done) { + this.doc.create([], richText.type.uri); + this.doc.on('error', done); + this.doc.submitSnapshot([ { insert: 'abc' } ], { undoable: true }, function(err) { + expect(err.code).to.equal(4025); + done(); + }); + }); + + it('fails to submit undoable snapshot, if type is not invertible (no callback)', function(done) { + this.doc.create([], richText.type.uri); + this.doc.on('error', function(err) { + expect(err.code).to.equal(4025); + done(); + }); + this.doc.submitSnapshot([ { insert: 'abc' } ], { undoable: true }); + }); + + it('fails to submit with fixUpUndoStack, if type is not invertible', function(done) { + this.doc.create('two', otText.type.uri); + this.doc.on('error', done); + this.doc.submitOp([ 'one' ], { fixUpUndoStack: true }, function(err) { + expect(err.code).to.equal(4025); + done(); + }); + }); + + it('fails to submit with fixUpRedoStack, if type is not invertible', function(done) { + this.doc.create('two', otText.type.uri); + this.doc.on('error', done); + this.doc.submitOp([ 'one' ], { fixUpRedoStack: true }, function(err) { + expect(err.code).to.equal(4025); + done(); + }); }); it('composes similar operations', function() { @@ -829,8 +873,15 @@ describe('client undo/redo', function() { describe('operationType', function() { it('reports UNDOABLE operationType', function(done) { + var beforeOpCalled = false; this.doc.create({ test: 5 }); + this.doc.on('before op', function(op, source, operationType) { + expect(source).to.equal(true); + expect(operationType).to.equal('UNDOABLE'); + beforeOpCalled = true; + }); this.doc.on('op', function(op, source, operationType) { + expect(beforeOpCalled).to.equal(true); expect(source).to.equal(true); expect(operationType).to.equal('UNDOABLE'); done(); @@ -839,9 +890,16 @@ describe('client undo/redo', function() { }); it('reports UNDO operationType', function(done) { + var beforeOpCalled = false; this.doc.create({ test: 5 }); this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + this.doc.on('before op', function(op, source, operationType) { + expect(source).to.equal(true); + expect(operationType).to.equal('UNDO'); + beforeOpCalled = true; + }); this.doc.on('op', function(op, source, operationType) { + expect(beforeOpCalled).to.equal(true); expect(source).to.equal(true); expect(operationType).to.equal('UNDO'); done(); @@ -850,10 +908,17 @@ describe('client undo/redo', function() { }); it('reports REDO operationType', function(done) { + var beforeOpCalled = false; this.doc.create({ test: 5 }); this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); this.doc.undo(); + this.doc.on('before op', function(op, source, operationType) { + expect(source).to.equal(true); + expect(operationType).to.equal('REDO'); + beforeOpCalled = true; + }); this.doc.on('op', function(op, source, operationType) { + expect(beforeOpCalled).to.equal(true); expect(source).to.equal(true); expect(operationType).to.equal('REDO'); done(); @@ -862,28 +927,32 @@ describe('client undo/redo', function() { }); it('reports FIXED operationType (local operation, undoable=false)', function(done) { + var beforeOpCalled = false; this.doc.create({ test: 5 }); - this.doc.on('op', function(op, source, operationType) { + this.doc.on('before op', function(op, source, operationType) { expect(source).to.equal(true); expect(operationType).to.equal('FIXED'); - done(); + beforeOpCalled = true; }); - this.doc.submitOp([ { p: [ 'test' ], na: 2 } ]); - }); - - it('reports FIXED operationType (local operation, undoable=true but type is not invertible)', function(done) { - this.doc.create('', otText.type.uri); this.doc.on('op', function(op, source, operationType) { + expect(beforeOpCalled).to.equal(true); expect(source).to.equal(true); expect(operationType).to.equal('FIXED'); done(); }); - this.doc.submitOp([ 'test' ], { undoable: true }); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ]); }); it('reports FIXED operationType (remote operation, undoable=false)', function(done) { + var beforeOpCalled = false; this.doc.subscribe(); + this.doc.on('before op', function(op, source, operationType) { + expect(source).to.equal(false); + expect(operationType).to.equal('FIXED'); + beforeOpCalled = true; + }); this.doc.on('op', function(op, source, operationType) { + expect(beforeOpCalled).to.equal(true); expect(source).to.equal(false); expect(operationType).to.equal('FIXED'); done(); @@ -894,8 +963,15 @@ describe('client undo/redo', function() { }); it('reports FIXED operationType (remote operation, undoable=true)', function(done) { + var beforeOpCalled = false; this.doc.subscribe(); + this.doc.on('before op', function(op, source, operationType) { + expect(source).to.equal(false); + expect(operationType).to.equal('FIXED'); + beforeOpCalled = true; + }); this.doc.on('op', function(op, source, operationType) { + expect(beforeOpCalled).to.equal(true); expect(source).to.equal(false); expect(operationType).to.equal('FIXED'); done(); From ade98a4a507088a10b478ef88a38e36b771607ab Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Wed, 18 Jul 2018 13:12:13 +0200 Subject: [PATCH 25/45] Implement UndoManager (WIP) --- lib/client/connection.js | 41 ++ lib/client/doc.js | 278 ++-------- lib/client/undoManager.js | 273 ++++++++++ package-lock.json | 164 ++---- package.json | 3 +- test/client/submit.js | 11 +- test/client/undo-redo.js | 1037 +++++++++++++++---------------------- 7 files changed, 817 insertions(+), 990 deletions(-) create mode 100644 lib/client/undoManager.js diff --git a/lib/client/connection.js b/lib/client/connection.js index f4cc298e6..97b8c00ba 100644 --- a/lib/client/connection.js +++ b/lib/client/connection.js @@ -1,5 +1,6 @@ var Doc = require('./doc'); var Query = require('./query'); +var UndoManager = require('./undoManager'); var emitter = require('../emitter'); var ShareDBError = require('../error'); var types = require('../types'); @@ -33,6 +34,9 @@ function Connection(socket) { // (created documents MUST BE UNIQUE) this.collections = {}; + // A list of active UndoManagers. + this.undoManagers = []; + // Each query is created with an id that the server uses when it sends us // info about the query (updates, etc) this.nextQueryId = 1; @@ -584,3 +588,40 @@ Connection.prototype._firstQuery = function(fn) { } } }; + +Connection.prototype.undoManager = function(options) { + var undoManager = new UndoManager(this, options); + this.undoManagers.push(undoManager); + return undoManager; +}; + +Connection.prototype.removeUndoManager = function(undoManager) { + var index = this.undoManagers.indexOf(undoManager); + if (index >= 0) { + this.undoManagers.splice(index, 1); + } +}; + +Connection.prototype.onDocLoad = function(doc) { + for (var i = 0; i < this.undoManagers.length; i++) { + this.undoManagers[i].onDocLoad(doc); + } +}; + +Connection.prototype.onDocCreate = function(doc) { + for (var i = 0; i < this.undoManagers.length; i++) { + this.undoManagers[i].onDocCreate(doc); + } +}; + +Connection.prototype.onDocDelete = function(doc) { + for (var i = 0; i < this.undoManagers.length; i++) { + this.undoManagers[i].onDocDelete(doc); + } +}; + +Connection.prototype.onDocOp = function(doc, op, undoOp, source, undoable, fixUp) { + for (var i = 0; i < this.undoManagers.length; i++) { + this.undoManagers[i].onDocOp(doc, op, undoOp, source, undoable, fixUp); + } +}; diff --git a/lib/client/doc.js b/lib/client/doc.js index e839ee4af..0915e9f8b 100644 --- a/lib/client/doc.js +++ b/lib/client/doc.js @@ -44,13 +44,6 @@ var types = require('../types'); * - `load ()` Fired when a new snapshot is ingested from a fetch, subscribe, or query */ -var OPERATION_TYPES = { - UNDOABLE: 'UNDOABLE', // basic operation that can be undone - FIXED: 'FIXED', // basic operation that cannot be undone - UNDO: 'UNDO', // undo operation - REDO: 'REDO' // redo operation -}; - module.exports = Doc; function Doc(connection, collection, id) { emitter.EventEmitter.call(this); @@ -64,19 +57,6 @@ function Doc(connection, collection, id) { this.type = null; this.data = undefined; - // Undo stack for local operations. - this.undoStack = []; - // Redo stack for local operations. - this.redoStack = []; - // The max number of undo operations to keep on the stack. - this.undoLimit = 100; - // The max time difference between operations in milliseconds, - // which still allows the operations to be composed on the undoStack. - this.undoComposeTimeout = 1000; - // The timestamp of the previous reversible operation. Used to determine if - // the next reversible operation can be composed on the undoStack. - this.previousUndoableOperationTime = -Infinity; - // Array of callbacks or nulls as placeholders this.inflightFetch = []; this.inflightSubscribe = []; @@ -211,7 +191,7 @@ Doc.prototype.ingestSnapshot = function(snapshot, callback) { this.data = (this.type && this.type.deserialize) ? this.type.deserialize(snapshot.data) : snapshot.data; - this._clearUndoRedo(); + this.connection.onDocLoad(this); this.emit('load'); callback && callback(); }; @@ -529,9 +509,8 @@ Doc.prototype._otApply = function(op, options) { return this.emit('error', err); } var undoOp = options && options.undoOp || null; - var operationType = options && options.operationType || OPERATION_TYPES.FIXED; - var fixUpUndoStack = options && options.fixUpUndoStack || false; - var fixUpRedoStack = options && options.fixUpRedoStack || false; + var undoable = options && options.undoable || false; + var fixUp = options && options.fixUp || false; // Iteratively apply multi-component remote operations and rollback ops // (source === false) for the default JSON0 OT type. It could use @@ -562,9 +541,9 @@ Doc.prototype._otApply = function(op, options) { if (transformErr) return this._hardRollback(transformErr); } // Apply the individual op component - this.emit('before op', componentOp.op, source, operationType); - this._applyOp(componentOp, undoOp, operationType, fixUpUndoStack, fixUpRedoStack); - this.emit('op', componentOp.op, source, operationType); + this.emit('before op', componentOp.op, source); + this._applyOp(componentOp, undoOp, source, undoable, fixUp); + this.emit('op', componentOp.op, source); } // Pop whatever was submitted since we started applying this op this._popApplyStack(stackLength); @@ -573,15 +552,15 @@ Doc.prototype._otApply = function(op, options) { // The 'before op' event enables clients to pull any necessary data out of // the snapshot before it gets changed - this.emit('before op', op.op, source, operationType); + this.emit('before op', op.op, source); // Apply the operation to the local data, mutating it in place - this._applyOp(op, undoOp, operationType, fixUpUndoStack, fixUpRedoStack); + this._applyOp(op, undoOp, source, undoable, fixUp); // Emit an 'op' event once the local data includes the changes from the // op. For locally submitted ops, this will be synchronously with // submission and before the server or other clients have received the op. // For ops from other clients, this will be after the op has been // committed to the database and published - this.emit('op', op.op, source, operationType); + this.emit('op', op.op, source); return; } @@ -592,7 +571,7 @@ Doc.prototype._otApply = function(op, options) { this.type.createDeserialized(op.create.data) : this.type.deserialize(this.type.create(op.create.data)) : this.type.create(op.create.data); - this._clearUndoRedo(); + this.connection.onDocCreate(this); this.emit('create', source); return; } @@ -600,17 +579,15 @@ Doc.prototype._otApply = function(op, options) { if (op.del) { var oldData = this.data; this._setType(null); - this._clearUndoRedo(); + this.connection.onDocDelete(this); this.emit('del', oldData, source); return; } }; // Applies `op` to `this.data` and updates the undo/redo stacks. -Doc.prototype._applyOp = function(op, undoOp, operationType, fixUpUndoStack, fixUpRedoStack) { - var needsUndoOp = operationType !== OPERATION_TYPES.FIXED || fixUpUndoStack || fixUpRedoStack; - - if (needsUndoOp && undoOp == null) { +Doc.prototype._applyOp = function(op, undoOp, source, undoable, fixUp) { + if (undoOp == null && (undoable || fixUp)) { if (this.type.applyAndInvert) { var result = this.type.applyAndInvert(this.data, op.op); this.data = result[0]; @@ -623,131 +600,7 @@ Doc.prototype._applyOp = function(op, undoOp, operationType, fixUpUndoStack, fix this.data = this.type.apply(this.data, op.op); } - switch (operationType) { - case OPERATION_TYPES.UNDOABLE: - this._updateStacksUndoable(op, undoOp); - break; - case OPERATION_TYPES.UNDO: - this._updateStacksUndo(op, undoOp); - break; - case OPERATION_TYPES.REDO: - this._updateStacksRedo(op, undoOp); - break; - default: - this._updateStacksFixed(op, undoOp, fixUpUndoStack, fixUpRedoStack); - break; - }; -}; - -Doc.prototype._clearUndoRedo = function() { - this.undoStack.length = 0; - this.redoStack.length = 0; - this.previousUndoableOperationTime = -Infinity; -}; - -Doc.prototype._updateStacksUndoable = function(op, undoOp) { - var now = Date.now(); - - if (this.undoStack.length === 0 || now - this.previousUndoableOperationTime > this.undoComposeTimeout) { - this.undoStack.push(undoOp); - - } else if (this.type.composeSimilar) { - var lastOp = this.undoStack.pop(); - var composedOp = this.type.composeSimilar(undoOp.op, lastOp.op); - if (composedOp != null) { - this.undoStack.push({ op: composedOp }); - } else { - this.undoStack.push(lastOp, undoOp); - } - - } else if (this.type.compose) { - var lastOp = this.undoStack.pop(); - var composedOp = this.type.compose(undoOp.op, lastOp.op); - this.undoStack.push({ op: composedOp }); - - } else { - this.undoStack.push(undoOp); - } - - this.redoStack.length = 0; - this.previousUndoableOperationTime = now; - - var isNoop = this.type.isNoop; - if (isNoop && isNoop(this.undoStack[this.undoStack.length - 1].op)) { - this.undoStack.pop(); - } - - var itemsToRemove = this.undoStack.length - this.undoLimit; - if (itemsToRemove > 0) { - this.undoStack.splice(0, itemsToRemove); - } -}; - -Doc.prototype._updateStacksUndo = function(op, undoOp) { - if (!this.type.isNoop || !this.type.isNoop(undoOp.op)) { - this.redoStack.push(undoOp); - } - this.previousUndoableOperationTime = -Infinity; -}; - -Doc.prototype._updateStacksRedo = function(op, undoOp) { - if (!this.type.isNoop || !this.type.isNoop(undoOp.op)) { - this.undoStack.push(undoOp); - } - this.previousUndoableOperationTime = -Infinity; -}; - -Doc.prototype._updateStacksFixed = function(op, undoOp, fixUpUndoStack, fixUpRedoStack) { - if (fixUpUndoStack && this.undoStack.length > 0 && this.type.compose && undoOp) { - var lastOp = this.undoStack.pop(); - var composedOp = this.type.compose(undoOp.op, lastOp.op); - if (!this.type.isNoop || !this.type.isNoop(composedOp)) { - this.undoStack.push({ op: composedOp }); - } - } else { - this.undoStack = this._transformStack(this.undoStack, op.op); - } - - if (fixUpRedoStack && this.redoStack.length > 0 && this.type.compose && undoOp) { - var lastOp = this.redoStack.pop(); - var composedOp = this.type.compose(undoOp.op, lastOp.op); - if (!this.type.isNoop || !this.type.isNoop(composedOp)) { - this.redoStack.push({ op: composedOp }); - } - } else { - this.redoStack = this._transformStack(this.redoStack, op.op); - } -}; - -Doc.prototype._transformStack = function(stack, op) { - var transform = this.type.transform; - var transformX = this.type.transformX; - var isNoop = this.type.isNoop; - var newStack = []; - var newStackIndex = 0; - - for (var i = stack.length - 1; i >= 0; --i) { - var stackOp = stack[i].op; - var transformedStackOp; - var transformedOp; - - if (transformX) { - var result = transformX(op, stackOp); - transformedOp = result[0]; - transformedStackOp = result[1]; - } else { - transformedOp = transform(op, stackOp, 'left'); - transformedStackOp = transform(stackOp, op, 'right'); - } - - if (!isNoop || !isNoop(transformedStackOp)) { - newStack[newStackIndex++] = { op: transformedStackOp }; - } - - op = transformedOp; - } - - return newStack.reverse(); + this.connection.onDocOp(this, op, undoOp, source, undoable, fixUp); }; // ***** Sending operations @@ -801,11 +654,10 @@ Doc.prototype._sendOp = function() { // @param [op.op] // @param [op.del] // @param [op.create] -// @param options { source, skipNoop, operationType, undoOp, fixUpUndoStack, fixUpRedoStack } +// @param options { source, skipNoop, undoable, undoOp, fixUp } // @param [callback] called when operation is submitted Doc.prototype._submit = function(op, options, callback) { if (!options) options = {}; - if (!options.operationType) options.operationType = OPERATION_TYPES.FIXED; // Locally submitted ops must always have a truthy source if (!options.source) options.source = true; @@ -817,11 +669,10 @@ Doc.prototype._submit = function(op, options, callback) { if (callback) return callback(err); return this.emit('error', err); } - var notFixedOperation = options.operationType !== OPERATION_TYPES.FIXED; - var fixUpUndoStack = options.fixUpUndoStack; - var fixUpRedoStack = options.fixUpRedoStack; - var needsInvert = notFixedOperation || fixUpUndoStack || fixUpRedoStack; - if (needsInvert && !this.type.invert && !this.type.applyAndInvert) { + var undoable = options && options.undoable; + var fixUp = options && options.fixUp; + var needsUndoOp = undoable || fixUp; + if (needsUndoOp && !this.type.invert && !this.type.applyAndInvert) { var err = new ShareDBError(4025, 'Cannot submit op. OT type does not support invert not applyAndInvert. ' + this.collection + '.' + this.id); if (callback) return callback(err); return this.emit('error', err); @@ -926,12 +777,9 @@ Doc.prototype._tryCompose = function(op) { // @param options.source passed into 'op' event handler // @param options.skipNoop should processing be skipped entirely, if `component` is a no-op. // @param options.undoable should the operation be undoable -// @param options.fixUpUndoStack Determines how non-undoable op affects undoStack. -// If false (default), op transforms undoStack. -// If true, op is inverted and composed into the last operation on the undoStack. -// @param options.fixUpRedoStack Determines how non-undoable op affects redoStack. -// If false (default), op transforms redoStack. -// If true, op is inverted and composed into the last operation on the redoStack. +// @param options.fixUp If true, this operation is meant to fix the current invalid state of the snapshot. +// It also updates UndoManagers in such a way that undo/redo will "skip" over the current state of the snapshot. +// This feature requires the OT type to implement `compose`. // @param [callback] called after operation submitted // // @fires before op, op @@ -944,9 +792,8 @@ Doc.prototype.submitOp = function(component, options, callback) { var submitOptions = { source: options && options.source, skipNoop: options && options.skipNoop, - operationType: options && options.undoable ? OPERATION_TYPES.UNDOABLE : OPERATION_TYPES.FIXED, - fixUpUndoStack: options && options.fixUpUndoStack, - fixUpRedoStack: options && options.fixUpRedoStack + undoable: options && options.undoable, + fixUp: options && options.fixUp }; this._submit(op, submitOptions, callback); }; @@ -961,12 +808,9 @@ Doc.prototype.submitOp = function(component, options, callback) { // @param options.source passed into 'op' event handler // @param options.skipNoop should processing be skipped entirely, if the generated operation is a no-op. // @param options.undoable should the operation be undoable -// @param options.fixUpUndoStack Determines how non-undoable op affects undoStack. -// If false (default), op transforms undoStack. -// If true, op is inverted and composed into the last operation on the undoStack. -// @param options.fixUpRedoStack Determines how non-undoable op affects redoStack. -// If false (default), op transforms redoStack. -// If true, op is inverted and composed into the last operation on the redoStack. +// @param options.fixUp If true, this operation is meant to fix the current invalid state of the snapshot. +// It also updates UndoManagers in such a way that undo/redo will "skip" over the current state of the snapshot. +// This feature requires the OT type to implement `compose`. // @param options.diffHint a hint passed into diff/diffX // @param [callback] called after operation submitted @@ -988,10 +832,9 @@ Doc.prototype.submitSnapshot = function(snapshot, options, callback) { } var undoable = !!(options && options.undoable); - var fixUpUndoStack = options && options.fixUpUndoStack; - var fixUpRedoStack = options && options.fixUpRedoStack; + var fixUp = options && options.fixUp; var diffHint = options && options.diffHint; - var needsUndoOp = undoable || fixUpUndoStack || fixUpRedoStack; + var needsUndoOp = undoable || fixUp; var op, undoOp; if ((needsUndoOp && this.type.diffX) || !this.type.diff) { @@ -1006,68 +849,9 @@ Doc.prototype.submitSnapshot = function(snapshot, options, callback) { var submitOptions = { source: options && options.source, skipNoop: options && options.skipNoop, - operationType: undoable ? OPERATION_TYPES.UNDOABLE : OPERATION_TYPES.FIXED, + undoable: undoable, undoOp: undoOp, - fixUpUndoStack: fixUpUndoStack, - fixUpRedoStack: fixUpRedoStack - }; - this._submit(op, submitOptions, callback); -}; - -// Returns true, if there are any operations on the undo stack, otherwise false. -Doc.prototype.canUndo = function() { - return this.undoStack.length > 0 -}; - -// Undoes a submitted operation. -// -// @param options {source: ...} -// @param [callback] called after operation submitted -// @fires before op, op -Doc.prototype.undo = function(options, callback) { - if (typeof options === 'function') { - callback = options; - options = null; - } - - if (!this.canUndo()) { - if (callback) process.nextTick(callback); - return; - } - - var op = this.undoStack.pop(); - var submitOptions = { - source: options && options.source, - operationType: OPERATION_TYPES.UNDO - }; - this._submit(op, submitOptions, callback); -}; - -// Returns true, if there are any operations on the redo stack, otherwise false. -Doc.prototype.canRedo = function() { - return this.redoStack.length > 0 -}; - -// Redoes an undone operation. -// -// @param options {source:...} -// @param [callback] called after operation submitted -// @fires before op, op -Doc.prototype.redo = function(options, callback) { - if (typeof options === 'function') { - callback = options; - options = null; - } - - if (!this.canRedo()) { - if (callback) process.nextTick(callback); - return; - } - - var op = this.redoStack.pop() - var submitOptions = { - source: options && options.source, - operationType: OPERATION_TYPES.REDO + fixUp: fixUp }; this._submit(op, submitOptions, callback); }; diff --git a/lib/client/undoManager.js b/lib/client/undoManager.js new file mode 100644 index 000000000..7f51218e2 --- /dev/null +++ b/lib/client/undoManager.js @@ -0,0 +1,273 @@ +function findLastIndex(stack, doc) { + var index = stack.length - 1; + while (index >= 0) { + if (stack[index].doc === doc) break; + index--; + } + return index; +} + +function getLast(list) { + var lastIndex = list.length - 1; + if (lastIndex < 0) throw new Error('List empty'); + return list[lastIndex]; +} + +function setLast(list, item) { + var lastIndex = list.length - 1; + if (lastIndex < 0) throw new Error('List empty'); + list[lastIndex] = item; +} + +function Item(op, doc) { + this.op = op; + this.doc = doc; +} + +// Manages an undo/redo stack for all operations from the specified `source`. +module.exports = UndoManager; +function UndoManager(connection, options) { + this.connection = connection; + + // The "source" value of undoable operations. If null or undefined, it works for all operations. + this._source = options && options.source; + + // The max number of undo operations to keep on the stack. + this._limit = options && typeof options.limit === 'number' ? options.limit : 100; + + // The max time difference between operations in milliseconds, + // which still allows the operations to be composed on the undoStack. + this._composeTimeout = options && typeof options.composeTimeout === 'number' ? options.composeTimeout : 1000; + + // Undo stack for local operations. + this._undoStack = []; + + // Redo stack for local operations. + this._redoStack = []; + + // The timestamp of the previous reversible operation. Used to determine if + // the next reversible operation can be composed on the undoStack. + this._previousUndoableOperationTime = -Infinity; + + // The type of operation that is currently in progress. + this._operationInProgress = null; +} + +UndoManager.prototype.destroy = function() { + this.connection.removeUndoManager(this); +}; + +UndoManager.prototype.clear = function() { + this._undoStack.length = 0; + this._redoStack.length = 0; + this._previousUndoableOperationTime = -Infinity; +}; + +// Returns true, if there are any operations on the undo stack, otherwise false. +UndoManager.prototype.canUndo = function() { + return this._undoStack.length > 0 +}; + +// Undoes a submitted operation. +// +// @param options {source: ...} +// @param [callback] called after operation submitted +// @fires before op, op +UndoManager.prototype.undo = function(options, callback) { + if (typeof options === 'function') { + callback = options; + options = null; + } + + if (!this.canUndo()) { + if (callback) process.nextTick(callback); + return; + } + + this._operationInProgress = 'undo'; + var op = this._undoStack.pop(); + var submitOptions = { + source: options && options.source, + undoable: true + }; + op.doc._submit(op, submitOptions, callback); +}; + +// Returns true, if there are any operations on the redo stack, otherwise false. +UndoManager.prototype.canRedo = function() { + return this._redoStack.length > 0; +}; + +// Redoes an undone operation. +// +// @param options {source: ...} +// @param [callback] called after operation submitted +// @fires before op, op +UndoManager.prototype.redo = function(options, callback) { + if (typeof options === 'function') { + callback = options; + options = null; + } + + if (!this.canRedo()) { + if (callback) process.nextTick(callback); + return; + } + + this._operationInProgress = 'redo'; + var op = this._redoStack.pop(); + var submitOptions = { + source: options && options.source, + undoable: true + }; + op.doc._submit(op, submitOptions, callback); +}; + +UndoManager.prototype.onDocLoad = function(doc) { + this.clear(); +}; + +UndoManager.prototype.onDocCreate = function(doc) { + this.clear(); +}; + +UndoManager.prototype.onDocDelete = function(doc) { + this.clear(); +}; + +UndoManager.prototype.onDocOp = function(doc, op, undoOp, source, undoable, fixUp) { + if (this._operationInProgress === 'undo') { + this._updateStacksUndo(doc, op, undoOp); + this._operationInProgress = null; + + } else if (this._operationInProgress === 'redo') { + this._updateStacksRedo(doc, op, undoOp); + this._operationInProgress = null; + + } else if (!fixUp && undoable && (this._source == null || this._source === source)) { + this._updateStacksUndoable(doc, op, undoOp); + + } else { + this._updateStacksFixed(doc, op, undoOp, fixUp); + } +}; + +UndoManager.prototype._updateStacksUndoable = function(doc, op, undoOp) { + var now = Date.now(); + + if ( + this._undoStack.length === 0 || + getLast(this._undoStack).doc !== doc || + now - this._previousUndoableOperationTime > this._composeTimeout + ) { + this._undoStack.push(new Item(undoOp.op, doc)); + + } else if (doc.type.composeSimilar) { + var lastOp = getLast(this._undoStack); + var composedOp = doc.type.composeSimilar(undoOp.op, lastOp.op); + if (composedOp != null) { + setLast(this._undoStack, new Item(composedOp, doc)); + } else { + this._undoStack.push(new Item(undoOp.op, doc)); + } + + } else if (doc.type.compose) { + var lastOp = getLast(this._undoStack); + var composedOp = doc.type.compose(undoOp.op, lastOp.op); + setLast(this._undoStack, new Item(composedOp, doc)); + + } else { + this._undoStack.push(new Item(undoOp.op, doc)); + } + + this._redoStack.length = 0; + this._previousUndoableOperationTime = now; + + var isNoop = doc.type.isNoop; + if (isNoop && isNoop(getLast(this._undoStack).op)) { + this._undoStack.pop(); + } + + var itemsToRemove = this._undoStack.length - this._limit; + if (itemsToRemove > 0) { + this._undoStack.splice(0, itemsToRemove); + } +}; + +UndoManager.prototype._updateStacksUndo = function(doc, op, undoOp) { + if (!doc.type.isNoop || !doc.type.isNoop(undoOp.op)) { + this._redoStack.push(new Item(undoOp.op, doc)); + } + this._previousUndoableOperationTime = -Infinity; +}; + +UndoManager.prototype._updateStacksRedo = function(doc, op, undoOp) { + if (!doc.type.isNoop || !doc.type.isNoop(undoOp.op)) { + this._undoStack.push(new Item(undoOp.op, doc)); + } + this._previousUndoableOperationTime = -Infinity; +}; + +UndoManager.prototype._updateStacksFixed = function(doc, op, undoOp, fixUp) { + var fixUpUndoStack = false; + var fixUpRedoStack = false; + + if (fixUp && undoOp && doc.type.compose) { + var lastUndoIndex = findLastIndex(this._undoStack, doc); + if (lastUndoIndex >= 0) { + var lastOp = this._undoStack[lastUndoIndex]; + var composedOp = doc.type.compose(undoOp.op, lastOp.op); + if (!doc.type.isNoop || !doc.type.isNoop(composedOp)) { + this._undoStack[lastUndoIndex] = new Item(composedOp, doc); + } else { + this._undoStack.splice(lastUndoIndex, 1); + } + } + + var lastRedoIndex = findLastIndex(this._redoStack, doc); + if (lastRedoIndex >= 0) { + var lastOp = this._redoStack[lastRedoIndex]; + var composedOp = doc.type.compose(undoOp.op, lastOp.op); + if (!doc.type.isNoop || !doc.type.isNoop(composedOp)) { + this._redoStack[lastRedoIndex] = new Item(composedOp, doc); + } else { + this._redoStack.splice(lastRedoIndex, 1); + } + } + + } else { + this._undoStack = this._transformStack(this._undoStack, doc, op.op); + this._redoStack = this._transformStack(this._redoStack, doc, op.op); + } +}; + +UndoManager.prototype._transformStack = function(stack, doc, op) { + var transform = doc.type.transform; + var transformX = doc.type.transformX; + var isNoop = doc.type.isNoop; + var newStack = []; + var newStackIndex = 0; + + for (var i = stack.length - 1; i >= 0; --i) { + var stackOp = stack[i].op; + var transformedStackOp; + var transformedOp; + + if (transformX) { + var result = transformX(op, stackOp); + transformedOp = result[0]; + transformedStackOp = result[1]; + } else { + transformedOp = transform(op, stackOp, 'left'); + transformedStackOp = transform(stackOp, op, 'right'); + } + + if (!isNoop || !isNoop(transformedStackOp)) { + newStack[newStackIndex++] = new Item(transformedStackOp, doc); + } + + op = transformedOp; + } + + return newStack.reverse(); +}; diff --git a/package-lock.json b/package-lock.json index 0af5c9b23..027e6b09c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -133,9 +133,9 @@ } }, "browser-stdout": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.0.tgz", - "integrity": "sha1-81HTKWnTL6XXpVZxVCY9korjvR8=", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", "dev": true }, "camelcase": { @@ -304,9 +304,9 @@ "dev": true }, "debug": { - "version": "2.6.8", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.8.tgz", - "integrity": "sha1-5zFTHKLt4n0YgiJCfaF4IdaP9Pw=", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", "dev": true, "requires": { "ms": "2.0.0" @@ -337,9 +337,9 @@ "dev": true }, "diff": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-3.2.0.tgz", - "integrity": "sha1-yc45Okt8vQsFinJck98pkCeGj/k=", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", + "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", "dev": true }, "dom-serializer": { @@ -548,16 +548,10 @@ "path-is-absolute": "^1.0.0" } }, - "graceful-readlink": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz", - "integrity": "sha1-TK+tdrxi8C+gObL5Tpo906ORpyU=", - "dev": true - }, "growl": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/growl/-/growl-1.9.2.tgz", - "integrity": "sha1-Dqd0NxXbjY3ixe3hd14bRayFwC8=", + "version": "1.10.5", + "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", + "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", "dev": true }, "handlebars": { @@ -812,12 +806,6 @@ "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", "dev": true }, - "json3": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/json3/-/json3-3.3.2.tgz", - "integrity": "sha1-PAQ0dD35Pi9cQq7nsZvLSDV19OE=", - "dev": true - }, "jsonpointer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-4.0.1.tgz", @@ -882,80 +870,18 @@ "integrity": "sha1-Nni9irmVBXwHreg27S7wh9qBHUU=", "dev": true }, - "lodash._baseassign": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/lodash._baseassign/-/lodash._baseassign-3.2.0.tgz", - "integrity": "sha1-jDigmVAPIVrQnlnxci/QxSv+Ck4=", - "dev": true, - "requires": { - "lodash._basecopy": "^3.0.0", - "lodash.keys": "^3.0.0" - } - }, - "lodash._basecopy": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/lodash._basecopy/-/lodash._basecopy-3.0.1.tgz", - "integrity": "sha1-jaDmqHbPNEwK2KVIghEd08XHyjY=", - "dev": true - }, - "lodash._basecreate": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash._basecreate/-/lodash._basecreate-3.0.3.tgz", - "integrity": "sha1-G8ZhYU2qf8MRt9A78WgGoCE8+CE=", - "dev": true - }, - "lodash._getnative": { - "version": "3.9.1", - "resolved": "https://registry.npmjs.org/lodash._getnative/-/lodash._getnative-3.9.1.tgz", - "integrity": "sha1-VwvH3t5G1hzc3mh9ZdPuy6o6r/U=", - "dev": true - }, - "lodash._isiterateecall": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/lodash._isiterateecall/-/lodash._isiterateecall-3.0.9.tgz", - "integrity": "sha1-UgOte6Ql+uhCRg5pbbnPPmqsBXw=", - "dev": true - }, - "lodash.create": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/lodash.create/-/lodash.create-3.1.1.tgz", - "integrity": "sha1-1/KEnw29p+BGgruM1yqwIkYd6+c=", - "dev": true, - "requires": { - "lodash._baseassign": "^3.0.0", - "lodash._basecreate": "^3.0.0", - "lodash._isiterateecall": "^3.0.0" - } - }, - "lodash.isarguments": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", - "integrity": "sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo=", - "dev": true - }, - "lodash.isarray": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/lodash.isarray/-/lodash.isarray-3.0.4.tgz", - "integrity": "sha1-eeTriMNqgSKvhvhEqpvNhRtfu1U=", - "dev": true - }, - "lodash.keys": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-3.1.2.tgz", - "integrity": "sha1-TbwEcrFWvlCgsoaFXRvQsMZWCYo=", - "dev": true, - "requires": { - "lodash._getnative": "^3.0.0", - "lodash.isarguments": "^3.0.0", - "lodash.isarray": "^3.0.0" - } - }, "log-driver": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/log-driver/-/log-driver-1.2.5.tgz", "integrity": "sha1-euTsJXMC/XkNVXyxDJcQDYV7AFY=", "dev": true }, + "lolex": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/lolex/-/lolex-2.7.1.tgz", + "integrity": "sha512-Oo2Si3RMKV3+lV5MsSWplDQFoTClz/24S0MMHYcgGWWmFXr6TMlqcqk/l1GtH+d5wLBwNRiqGnwDRMirtFalJw==", + "dev": true + }, "longest": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz", @@ -1021,55 +947,51 @@ } }, "mocha": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-3.5.3.tgz", - "integrity": "sha512-/6na001MJWEtYxHOV1WLfsmR4YIynkUEhBwzsb+fk2qmQ3iqsi258l/Q2MWHJMImAcNpZ8DEdYAK72NHoIQ9Eg==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-5.2.0.tgz", + "integrity": "sha512-2IUgKDhc3J7Uug+FxMXuqIyYzH7gJjXECKe/w43IGgQHTSj3InJi+yAA7T24L9bQMRKiUEHxEX37G5JpVUGLcQ==", "dev": true, "requires": { - "browser-stdout": "1.3.0", - "commander": "2.9.0", - "debug": "2.6.8", - "diff": "3.2.0", + "browser-stdout": "1.3.1", + "commander": "2.15.1", + "debug": "3.1.0", + "diff": "3.5.0", "escape-string-regexp": "1.0.5", - "glob": "7.1.1", - "growl": "1.9.2", + "glob": "7.1.2", + "growl": "1.10.5", "he": "1.1.1", - "json3": "3.3.2", - "lodash.create": "3.1.1", + "minimatch": "3.0.4", "mkdirp": "0.5.1", - "supports-color": "3.1.2" + "supports-color": "5.4.0" }, "dependencies": { - "commander": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.9.0.tgz", - "integrity": "sha1-nJkJQXbhIkDLItbFFGCYQA/g99Q=", - "dev": true, - "requires": { - "graceful-readlink": ">= 1.0.0" - } - }, "glob": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.1.tgz", - "integrity": "sha1-gFIR3wT6rxxjo2ADBs31reULLsg=", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", + "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", "dev": true, "requires": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", - "minimatch": "^3.0.2", + "minimatch": "^3.0.4", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, "supports-color": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.1.2.tgz", - "integrity": "sha1-cqJiiU2dQIuVbKBf83su2KbiotU=", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz", + "integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==", "dev": true, "requires": { - "has-flag": "^1.0.0" + "has-flag": "^3.0.0" } } } diff --git a/package.json b/package.json index 4b96159f5..055267de5 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "expect.js": "^0.3.1", "istanbul": "^0.4.2", "jshint": "^2.9.2", - "mocha": "^3.2.0", + "lolex": "^2.7.1", + "mocha": "^5.2.0", "ot-text": "^1.0.1", "rich-text": "^3.1.0", "sharedb-mingo-memory": "^1.0.0-beta" diff --git a/test/client/submit.js b/test/client/submit.js index 4e508e66e..64bc51b97 100644 --- a/test/client/submit.js +++ b/test/client/submit.js @@ -598,7 +598,7 @@ describe('client submit', function() { }); }); - it('transforming pending op by server delete returns error', function(done) { + it('transforming pending op by server delete emits error', function(done) { var doc = this.backend.connect().get('dogs', 'fido'); var doc2 = this.backend.connect().get('dogs', 'fido'); doc.create({age: 3}, function(err) { @@ -608,19 +608,21 @@ describe('client submit', function() { doc2.del(function(err) { if (err) return done(err); doc.pause(); - doc.submitOp({p: ['age'], na: 1}, function(err) { + doc.on('error', function(err) { expect(err).ok(); + expect(err.code).to.equal(4017); expect(doc.version).equal(2); expect(doc.data).eql(undefined); done(); }); + doc.submitOp({p: ['age'], na: 1}); doc.fetch(); }); }); }); }); - it('transforming pending op by server create returns error', function(done) { + it('transforming pending op by server create emits error', function(done) { var doc = this.backend.connect().get('dogs', 'fido'); var doc2 = this.backend.connect().get('dogs', 'fido'); doc.create({age: 3}, function(err) { @@ -632,12 +634,13 @@ describe('client submit', function() { doc2.create({age: 5}, function(err) { if (err) return done(err); doc.pause(); - doc.create({age: 9}, function(err) { + doc.on('error', function(err) { expect(err).ok(); expect(doc.version).equal(3); expect(doc.data).eql({age: 5}); done(); }); + doc.create({age: 9}); doc.fetch(); }); }); diff --git a/test/client/undo-redo.js b/test/client/undo-redo.js index b98a62a58..55a5bc0f5 100644 --- a/test/client/undo-redo.js +++ b/test/client/undo-redo.js @@ -1,4 +1,5 @@ var async = require('async'); +var lolex = require("lolex"); var util = require('../util'); var errorHandler = util.errorHandler; var Backend = require('../../lib/backend'); @@ -21,6 +22,7 @@ types.register(invertibleType.typeWithTransformX); describe('client undo/redo', function() { beforeEach(function() { + this.clock = lolex.install(); this.backend = new Backend(); this.connection = this.backend.connect(); this.connection2 = this.backend.connect(); @@ -30,215 +32,226 @@ describe('client undo/redo', function() { afterEach(function(done) { this.backend.close(done); + this.clock.uninstall(); }); - it('submits a fixed operation', function(allDone) { + it('submits a non-undoable operation', function(allDone) { + var undoManager = this.connection.undoManager(); async.series([ this.doc.create.bind(this.doc, { test: 5 }), this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ]), function(done) { expect(this.doc.version).to.equal(2); expect(this.doc.data).to.eql({ test: 7 }); - expect(this.doc.canUndo()).to.equal(false); - expect(this.doc.canRedo()).to.equal(false); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); done(); }.bind(this) ], allDone); }); - it('receives a remote operation', function(allDone) { - async.series([ - this.doc.subscribe.bind(this.doc), - this.doc2.create.bind(this.doc2, { test: 5 }), - this.doc2.submitOp.bind(this.doc2, [ { p: [ 'test' ], na: 2 } ]), - setTimeout, - function(done) { - expect(this.doc.version).to.equal(2); - expect(this.doc.data).to.eql({ test: 7 }); - expect(this.doc.canUndo()).to.equal(false); - expect(this.doc.canRedo()).to.equal(false); - done(); - }.bind(this) - ], allDone); + it('receives a remote operation', function(done) { + var undoManager = this.connection.undoManager(); + this.doc2.preventCompose = true; + this.doc.on('op', function() { + expect(this.doc.version).to.equal(2); + expect(this.doc.data).to.eql({ test: 7 }); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); + done(); + }.bind(this)); + this.doc.subscribe(function() { + this.doc2.create({ test: 5 }); + this.doc2.submitOp([ { p: [ 'test' ], na: 2 } ]); + }.bind(this)); }); it('submits an undoable operation', function(allDone) { + var undoManager = this.connection.undoManager(); async.series([ this.doc.create.bind(this.doc, { test: 5 }), this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ], { undoable: true }), function(done) { expect(this.doc.version).to.equal(2); expect(this.doc.data).to.eql({ test: 7 }); - expect(this.doc.canUndo()).to.equal(true); - expect(this.doc.canRedo()).to.equal(false); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(false); done(); }.bind(this) ], allDone); }); it('undoes an operation', function(allDone) { + var undoManager = this.connection.undoManager(); async.series([ this.doc.create.bind(this.doc, { test: 5 }), this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ], { undoable: true }), - this.doc.undo.bind(this.doc), + undoManager.undo.bind(undoManager), function(done) { expect(this.doc.version).to.equal(3); expect(this.doc.data).to.eql({ test: 5 }); - expect(this.doc.canUndo()).to.equal(false); - expect(this.doc.canRedo()).to.equal(true); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(true); done(); }.bind(this) ], allDone); }); it('redoes an operation', function(allDone) { + var undoManager = this.connection.undoManager(); async.series([ this.doc.create.bind(this.doc, { test: 5 }), this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ], { undoable: true }), - this.doc.undo.bind(this.doc), - this.doc.redo.bind(this.doc), + undoManager.undo.bind(undoManager), + undoManager.redo.bind(undoManager), function(done) { expect(this.doc.version).to.equal(4); expect(this.doc.data).to.eql({ test: 7 }); - expect(this.doc.canUndo()).to.equal(true); - expect(this.doc.canRedo()).to.equal(false); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(false); done(); }.bind(this) ], allDone); }); it('performs a series of undo and redo operations', function(allDone) { + var undoManager = this.connection.undoManager(); async.series([ this.doc.create.bind(this.doc, { test: 5 }), this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ], { undoable: true }), - this.doc.undo.bind(this.doc), - this.doc.redo.bind(this.doc), - this.doc.undo.bind(this.doc), - this.doc.redo.bind(this.doc), - this.doc.undo.bind(this.doc), - this.doc.redo.bind(this.doc), + undoManager.undo.bind(undoManager), + undoManager.redo.bind(undoManager), + undoManager.undo.bind(undoManager), + undoManager.redo.bind(undoManager), + undoManager.undo.bind(undoManager), + undoManager.redo.bind(undoManager), function(done) { expect(this.doc.version).to.equal(8); expect(this.doc.data).to.eql({ test: 7 }); - expect(this.doc.canUndo()).to.equal(true); - expect(this.doc.canRedo()).to.equal(false); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(false); done(); }.bind(this) ], allDone); }); it('performs a series of undo and redo operations synchronously', function() { + var undoManager = this.connection.undoManager(); this.doc.create({ test: 5 }), this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }), expect(this.doc.data).to.eql({ test: 7 }); - this.doc.undo(), + undoManager.undo(), expect(this.doc.data).to.eql({ test: 5 }); - this.doc.redo(), + undoManager.redo(), expect(this.doc.data).to.eql({ test: 7 }); - this.doc.undo(), + undoManager.undo(), expect(this.doc.data).to.eql({ test: 5 }); - this.doc.redo(), + undoManager.redo(), expect(this.doc.data).to.eql({ test: 7 }); - this.doc.undo(), + undoManager.undo(), expect(this.doc.data).to.eql({ test: 5 }); - this.doc.redo(), + undoManager.redo(), expect(this.doc.data).to.eql({ test: 7 }); - expect(this.doc.canUndo()).to.equal(true); - expect(this.doc.canRedo()).to.equal(false); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(false); }); it('undoes one of two operations', function(allDone) { - this.doc.undoComposeTimeout = -1; + var undoManager = this.connection.undoManager({ composeTimeout: -1 }); async.series([ this.doc.create.bind(this.doc, { test: 5 }), this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ], { undoable: true }), this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 3 } ], { undoable: true }), - this.doc.undo.bind(this.doc), + undoManager.undo.bind(undoManager), function(done) { expect(this.doc.version).to.equal(4); expect(this.doc.data).to.eql({ test: 7 }); - expect(this.doc.canUndo()).to.equal(true); - expect(this.doc.canRedo()).to.equal(true); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(true); done(); }.bind(this) ], allDone); }); it('undoes two of two operations', function(allDone) { - this.doc.undoComposeTimeout = -1; + var undoManager = this.connection.undoManager({ composeTimeout: -1 }); async.series([ this.doc.create.bind(this.doc, { test: 5 }), this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ], { undoable: true }), this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 3 } ], { undoable: true }), - this.doc.undo.bind(this.doc), - this.doc.undo.bind(this.doc), + undoManager.undo.bind(undoManager), + undoManager.undo.bind(undoManager), function(done) { expect(this.doc.version).to.equal(5); expect(this.doc.data).to.eql({ test: 5 }); - expect(this.doc.canUndo()).to.equal(false); - expect(this.doc.canRedo()).to.equal(true); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(true); done(); }.bind(this) ], allDone); }); - it('reoes one of two operations', function(allDone) { - this.doc.undoComposeTimeout = -1; + it('redoes one of two operations', function(allDone) { + var undoManager = this.connection.undoManager({ composeTimeout: -1 }); async.series([ this.doc.create.bind(this.doc, { test: 5 }), this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ], { undoable: true }), this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 3 } ], { undoable: true }), - this.doc.undo.bind(this.doc), - this.doc.undo.bind(this.doc), - this.doc.redo.bind(this.doc), + undoManager.undo.bind(undoManager), + undoManager.undo.bind(undoManager), + undoManager.redo.bind(undoManager), function(done) { expect(this.doc.version).to.equal(6); expect(this.doc.data).to.eql({ test: 7 }); - expect(this.doc.canUndo()).to.equal(true); - expect(this.doc.canRedo()).to.equal(true); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(true); done(); }.bind(this) ], allDone); }); - it('reoes two of two operations', function(allDone) { - this.doc.undoComposeTimeout = -1; + it('redoes two of two operations', function(allDone) { + var undoManager = this.connection.undoManager({ composeTimeout: -1 }); async.series([ this.doc.create.bind(this.doc, { test: 5 }), this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ], { undoable: true }), this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 3 } ], { undoable: true }), - this.doc.undo.bind(this.doc), - this.doc.undo.bind(this.doc), - this.doc.redo.bind(this.doc), - this.doc.redo.bind(this.doc), + undoManager.undo.bind(undoManager), + undoManager.undo.bind(undoManager), + undoManager.redo.bind(undoManager), + undoManager.redo.bind(undoManager), function(done) { expect(this.doc.version).to.equal(7); expect(this.doc.data).to.eql({ test: 10 }); - expect(this.doc.canUndo()).to.equal(true); - expect(this.doc.canRedo()).to.equal(false); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(false); done(); }.bind(this) ], allDone); }); it('calls undo, when canUndo is false', function(done) { - expect(this.doc.canUndo()).to.equal(false); - this.doc.undo(done); + var undoManager = this.connection.undoManager(); + expect(undoManager.canUndo()).to.equal(false); + undoManager.undo(done); }); it('calls undo, when canUndo is false - no callback', function() { - expect(this.doc.canUndo()).to.equal(false); - this.doc.undo(); + var undoManager = this.connection.undoManager(); + expect(undoManager.canUndo()).to.equal(false); + undoManager.undo(); }); it('calls redo, when canRedo is false', function(done) { - expect(this.doc.canRedo()).to.equal(false); - this.doc.redo(done); + var undoManager = this.connection.undoManager(); + expect(undoManager.canRedo()).to.equal(false); + undoManager.redo(done); }); it('calls redo, when canRedo is false - no callback', function() { - expect(this.doc.canRedo()).to.equal(false); - this.doc.redo(); + var undoManager = this.connection.undoManager(); + expect(undoManager.canRedo()).to.equal(false); + undoManager.redo(); }); it('preserves source on create', function(done) { @@ -268,24 +281,26 @@ describe('client undo/redo', function() { }); it('preserves source on undo', function(done) { + var undoManager = this.connection.undoManager(); this.doc.create({ test: 5 }); this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); this.doc.on('op', function(op, source) { expect(source).to.equal('test source'); done(); }); - this.doc.undo({ source: 'test source' }); + undoManager.undo({ source: 'test source' }); }); it('preserves source on redo', function(done) { + var undoManager = this.connection.undoManager(); this.doc.create({ test: 5 }); this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); - this.doc.undo(); + undoManager.undo(); this.doc.on('op', function(op, source) { expect(source).to.equal('test source'); done(); }); - this.doc.redo({ source: 'test source' }); + undoManager.redo({ source: 'test source' }); }); it('has source=false on remote operations', function(done) { @@ -301,101 +316,106 @@ describe('client undo/redo', function() { }); it('composes undoable operations within time limit', function(done) { + var undoManager = this.connection.undoManager(); this.doc.create({ test: 5 }); this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); setTimeout(function() { this.doc.submitOp([ { p: [ 'test' ], na: 3 } ], { undoable: true }); expect(this.doc.data).to.eql({ test: 10 }); - this.doc.undo(); + undoManager.undo(); expect(this.doc.data).to.eql({ test: 5 }); - expect(this.doc.canUndo()).to.equal(false); + expect(undoManager.canUndo()).to.equal(false); done(); - }.bind(this), 2); + }.bind(this), 1000); + this.clock.runAll(); }); it('composes undoable operations correctly', function() { + var undoManager = this.connection.undoManager(); this.doc.create({ a: 1, b: 2 }); this.doc.submitOp([ { p: [ 'a' ], od: 1 } ], { undoable: true }); this.doc.submitOp([ { p: [ 'b' ], od: 2 } ], { undoable: true }); expect(this.doc.data).to.eql({}); - expect(this.doc.canRedo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); var opCalled = false; this.doc.once('op', function(op) { opCalled = true; expect(op).to.eql([ { p: [ 'b' ], oi: 2 }, { p: [ 'a' ], oi: 1 } ]); }); - this.doc.undo(); + undoManager.undo(); expect(opCalled).to.equal(true); expect(this.doc.data).to.eql({ a: 1, b: 2 }); - expect(this.doc.canUndo()).to.equal(false); - this.doc.redo(); + expect(undoManager.canUndo()).to.equal(false); + undoManager.redo(); expect(this.doc.data).to.eql({}); - expect(this.doc.canRedo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); }); it('does not compose undoable operations outside time limit', function(done) { - this.doc.undoComposeTimeout = 1; + var undoManager = this.connection.undoManager(); this.doc.create({ test: 5 }); this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); setTimeout(function () { this.doc.submitOp([ { p: [ 'test' ], na: 3 } ], { undoable: true }); expect(this.doc.data).to.eql({ test: 10 }); - this.doc.undo(); + undoManager.undo(); expect(this.doc.data).to.eql({ test: 7 }); - expect(this.doc.canUndo()).to.equal(true); - this.doc.undo(); + expect(undoManager.canUndo()).to.equal(true); + undoManager.undo(); expect(this.doc.data).to.eql({ test: 5 }); - expect(this.doc.canUndo()).to.equal(false); + expect(undoManager.canUndo()).to.equal(false); done(); - }.bind(this), 3); + }.bind(this), 1001); + this.clock.runAll(); }); - it('does not compose undoable operations, if undoComposeTimeout < 0', function() { - this.doc.undoComposeTimeout = -1; + it('does not compose undoable operations, if composeTimeout < 0', function() { + var undoManager = this.connection.undoManager({ composeTimeout: -1 }); this.doc.create({ test: 5 }); this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); this.doc.submitOp([ { p: [ 'test' ], na: 3 } ], { undoable: true }); expect(this.doc.data).to.eql({ test: 10 }); - this.doc.undo(); + undoManager.undo(); expect(this.doc.data).to.eql({ test: 7 }); - expect(this.doc.canUndo()).to.equal(true); - this.doc.undo(); + expect(undoManager.canUndo()).to.equal(true); + undoManager.undo(); expect(this.doc.data).to.eql({ test: 5 }); - expect(this.doc.canUndo()).to.equal(false); + expect(undoManager.canUndo()).to.equal(false); }); it('does not compose undoable operations, if type does not support compose nor composeSimilar', function() { + var undoManager = this.connection.undoManager(); this.doc.create(5, invertibleType.type.uri); this.doc.submitOp(2, { undoable: true }); expect(this.doc.data).to.equal(7); this.doc.submitOp(2, { undoable: true }); expect(this.doc.data).to.equal(9); - this.doc.undo(); + undoManager.undo(); expect(this.doc.data).to.equal(7); - this.doc.undo(); + undoManager.undo(); expect(this.doc.data).to.equal(5); - expect(this.doc.canUndo()).to.equal(false); - this.doc.redo(); + expect(undoManager.canUndo()).to.equal(false); + undoManager.redo(); expect(this.doc.data).to.equal(7); - this.doc.redo(); + undoManager.redo(); expect(this.doc.data).to.equal(9); - expect(this.doc.canRedo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); }); it('uses applyAndInvert, if available', function() { - this.doc.undoComposeTimeout = -1; + var undoManager = this.connection.undoManager({ composeTimeout: -1 }); this.doc.create([], otRichText.type.uri); this.doc.submitOp([ otRichText.Action.createInsertText('two') ], { undoable: true }); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('two') ]); this.doc.submitOp([ otRichText.Action.createInsertText('one') ], { undoable: true }); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('onetwo') ]); - this.doc.undo(); + undoManager.undo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('two') ]); - this.doc.undo(); + undoManager.undo(); expect(this.doc.data).to.eql([]); - this.doc.redo(); + undoManager.redo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('two') ]); - this.doc.redo(); + undoManager.redo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('onetwo') ]); }); @@ -435,25 +455,18 @@ describe('client undo/redo', function() { this.doc.submitSnapshot([ { insert: 'abc' } ], { undoable: true }); }); - it('fails to submit with fixUpUndoStack, if type is not invertible', function(done) { - this.doc.create('two', otText.type.uri); - this.doc.on('error', done); - this.doc.submitOp([ 'one' ], { fixUpUndoStack: true }, function(err) { - expect(err.code).to.equal(4025); - done(); - }); - }); - - it('fails to submit with fixUpRedoStack, if type is not invertible', function(done) { + it('fails to submit with fixUp, if type is not invertible', function(done) { + var undoManager = this.connection.undoManager(); this.doc.create('two', otText.type.uri); this.doc.on('error', done); - this.doc.submitOp([ 'one' ], { fixUpRedoStack: true }, function(err) { + this.doc.submitOp([ 'one' ], { fixUp: true }, function(err) { expect(err.code).to.equal(4025); done(); }); }); it('composes similar operations', function() { + var undoManager = this.connection.undoManager(); this.doc.create([], otRichText.type.uri); this.doc.submitOp([ otRichText.Action.createInsertText('one') @@ -463,57 +476,40 @@ describe('client undo/redo', function() { otRichText.Action.createInsertText('two') ], { undoable: true }); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('onetwo') ]); - expect(this.doc.canRedo()).to.equal(false); - this.doc.undo(); + expect(undoManager.canRedo()).to.equal(false); + undoManager.undo(); expect(this.doc.data).to.eql([]); - expect(this.doc.canUndo()).to.equal(false); - this.doc.redo(); + expect(undoManager.canUndo()).to.equal(false); + undoManager.redo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('onetwo') ]); - expect(this.doc.canRedo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); }); it('does not compose dissimilar operations', function() { - this.doc.create([ - otRichText.Action.createInsertText(' ') - ], otRichText.type.uri); + var undoManager = this.connection.undoManager(); + this.doc.create([ otRichText.Action.createInsertText(' ') ], otRichText.type.uri); - this.doc.submitOp([ - otRichText.Action.createRetain(1), - otRichText.Action.createInsertText('two') - ], { undoable: true }); - expect(this.doc.data).to.eql([ - otRichText.Action.createInsertText(' two') - ]); + this.doc.submitOp([ otRichText.Action.createRetain(1), otRichText.Action.createInsertText('two') ], { undoable: true }); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText(' two') ]); - this.doc.submitOp([ - otRichText.Action.createInsertText('one') - ], { undoable: true }); - expect(this.doc.data).to.eql([ - otRichText.Action.createInsertText('one two') - ]); - - this.doc.undo(); - expect(this.doc.data).to.eql([ - otRichText.Action.createInsertText(' two') - ]); - - this.doc.undo(); - expect(this.doc.data).to.eql([ - otRichText.Action.createInsertText(' ') - ]); - - this.doc.redo(); - expect(this.doc.data).to.eql([ - otRichText.Action.createInsertText(' two') - ]); - - this.doc.redo(); - expect(this.doc.data).to.eql([ - otRichText.Action.createInsertText('one two') - ]); + this.doc.submitOp([ otRichText.Action.createInsertText('one') ], { undoable: true }); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('one two') ]); + + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText(' two') ]); + + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText(' ') ]); + + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText(' two') ]); + + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('one two') ]); }); it('does not add no-ops to the undo stack on undoable operation', function() { + var undoManager = this.connection.undoManager(); var opCalled = false; this.doc.create([ otRichText.Action.createInsertText('test', [ 'key', 'value' ]) ], otRichText.type.uri); this.doc.on('op', function(op, source) { @@ -523,164 +519,114 @@ describe('client undo/redo', function() { this.doc.submitOp([ otRichText.Action.createRetain(4, [ 'key', 'value' ]) ], { undoable: true }); expect(opCalled).to.equal(true); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('test', [ 'key', 'value' ]) ]); - expect(this.doc.canUndo()).to.eql(false); - expect(this.doc.canRedo()).to.eql(false); + expect(undoManager.canUndo()).to.eql(false); + expect(undoManager.canRedo()).to.eql(false); }); it('limits the size of the undo stack', function() { - this.doc.undoLimit = 2; - this.doc.undoComposeTimeout = -1; + var undoManager = this.connection.undoManager({ limit: 2, composeTimeout: -1 }); this.doc.create({ test: 5 }); this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); expect(this.doc.data).to.eql({ test: 11 }); - expect(this.doc.canUndo()).to.equal(true); - this.doc.undo(); - expect(this.doc.canUndo()).to.equal(true); - this.doc.undo(); - expect(this.doc.canUndo()).to.equal(false); - this.doc.undo(); + expect(undoManager.canUndo()).to.equal(true); + undoManager.undo(); + expect(undoManager.canUndo()).to.equal(true); + undoManager.undo(); + expect(undoManager.canUndo()).to.equal(false); + undoManager.undo(); expect(this.doc.data).to.eql({ test: 7 }); }); - it('limits the size of the undo stack, after adjusting the limit', function() { - this.doc.undoLimit = 100; - this.doc.undoComposeTimeout = -1; - this.doc.create({ test: 5 }); - this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); - this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); - this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); - this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); - this.doc.undoLimit = 2; - this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); - expect(this.doc.data).to.eql({ test: 15 }); - expect(this.doc.canUndo()).to.equal(true); - this.doc.undo(); - expect(this.doc.canUndo()).to.equal(true); - this.doc.undo(); - expect(this.doc.canUndo()).to.equal(false); - this.doc.undo(); - expect(this.doc.data).to.eql({ test: 11 }); - }); - - it('does not limit the size of the stacks on undo and redo operations', function() { - this.doc.undoLimit = 100; - this.doc.undoComposeTimeout = -1; - this.doc.create({ test: 5 }); - this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); - this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); - this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); - this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); - this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); - this.doc.undoLimit = 2; - expect(this.doc.data).to.eql({ test: 15 }); - this.doc.undo(); - this.doc.undo(); - this.doc.undo(); - this.doc.undo(); - this.doc.undo(); - expect(this.doc.data).to.eql({ test: 5 }); - this.doc.redo(); - this.doc.redo(); - this.doc.redo(); - this.doc.redo(); - this.doc.redo(); - expect(this.doc.data).to.eql({ test: 15 }); - this.doc.undo(); - this.doc.undo(); - this.doc.undo(); - this.doc.undo(); - this.doc.undo(); - expect(this.doc.data).to.eql({ test: 5 }); - }); - it('does not compose the next operation after undo', function() { + var undoManager = this.connection.undoManager(); this.doc.create({ test: 5 }); - this.doc.undoComposeTimeout = -1; + this.clock.tick(1001); this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); // not composed + this.clock.tick(1001); this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); // not composed - this.doc.undoComposeTimeout = 1000; - this.doc.undo(); + undoManager.undo(); this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); // not composed this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); // composed expect(this.doc.data).to.eql({ test: 11 }); - expect(this.doc.canUndo()).to.equal(true); + expect(undoManager.canUndo()).to.equal(true); - this.doc.undo(); + undoManager.undo(); expect(this.doc.data).to.eql({ test: 7 }); - expect(this.doc.canUndo()).to.equal(true); + expect(undoManager.canUndo()).to.equal(true); - this.doc.undo(); + undoManager.undo(); expect(this.doc.data).to.eql({ test: 5 }); - expect(this.doc.canUndo()).to.equal(false); + expect(undoManager.canUndo()).to.equal(false); }); it('does not compose the next operation after undo and redo', function() { + var undoManager = this.connection.undoManager(); this.doc.create({ test: 5 }); - this.doc.undoComposeTimeout = -1; + this.clock.tick(1001); this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); // not composed + this.clock.tick(1001); this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); // not composed - this.doc.undoComposeTimeout = 1000; - this.doc.undo(); - this.doc.redo(); + undoManager.undo(); + undoManager.redo(); this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); // not composed this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); // composed expect(this.doc.data).to.eql({ test: 13 }); - expect(this.doc.canUndo()).to.equal(true); + expect(undoManager.canUndo()).to.equal(true); - this.doc.undo(); + undoManager.undo(); expect(this.doc.data).to.eql({ test: 9 }); - expect(this.doc.canUndo()).to.equal(true); + expect(undoManager.canUndo()).to.equal(true); - this.doc.undo(); + undoManager.undo(); expect(this.doc.data).to.eql({ test: 7 }); - expect(this.doc.canUndo()).to.equal(true); + expect(undoManager.canUndo()).to.equal(true); - this.doc.undo(); + undoManager.undo(); expect(this.doc.data).to.eql({ test: 5 }); - expect(this.doc.canUndo()).to.equal(false); + expect(undoManager.canUndo()).to.equal(false); }); it('clears stacks on del', function() { - this.doc.undoComposeTimeout = -1; + var undoManager = this.connection.undoManager({ composeTimeout: -1 }); this.doc.create({ test: 5 }); this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); - this.doc.undo(); - expect(this.doc.canUndo()).to.equal(true); - expect(this.doc.canRedo()).to.equal(true); + undoManager.undo(); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(true); this.doc.del(); - expect(this.doc.canUndo()).to.equal(false); - expect(this.doc.canRedo()).to.equal(false); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); }); it('transforms the stacks by remote operations', function(done) { + var undoManager = this.connection.undoManager({ composeTimeout: -1 }); this.doc2.subscribe(); this.doc.subscribe(); - this.doc.undoComposeTimeout = -1; this.doc.create([], otRichText.type.uri); this.doc.submitOp([ otRichText.Action.createInsertText('4') ], { undoable: true }); this.doc.submitOp([ otRichText.Action.createInsertText('3') ], { undoable: true }); this.doc.submitOp([ otRichText.Action.createInsertText('2') ], { undoable: true }); this.doc.submitOp([ otRichText.Action.createInsertText('1') ], { undoable: true }); - this.doc.undo(); - this.doc.undo(); - setTimeout(function() { + undoManager.undo(); + undoManager.undo(); + this.doc.whenNothingPending(function() { this.doc.once('op', function(op, source) { expect(source).to.equal(false); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC34') ]); - this.doc.undo(); + undoManager.undo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC4') ]); - this.doc.undo(); + undoManager.undo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC') ]); - this.doc.redo(); + undoManager.redo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC4') ]); - this.doc.redo(); + undoManager.redo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC34') ]); - this.doc.redo(); + undoManager.redo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC234') ]); - this.doc.redo(); + undoManager.redo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC1234') ]); done(); }.bind(this)); @@ -689,30 +635,30 @@ describe('client undo/redo', function() { }); it('transforms the stacks by remote operations and removes no-ops', function(done) { + var undoManager = this.connection.undoManager({ composeTimeout: -1 }); this.doc2.subscribe(); this.doc.subscribe(); - this.doc.undoComposeTimeout = -1; this.doc.create([], otRichText.type.uri); this.doc.submitOp([ otRichText.Action.createInsertText('4') ], { undoable: true }); this.doc.submitOp([ otRichText.Action.createInsertText('3') ], { undoable: true }); this.doc.submitOp([ otRichText.Action.createInsertText('2') ], { undoable: true }); this.doc.submitOp([ otRichText.Action.createInsertText('1') ], { undoable: true }); - this.doc.undo(); - this.doc.undo(); - setTimeout(function() { + undoManager.undo(); + undoManager.undo(); + this.doc.whenNothingPending(function() { this.doc.once('op', function(op, source) { expect(source).to.equal(false); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('4') ]); - this.doc.undo(); + undoManager.undo(); expect(this.doc.data).to.eql([]); - expect(this.doc.canUndo()).to.equal(false); - this.doc.redo(); + expect(undoManager.canUndo()).to.equal(false); + undoManager.redo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('4') ]); - this.doc.redo(); + undoManager.redo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('24') ]); - this.doc.redo(); + undoManager.redo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('124') ]); - expect(this.doc.canRedo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); done(); }.bind(this)); this.doc2.submitOp([ otRichText.Action.createDelete(1) ]); @@ -720,100 +666,100 @@ describe('client undo/redo', function() { }); it('transforms the stacks by a local FIXED operation', function() { - this.doc.undoComposeTimeout = -1; + var undoManager = this.connection.undoManager({ composeTimeout: -1 }); this.doc.create([], otRichText.type.uri); this.doc.submitOp([ otRichText.Action.createInsertText('4') ], { undoable: true }); this.doc.submitOp([ otRichText.Action.createInsertText('3') ], { undoable: true }); this.doc.submitOp([ otRichText.Action.createInsertText('2') ], { undoable: true }); this.doc.submitOp([ otRichText.Action.createInsertText('1') ], { undoable: true }); - this.doc.undo(); - this.doc.undo(); + undoManager.undo(); + undoManager.undo(); this.doc.submitOp([ otRichText.Action.createInsertText('ABC') ]); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC34') ]); - this.doc.undo(); + undoManager.undo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC4') ]); - this.doc.undo(); + undoManager.undo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC') ]); - this.doc.redo(); + undoManager.redo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC4') ]); - this.doc.redo(); + undoManager.redo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC34') ]); - this.doc.redo(); + undoManager.redo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC234') ]); - this.doc.redo(); + undoManager.redo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC1234') ]); }); it('transforms the stacks by a local FIXED operation and removes no-ops', function() { - this.doc.undoComposeTimeout = -1; + var undoManager = this.connection.undoManager({ composeTimeout: -1 }); this.doc.create([], otRichText.type.uri); this.doc.submitOp([ otRichText.Action.createInsertText('4') ], { undoable: true }); this.doc.submitOp([ otRichText.Action.createInsertText('3') ], { undoable: true }); this.doc.submitOp([ otRichText.Action.createInsertText('2') ], { undoable: true }); this.doc.submitOp([ otRichText.Action.createInsertText('1') ], { undoable: true }); - this.doc.undo(); - this.doc.undo(); + undoManager.undo(); + undoManager.undo(); this.doc.submitOp([ otRichText.Action.createDelete(1) ]); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('4') ]); - this.doc.undo(); + undoManager.undo(); expect(this.doc.data).to.eql([]); - expect(this.doc.canUndo()).to.equal(false); - this.doc.redo(); + expect(undoManager.canUndo()).to.equal(false); + undoManager.redo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('4') ]); - this.doc.redo(); + undoManager.redo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('24') ]); - this.doc.redo(); + undoManager.redo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('124') ]); - expect(this.doc.canRedo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); }); it('transforms the stacks using transform', function() { - this.doc.undoComposeTimeout = -1; + var undoManager = this.connection.undoManager({ composeTimeout: -1 }); this.doc.create(0, invertibleType.type.uri); this.doc.submitOp(1, { undoable: true }); this.doc.submitOp(10, { undoable: true }); this.doc.submitOp(100, { undoable: true }); this.doc.submitOp(1000, { undoable: true }); - this.doc.undo(); - this.doc.undo(); + undoManager.undo(); + undoManager.undo(); expect(this.doc.data).to.equal(11); this.doc.submitOp(10000); - this.doc.undo(); + undoManager.undo(); expect(this.doc.data).to.equal(10001); - this.doc.undo(); + undoManager.undo(); expect(this.doc.data).to.equal(10000); - this.doc.redo(); + undoManager.redo(); expect(this.doc.data).to.equal(10001); - this.doc.redo(); + undoManager.redo(); expect(this.doc.data).to.equal(10011); - this.doc.redo(); + undoManager.redo(); expect(this.doc.data).to.equal(10111); - this.doc.redo(); + undoManager.redo(); expect(this.doc.data).to.equal(11111); }); it('transforms the stacks using transformX', function() { - this.doc.undoComposeTimeout = -1; + var undoManager = this.connection.undoManager({ composeTimeout: -1 }); this.doc.create(0, invertibleType.typeWithTransformX.uri); this.doc.submitOp(1, { undoable: true }); this.doc.submitOp(10, { undoable: true }); this.doc.submitOp(100, { undoable: true }); this.doc.submitOp(1000, { undoable: true }); - this.doc.undo(); - this.doc.undo(); + undoManager.undo(); + undoManager.undo(); expect(this.doc.data).to.equal(11); this.doc.submitOp(10000); - this.doc.undo(); + undoManager.undo(); expect(this.doc.data).to.equal(10001); - this.doc.undo(); + undoManager.undo(); expect(this.doc.data).to.equal(10000); - this.doc.redo(); + undoManager.redo(); expect(this.doc.data).to.equal(10001); - this.doc.redo(); + undoManager.redo(); expect(this.doc.data).to.equal(10011); - this.doc.redo(); + undoManager.redo(); expect(this.doc.data).to.equal(10111); - this.doc.redo(); + undoManager.redo(); expect(this.doc.data).to.equal(11111); }); @@ -871,283 +817,127 @@ describe('client undo/redo', function() { this.doc.submitSnapshot([ otRichText.Action.createInsertText('test') ], { skipNoop: true }, done); }); - describe('operationType', function() { - it('reports UNDOABLE operationType', function(done) { - var beforeOpCalled = false; - this.doc.create({ test: 5 }); - this.doc.on('before op', function(op, source, operationType) { - expect(source).to.equal(true); - expect(operationType).to.equal('UNDOABLE'); - beforeOpCalled = true; - }); - this.doc.on('op', function(op, source, operationType) { - expect(beforeOpCalled).to.equal(true); - expect(source).to.equal(true); - expect(operationType).to.equal('UNDOABLE'); - done(); - }); - this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + describe('fixup operations', function() { + beforeEach(function() { + var undoManager = this.connection.undoManager({ composeTimeout: -1 }); + + this.assert = function(text) { + var expected = text ? [ otRichText.Action.createInsertText(text) ] : []; + expect(this.doc.data).to.eql(expected); + return this; + }; + this.submitOp = function(op, options) { + if (typeof op === 'string') { + this.doc.submitOp([ otRichText.Action.createInsertText(op) ], options); + } else if (op < 0) { + this.doc.submitOp([ otRichText.Action.createDelete(-op) ], options); + } else { + throw new Error('Invalid op'); + } + return this; + }; + this.submitSnapshot = function(snapshot, options) { + this.doc.submitSnapshot([ otRichText.Action.createInsertText(snapshot) ], options); + return this; + }; + this.undo = function() { + undoManager.undo(); + return this; + }; + this.redo = function() { + undoManager.redo(); + return this; + }; + + this.doc.create([], otRichText.type.uri); + this.submitOp('d', { undoable: true }).assert('d'); + this.submitOp('c', { undoable: true }).assert('cd'); + this.submitOp('b', { undoable: true }).assert('bcd'); + this.submitOp('a', { undoable: true }).assert('abcd'); + this.undo().assert('bcd'); + this.undo().assert('cd'); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(true); }); - it('reports UNDO operationType', function(done) { - var beforeOpCalled = false; - this.doc.create({ test: 5 }); - this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); - this.doc.on('before op', function(op, source, operationType) { - expect(source).to.equal(true); - expect(operationType).to.equal('UNDO'); - beforeOpCalled = true; - }); - this.doc.on('op', function(op, source, operationType) { - expect(beforeOpCalled).to.equal(true); - expect(source).to.equal(true); - expect(operationType).to.equal('UNDO'); - done(); - }); - this.doc.undo(); + it('submits an op and does not fix up stacks (insert)', function() { + this.submitOp('!').assert('!cd'); + this.undo().assert('!d'); + this.undo().assert('!'); + this.redo().assert('!d'); + this.redo().assert('!cd'); + this.redo().assert('!bcd'); + this.redo().assert('!abcd'); }); - it('reports REDO operationType', function(done) { - var beforeOpCalled = false; - this.doc.create({ test: 5 }); - this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); - this.doc.undo(); - this.doc.on('before op', function(op, source, operationType) { - expect(source).to.equal(true); - expect(operationType).to.equal('REDO'); - beforeOpCalled = true; - }); - this.doc.on('op', function(op, source, operationType) { - expect(beforeOpCalled).to.equal(true); - expect(source).to.equal(true); - expect(operationType).to.equal('REDO'); - done(); - }); - this.doc.redo(); + it('submits an op and fixes up stacks (insert)', function() { + this.submitOp('!', { fixUp: true }).assert('!cd'); + this.undo().assert('d'); + this.undo().assert(''); + this.redo().assert('d'); + this.redo().assert('!cd'); + this.redo().assert('bcd'); + this.redo().assert('abcd'); }); - it('reports FIXED operationType (local operation, undoable=false)', function(done) { - var beforeOpCalled = false; - this.doc.create({ test: 5 }); - this.doc.on('before op', function(op, source, operationType) { - expect(source).to.equal(true); - expect(operationType).to.equal('FIXED'); - beforeOpCalled = true; - }); - this.doc.on('op', function(op, source, operationType) { - expect(beforeOpCalled).to.equal(true); - expect(source).to.equal(true); - expect(operationType).to.equal('FIXED'); - done(); - }); - this.doc.submitOp([ { p: [ 'test' ], na: 2 } ]); + it('submits a snapshot and does not fix up stacks (insert)', function() { + this.submitSnapshot('!cd').assert('!cd'); + this.undo().assert('!d'); + this.undo().assert('!'); + this.redo().assert('!d'); + this.redo().assert('!cd'); + this.redo().assert('!bcd'); + this.redo().assert('!abcd'); }); - it('reports FIXED operationType (remote operation, undoable=false)', function(done) { - var beforeOpCalled = false; - this.doc.subscribe(); - this.doc.on('before op', function(op, source, operationType) { - expect(source).to.equal(false); - expect(operationType).to.equal('FIXED'); - beforeOpCalled = true; - }); - this.doc.on('op', function(op, source, operationType) { - expect(beforeOpCalled).to.equal(true); - expect(source).to.equal(false); - expect(operationType).to.equal('FIXED'); - done(); - }); - this.doc2.preventCompose = true; - this.doc2.create({ test: 5 }); - this.doc2.submitOp([ { p: [ 'test' ], na: 2 } ]); + it('submits a snapshot and fixes up stacks (insert)', function() { + this.submitSnapshot('!cd', { fixUp: true }).assert('!cd'); + this.undo().assert('d'); + this.undo().assert(''); + this.redo().assert('d'); + this.redo().assert('!cd'); + this.redo().assert('bcd'); + this.redo().assert('abcd'); }); - it('reports FIXED operationType (remote operation, undoable=true)', function(done) { - var beforeOpCalled = false; - this.doc.subscribe(); - this.doc.on('before op', function(op, source, operationType) { - expect(source).to.equal(false); - expect(operationType).to.equal('FIXED'); - beforeOpCalled = true; - }); - this.doc.on('op', function(op, source, operationType) { - expect(beforeOpCalled).to.equal(true); - expect(source).to.equal(false); - expect(operationType).to.equal('FIXED'); - done(); - }); - this.doc2.preventCompose = true; - this.doc2.create({ test: 5 }); - this.doc2.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + it('submits an op and does not fix up stacks (delete)', function() { + this.submitOp(-1).assert('d'); + this.undo().assert(''); + this.undo().assert(''); + this.redo().assert('d'); + this.redo().assert('bd'); + this.redo().assert('abd'); + this.redo().assert('abd'); }); - }); - describe('fixup operations', function() { - describe('basic tests', function() { - beforeEach(function() { - this.assert = function(text) { - var expected = text ? [ otRichText.Action.createInsertText(text) ] : []; - expect(this.doc.data).to.eql(expected); - return this; - }; - this.submitOp = function(op, options) { - this.doc.submitOp([ otRichText.Action.createInsertText(op) ], options); - return this; - }; - this.submitSnapshot = function(snapshot, options) { - this.doc.submitSnapshot([ otRichText.Action.createInsertText(snapshot) ], options); - return this; - }; - this.undo = function() { - this.doc.undo(); - return this; - }; - this.redo = function() { - this.doc.redo(); - return this; - }; - - this.doc.undoComposeTimeout = -1; - this.doc.create([], otRichText.type.uri); - this.submitOp('d', { undoable: true }).assert('d'); - this.submitOp('c', { undoable: true }).assert('cd'); - this.submitOp('b', { undoable: true }).assert('bcd'); - this.submitOp('a', { undoable: true }).assert('abcd'); - this.undo().assert('bcd'); - this.undo().assert('cd'); - expect(this.doc.canUndo()).to.equal(true); - expect(this.doc.canRedo()).to.equal(true); - }); - - it('submits an operation (transforms undo stack, transforms redo stack)', function() { - this.submitOp('!').assert('!cd'); - this.undo().assert('!d'); - this.undo().assert('!'); - this.redo().assert('!d'); - this.redo().assert('!cd'); - this.redo().assert('!bcd'); - this.redo().assert('!abcd'); - }); - - it('submits an operation (fixes up undo stack, transforms redo stack)', function() { - this.submitOp('!', { fixUpUndoStack: true }).assert('!cd'); - this.undo().assert('d'); - this.undo().assert(''); - this.redo().assert('d'); - this.redo().assert('!cd'); - this.redo().assert('!bcd'); - this.redo().assert('!abcd'); - }); - - it('submits an operation (transforms undo stack, fixes up redo stack)', function() { - this.submitOp('!', { fixUpRedoStack: true }).assert('!cd'); - this.undo().assert('!d'); - this.undo().assert('!'); - this.redo().assert('!d'); - this.redo().assert('!cd'); - this.redo().assert('bcd'); - this.redo().assert('abcd'); - }); - - it('submits an operation (fixes up undo stack, fixes up redo stack)', function() { - this.submitOp('!', { fixUpUndoStack: true, fixUpRedoStack: true }).assert('!cd'); - this.undo().assert('d'); - this.undo().assert(''); - this.redo().assert('d'); - this.redo().assert('!cd'); - this.redo().assert('bcd'); - this.redo().assert('abcd'); - }); - - it('submits a snapshot (transforms undo stack, transforms redo stack)', function() { - this.submitSnapshot('!cd').assert('!cd'); - this.undo().assert('!d'); - this.undo().assert('!'); - this.redo().assert('!d'); - this.redo().assert('!cd'); - this.redo().assert('!bcd'); - this.redo().assert('!abcd'); - }); - - it('submits a snapshot (fixes up undo stack, transforms redo stack)', function() { - this.submitSnapshot('!cd', { fixUpUndoStack: true }).assert('!cd'); - this.undo().assert('d'); - this.undo().assert(''); - this.redo().assert('d'); - this.redo().assert('!cd'); - this.redo().assert('!bcd'); - this.redo().assert('!abcd'); - }); - - it('submits a snapshot (transforms undo stack, fixes up redo stack)', function() { - this.submitSnapshot('!cd', { fixUpRedoStack: true }).assert('!cd'); - this.undo().assert('!d'); - this.undo().assert('!'); - this.redo().assert('!d'); - this.redo().assert('!cd'); - this.redo().assert('bcd'); - this.redo().assert('abcd'); - }); - - it('submits a snapshot (fixes up undo stack, fixes up redo stack)', function() { - this.submitSnapshot('!cd', { fixUpUndoStack: true, fixUpRedoStack: true }).assert('!cd'); - this.undo().assert('d'); - this.undo().assert(''); - this.redo().assert('d'); - this.redo().assert('!cd'); - this.redo().assert('bcd'); - this.redo().assert('abcd'); - }); + it('submits an op and fixes up stacks (delete)', function() { + this.submitOp(-1, { fixUp: true }).assert('d'); + this.undo().assert(''); + this.undo().assert(''); + this.redo().assert('d'); + this.redo().assert('bcd'); + this.redo().assert('abcd'); + this.redo().assert('abcd'); }); - describe('no-ops', function() { - it('removes a no-op from the undo stack', function() { - this.doc.undoComposeTimeout = -1; - this.doc.create([], otRichText.type.uri); - this.doc.submitOp([ otRichText.Action.createInsertText('d') ], { undoable: true }); - this.doc.submitOp([ otRichText.Action.createInsertText('c') ], { undoable: true }); - this.doc.submitOp([ otRichText.Action.createInsertText('b') ], { undoable: true }); - this.doc.submitOp([ otRichText.Action.createInsertText('a') ], { undoable: true }); - this.doc.undo(); - this.doc.undo(); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('cd') ]); - this.doc.submitOp([ otRichText.Action.createDelete(1) ], { fixUpUndoStack: true }); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('d') ]); - this.doc.undo(); - expect(this.doc.data).to.eql([]); - expect(this.doc.canUndo()).to.equal(false); - this.doc.redo(); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('d') ]); - this.doc.redo(); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('bd') ]); - this.doc.redo(); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abd') ]); - expect(this.doc.canRedo()).to.equal(false); - }); + it('submits a snapshot and does not fix up stacks (delete)', function() { + this.submitSnapshot('d').assert('d'); + this.undo().assert(''); + this.undo().assert(''); + this.redo().assert('d'); + this.redo().assert('bd'); + this.redo().assert('abd'); + this.redo().assert('abd'); + }); - it('removes a no-op from the redo stack', function() { - this.doc.undoComposeTimeout = -1; - this.doc.create([ otRichText.Action.createInsertText('abcd') ], otRichText.type.uri); - this.doc.submitOp([ otRichText.Action.createDelete(1) ], { undoable: true }); - this.doc.submitOp([ otRichText.Action.createDelete(1) ], { undoable: true }); - this.doc.submitOp([ otRichText.Action.createDelete(1) ], { undoable: true }); - this.doc.submitOp([ otRichText.Action.createDelete(1) ], { undoable: true }); - this.doc.undo(); - this.doc.undo(); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('cd') ]); - this.doc.submitOp([ otRichText.Action.createDelete(1) ], { fixUpRedoStack: true }); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('d') ]); - this.doc.redo(); - expect(this.doc.data).to.eql([]); - expect(this.doc.canRedo()).to.equal(false); - this.doc.undo(); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('d') ]); - this.doc.undo(); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('bd') ]); - this.doc.undo(); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abd') ]); - expect(this.doc.canUndo()).to.equal(false); - }); + it('submits a snapshot and fixes up stacks (delete)', function() { + this.submitSnapshot('d', { fixUp: true }).assert('d'); + this.undo().assert(''); + this.undo().assert(''); + this.redo().assert('d'); + this.redo().assert('bcd'); + this.redo().assert('abcd'); + this.redo().assert('abcd'); }); }); @@ -1190,11 +980,12 @@ describe('client undo/redo', function() { }); it('submits a snapshot with source (no callback)', function(done) { + var undoManager = this.connection.undoManager(); this.doc.on('op', function(op, source) { expect(op).to.eql([ otRichText.Action.createInsertText('abc') ]); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcdef') ]); - expect(this.doc.canUndo()).to.equal(false); - expect(this.doc.canRedo()).to.equal(false); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); expect(source).to.equal('test'); done(); }.bind(this)); @@ -1203,13 +994,14 @@ describe('client undo/redo', function() { }); it('submits a snapshot with source (with callback)', function(done) { + var undoManager = this.connection.undoManager(); var opEmitted = false; this.doc.on('op', function(op, source) { expect(op).to.eql([ otRichText.Action.createInsertText('abc') ]); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcdef') ]); expect(source).to.equal('test'); - expect(this.doc.canUndo()).to.equal(false); - expect(this.doc.canRedo()).to.equal(false); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); opEmitted = true; }.bind(this)); this.doc.create([ otRichText.Action.createInsertText('def') ], otRichText.type.uri); @@ -1220,11 +1012,12 @@ describe('client undo/redo', function() { }); it('submits a snapshot without source (no callback)', function(done) { + var undoManager = this.connection.undoManager(); this.doc.on('op', function(op, source) { expect(op).to.eql([ otRichText.Action.createInsertText('abc') ]); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcdef') ]); - expect(this.doc.canUndo()).to.equal(false); - expect(this.doc.canRedo()).to.equal(false); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); expect(source).to.equal(true); done(); }.bind(this)); @@ -1233,13 +1026,14 @@ describe('client undo/redo', function() { }); it('submits a snapshot without source (with callback)', function(done) { + var undoManager = this.connection.undoManager(); var opEmitted = false; this.doc.on('op', function(op, source) { expect(op).to.eql([ otRichText.Action.createInsertText('abc') ]); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcdef') ]); expect(source).to.equal(true); - expect(this.doc.canUndo()).to.equal(false); - expect(this.doc.canRedo()).to.equal(false); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); opEmitted = true; }.bind(this)); this.doc.create([ otRichText.Action.createInsertText('def') ], otRichText.type.uri); @@ -1250,44 +1044,45 @@ describe('client undo/redo', function() { }); it('submits snapshots and supports undo and redo', function() { - this.doc.undoComposeTimeout = -1; + var undoManager = this.connection.undoManager({ composeTimeout: -1 }); this.doc.create([ otRichText.Action.createInsertText('ghi') ], otRichText.type.uri); this.doc.submitSnapshot([ otRichText.Action.createInsertText('defghi') ], { undoable: true }); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('defghi') ]); this.doc.submitSnapshot([ otRichText.Action.createInsertText('abcdefghi') ], { undoable: true }); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcdefghi') ]); - this.doc.undo(); + undoManager.undo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('defghi') ]); - this.doc.undo(); + undoManager.undo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ghi') ]); - expect(this.doc.canUndo()).to.equal(false); - this.doc.redo(); + expect(undoManager.canUndo()).to.equal(false); + undoManager.redo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('defghi') ]); - this.doc.redo(); + undoManager.redo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcdefghi') ]); - expect(this.doc.canRedo()).to.equal(false); - this.doc.undo(); + expect(undoManager.canRedo()).to.equal(false); + undoManager.undo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('defghi') ]); - this.doc.undo(); + undoManager.undo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ghi') ]); - expect(this.doc.canUndo()).to.equal(false); + expect(undoManager.canUndo()).to.equal(false); }); it('submits snapshots and composes operations', function() { + var undoManager = this.connection.undoManager(); this.doc.create([ otRichText.Action.createInsertText('ghi') ], otRichText.type.uri); this.doc.submitSnapshot([ otRichText.Action.createInsertText('defghi') ], { undoable: true }); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('defghi') ]); this.doc.submitSnapshot([ otRichText.Action.createInsertText('abcdefghi') ], { undoable: true }); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcdefghi') ]); - this.doc.undo(); + undoManager.undo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ghi') ]); - expect(this.doc.canUndo()).to.equal(false); - this.doc.redo(); + expect(undoManager.canUndo()).to.equal(false); + undoManager.redo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcdefghi') ]); - expect(this.doc.canRedo()).to.equal(false); - this.doc.undo(); + expect(undoManager.canRedo()).to.equal(false); + undoManager.undo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ghi') ]); - expect(this.doc.canUndo()).to.equal(false); + expect(undoManager.canUndo()).to.equal(false); }); it('submits a snapshot and syncs it', function(done) { @@ -1306,7 +1101,7 @@ describe('client undo/redo', function() { }); it('submits undoable and fixed operations', function() { - this.doc.undoComposeTimeout = -1; + var undoManager = this.connection.undoManager({ composeTimeout: -1 }); this.doc.create([], otRichText.type.uri); this.doc.submitSnapshot([ otRichText.Action.createInsertText('a') ], { undoable: true }); this.doc.submitSnapshot([ otRichText.Action.createInsertText('ab') ], { undoable: true }); @@ -1314,34 +1109,35 @@ describe('client undo/redo', function() { this.doc.submitSnapshot([ otRichText.Action.createInsertText('abcd') ], { undoable: true }); this.doc.submitSnapshot([ otRichText.Action.createInsertText('abcde') ], { undoable: true }); this.doc.submitSnapshot([ otRichText.Action.createInsertText('abcdef') ], { undoable: true }); - this.doc.undo(); - this.doc.undo(); + undoManager.undo(); + undoManager.undo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcd') ]); this.doc.submitSnapshot([ otRichText.Action.createInsertText('abc123d') ]); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abc123d') ]); - this.doc.undo(); + undoManager.undo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abc123') ]); - this.doc.undo(); + undoManager.undo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ab123') ]); - this.doc.undo(); + undoManager.undo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('a123') ]); - this.doc.undo(); + undoManager.undo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('123') ]); - this.doc.redo(); + undoManager.redo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('a123') ]); - this.doc.redo(); + undoManager.redo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ab123') ]); - this.doc.redo(); + undoManager.redo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abc123') ]); - this.doc.redo(); + undoManager.redo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abc123d') ]); - this.doc.redo(); + undoManager.redo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abc123de') ]); - this.doc.redo(); + undoManager.redo(); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abc123def') ]); }); it('submits a snapshot without a diffHint', function() { + var undoManager = this.connection.undoManager(); var opCalled = 0; this.doc.create([ otRichText.Action.createInsertText('aaaa') ], otRichText.type.uri); this.doc.submitSnapshot([ otRichText.Action.createInsertText('aaaaa') ], { undoable: true }); @@ -1352,19 +1148,20 @@ describe('client undo/redo', function() { expect(op).to.eql([ otRichText.Action.createDelete(1) ]); opCalled++; }.bind(this)); - this.doc.undo(); + undoManager.undo(); this.doc.once('op', function(op) { expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('aaaaa') ]); expect(op).to.eql([ otRichText.Action.createInsertText('a') ]); opCalled++; }.bind(this)); - this.doc.redo(); + undoManager.redo(); expect(opCalled).to.equal(2); }); it('submits a snapshot with a diffHint', function() { + var undoManager = this.connection.undoManager(); var opCalled = 0; this.doc.create([ otRichText.Action.createInsertText('aaaa') ], otRichText.type.uri); this.doc.submitSnapshot([ otRichText.Action.createInsertText('aaaaa') ], { undoable: true, diffHint: 2 }); @@ -1375,14 +1172,14 @@ describe('client undo/redo', function() { expect(op).to.eql([ otRichText.Action.createRetain(2), otRichText.Action.createDelete(1) ]); opCalled++; }.bind(this)); - this.doc.undo(); + undoManager.undo(); this.doc.once('op', function(op) { expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('aaaaa') ]); expect(op).to.eql([ otRichText.Action.createRetain(2), otRichText.Action.createInsertText('a') ]); opCalled++; }.bind(this)); - this.doc.redo(); + undoManager.redo(); expect(opCalled).to.equal(2); }); @@ -1412,57 +1209,63 @@ describe('client undo/redo', function() { describe('with diff', function () { it('submits a snapshot (non-undoable)', function() { + var undoManager = this.connection.undoManager(); this.doc.create(5, invertibleType.typeWithDiff.uri); this.doc.submitSnapshot(7); expect(this.doc.data).to.equal(7); - expect(this.doc.canUndo()).to.equal(false); - expect(this.doc.canRedo()).to.equal(false); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); }); it('submits a snapshot (undoable)', function() { + var undoManager = this.connection.undoManager(); this.doc.create(5, invertibleType.typeWithDiff.uri); this.doc.submitSnapshot(7, { undoable: true }); expect(this.doc.data).to.equal(7); - this.doc.undo(); + undoManager.undo(); expect(this.doc.data).to.equal(5); - this.doc.redo(); + undoManager.redo(); expect(this.doc.data).to.equal(7); }); }); describe('with diffX', function () { it('submits a snapshot (non-undoable)', function() { + var undoManager = this.connection.undoManager(); this.doc.create(5, invertibleType.typeWithDiffX.uri); this.doc.submitSnapshot(7); expect(this.doc.data).to.equal(7); - expect(this.doc.canUndo()).to.equal(false); - expect(this.doc.canRedo()).to.equal(false); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); }); it('submits a snapshot (undoable)', function() { + var undoManager = this.connection.undoManager(); this.doc.create(5, invertibleType.typeWithDiffX.uri); this.doc.submitSnapshot(7, { undoable: true }); expect(this.doc.data).to.equal(7); - this.doc.undo(); + undoManager.undo(); expect(this.doc.data).to.equal(5); - this.doc.redo(); + undoManager.redo(); expect(this.doc.data).to.equal(7); }); }); describe('with diff and diffX', function () { it('submits a snapshot (non-undoable)', function() { + var undoManager = this.connection.undoManager(); this.doc.create(5, invertibleType.typeWithDiffAndDiffX.uri); this.doc.submitSnapshot(7); expect(this.doc.data).to.equal(7); - expect(this.doc.canUndo()).to.equal(false); - expect(this.doc.canRedo()).to.equal(false); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); }); it('submits a snapshot (undoable)', function() { + var undoManager = this.connection.undoManager(); this.doc.create(5, invertibleType.typeWithDiffAndDiffX.uri); this.doc.submitSnapshot(7, { undoable: true }); expect(this.doc.data).to.equal(7); - this.doc.undo(); + undoManager.undo(); expect(this.doc.data).to.equal(5); - this.doc.redo(); + undoManager.redo(); expect(this.doc.data).to.equal(7); }); }); From f289a58d54fe1092fe77c03ff36404915aa42c31 Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Wed, 18 Jul 2018 13:31:09 +0200 Subject: [PATCH 26/45] Simplify the code --- lib/client/doc.js | 16 ++++++++-------- lib/client/undoManager.js | 26 +++++++++++++------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/lib/client/doc.js b/lib/client/doc.js index 0915e9f8b..10e7e9929 100644 --- a/lib/client/doc.js +++ b/lib/client/doc.js @@ -508,7 +508,7 @@ Doc.prototype._otApply = function(op, options) { var err = new ShareDBError(4015, 'Cannot apply op to uncreated document. ' + this.collection + '.' + this.id); return this.emit('error', err); } - var undoOp = options && options.undoOp || null; + var undoOp = options && options.undoOp && options.undoOp.op || null; var undoable = options && options.undoable || false; var fixUp = options && options.fixUp || false; @@ -542,7 +542,7 @@ Doc.prototype._otApply = function(op, options) { } // Apply the individual op component this.emit('before op', componentOp.op, source); - this._applyOp(componentOp, undoOp, source, undoable, fixUp); + this._applyOp(componentOp.op, undoOp, source, undoable, fixUp); this.emit('op', componentOp.op, source); } // Pop whatever was submitted since we started applying this op @@ -554,7 +554,7 @@ Doc.prototype._otApply = function(op, options) { // the snapshot before it gets changed this.emit('before op', op.op, source); // Apply the operation to the local data, mutating it in place - this._applyOp(op, undoOp, source, undoable, fixUp); + this._applyOp(op.op, undoOp, source, undoable, fixUp); // Emit an 'op' event once the local data includes the changes from the // op. For locally submitted ops, this will be synchronously with // submission and before the server or other clients have received the op. @@ -589,15 +589,15 @@ Doc.prototype._otApply = function(op, options) { Doc.prototype._applyOp = function(op, undoOp, source, undoable, fixUp) { if (undoOp == null && (undoable || fixUp)) { if (this.type.applyAndInvert) { - var result = this.type.applyAndInvert(this.data, op.op); + var result = this.type.applyAndInvert(this.data, op); this.data = result[0]; - undoOp = { op: result[1] }; + undoOp = result[1]; } else { - this.data = this.type.apply(this.data, op.op); - undoOp = { op: this.type.invert(op.op) }; + this.data = this.type.apply(this.data, op); + undoOp = this.type.invert(op); } } else { - this.data = this.type.apply(this.data, op.op); + this.data = this.type.apply(this.data, op); } this.connection.onDocOp(this, op, undoOp, source, undoable, fixUp); diff --git a/lib/client/undoManager.js b/lib/client/undoManager.js index 7f51218e2..fa463a7b3 100644 --- a/lib/client/undoManager.js +++ b/lib/client/undoManager.js @@ -160,24 +160,24 @@ UndoManager.prototype._updateStacksUndoable = function(doc, op, undoOp) { getLast(this._undoStack).doc !== doc || now - this._previousUndoableOperationTime > this._composeTimeout ) { - this._undoStack.push(new Item(undoOp.op, doc)); + this._undoStack.push(new Item(undoOp, doc)); } else if (doc.type.composeSimilar) { var lastOp = getLast(this._undoStack); - var composedOp = doc.type.composeSimilar(undoOp.op, lastOp.op); + var composedOp = doc.type.composeSimilar(undoOp, lastOp.op); if (composedOp != null) { setLast(this._undoStack, new Item(composedOp, doc)); } else { - this._undoStack.push(new Item(undoOp.op, doc)); + this._undoStack.push(new Item(undoOp, doc)); } } else if (doc.type.compose) { var lastOp = getLast(this._undoStack); - var composedOp = doc.type.compose(undoOp.op, lastOp.op); + var composedOp = doc.type.compose(undoOp, lastOp.op); setLast(this._undoStack, new Item(composedOp, doc)); } else { - this._undoStack.push(new Item(undoOp.op, doc)); + this._undoStack.push(new Item(undoOp, doc)); } this._redoStack.length = 0; @@ -195,15 +195,15 @@ UndoManager.prototype._updateStacksUndoable = function(doc, op, undoOp) { }; UndoManager.prototype._updateStacksUndo = function(doc, op, undoOp) { - if (!doc.type.isNoop || !doc.type.isNoop(undoOp.op)) { - this._redoStack.push(new Item(undoOp.op, doc)); + if (!doc.type.isNoop || !doc.type.isNoop(undoOp)) { + this._redoStack.push(new Item(undoOp, doc)); } this._previousUndoableOperationTime = -Infinity; }; UndoManager.prototype._updateStacksRedo = function(doc, op, undoOp) { - if (!doc.type.isNoop || !doc.type.isNoop(undoOp.op)) { - this._undoStack.push(new Item(undoOp.op, doc)); + if (!doc.type.isNoop || !doc.type.isNoop(undoOp)) { + this._undoStack.push(new Item(undoOp, doc)); } this._previousUndoableOperationTime = -Infinity; }; @@ -216,7 +216,7 @@ UndoManager.prototype._updateStacksFixed = function(doc, op, undoOp, fixUp) { var lastUndoIndex = findLastIndex(this._undoStack, doc); if (lastUndoIndex >= 0) { var lastOp = this._undoStack[lastUndoIndex]; - var composedOp = doc.type.compose(undoOp.op, lastOp.op); + var composedOp = doc.type.compose(undoOp, lastOp.op); if (!doc.type.isNoop || !doc.type.isNoop(composedOp)) { this._undoStack[lastUndoIndex] = new Item(composedOp, doc); } else { @@ -227,7 +227,7 @@ UndoManager.prototype._updateStacksFixed = function(doc, op, undoOp, fixUp) { var lastRedoIndex = findLastIndex(this._redoStack, doc); if (lastRedoIndex >= 0) { var lastOp = this._redoStack[lastRedoIndex]; - var composedOp = doc.type.compose(undoOp.op, lastOp.op); + var composedOp = doc.type.compose(undoOp, lastOp.op); if (!doc.type.isNoop || !doc.type.isNoop(composedOp)) { this._redoStack[lastRedoIndex] = new Item(composedOp, doc); } else { @@ -236,8 +236,8 @@ UndoManager.prototype._updateStacksFixed = function(doc, op, undoOp, fixUp) { } } else { - this._undoStack = this._transformStack(this._undoStack, doc, op.op); - this._redoStack = this._transformStack(this._redoStack, doc, op.op); + this._undoStack = this._transformStack(this._undoStack, doc, op); + this._redoStack = this._transformStack(this._redoStack, doc, op); } }; From 498ee3f149ff61c6e878378b03925ccab07199d8 Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Wed, 18 Jul 2018 13:41:03 +0200 Subject: [PATCH 27/45] Document connection.undoManager(options) --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index a61f38968..fd0a9631a 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,12 @@ changes. Returns a [`ShareDB.Query`](#class-sharedbquery) instance. * `options.*` All other options are passed through to the database adapter. +`connection.undoManager(options)` creates a new `UndoManager`. + +* `options.source` if specified, only the operations from that `source` will be undo-able. If `null` or `undefined`, the `source` filter is disabled. +* `options.limit` the max number of operations to keep on the undo stack. +* `options.composeTimeout` the max time difference between operations in milliseconds, which still allows the operations to be composed on the undoStack. + ### Class: `ShareDB.Doc` `doc.type` _(String)_ From 1eb1d3ce95af87672683aba7c40ec3f5a360e7de Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Wed, 18 Jul 2018 16:42:29 +0200 Subject: [PATCH 28/45] Fix some issues --- lib/client/undoManager.js | 48 ++++++++--- test/client/undo-redo.js | 175 +++++++++++++++++++++++++++++++++++--- 2 files changed, 199 insertions(+), 24 deletions(-) diff --git a/lib/client/undoManager.js b/lib/client/undoManager.js index fa463a7b3..f571cbb18 100644 --- a/lib/client/undoManager.js +++ b/lib/client/undoManager.js @@ -27,9 +27,10 @@ function Item(op, doc) { // Manages an undo/redo stack for all operations from the specified `source`. module.exports = UndoManager; function UndoManager(connection, options) { - this.connection = connection; + // The Connection which created this UndoManager. + this._connection = connection; - // The "source" value of undoable operations. If null or undefined, it works for all operations. + // If != null, only ops from this "source" will be undoable. this._source = options && options.source; // The max number of undo operations to keep on the stack. @@ -50,17 +51,27 @@ function UndoManager(connection, options) { this._previousUndoableOperationTime = -Infinity; // The type of operation that is currently in progress. + // It depends on the `op` event being triggered synchronously when submitting an operation or snapshot. this._operationInProgress = null; } UndoManager.prototype.destroy = function() { - this.connection.removeUndoManager(this); + this._connection.removeUndoManager(this); + this.clear(); }; -UndoManager.prototype.clear = function() { - this._undoStack.length = 0; - this._redoStack.length = 0; - this._previousUndoableOperationTime = -Infinity; +// Clear the undo and redo stack. +// +// @param doc If specified, clear only the ops belonging to this doc. +UndoManager.prototype.clear = function(doc) { + if (doc) { + var filter = function(item) { return item.doc !== doc; }; + this._undoStack = this._undoStack.filter(filter); + this._redoStack = this._redoStack.filter(filter); + } else { + this._undoStack.length = 0; + this._redoStack.length = 0; + } }; // Returns true, if there are any operations on the undo stack, otherwise false. @@ -124,15 +135,25 @@ UndoManager.prototype.redo = function(options, callback) { }; UndoManager.prototype.onDocLoad = function(doc) { - this.clear(); + this.clear(doc); }; UndoManager.prototype.onDocCreate = function(doc) { - this.clear(); + // NOTE We don't support undo on create because we can't support undo on delete. }; UndoManager.prototype.onDocDelete = function(doc) { - this.clear(); + // NOTE We can't support undo on delete because we can't generate `initialData` required for `create`. + // See https://github.com/ottypes/docs#standard-properties. + // + // We could support undo on delete and create in the future but that would require some breaking changes to ShareDB. + // Here's what we could do: + // + // 1. Do NOT call `create` in ShareDB - ShareDB would get a valid snapshot from the client code. + // 2. Add `validate` to OT types. + // 3. Call `validate` in ShareDB to ensure that the snapshot from the client is valid. + // 4. The `create` ops would contain serialized snapshots instead of `initialData`. + this.clear(doc); }; UndoManager.prototype.onDocOp = function(doc, op, undoOp, source, undoable, fixUp) { @@ -249,7 +270,12 @@ UndoManager.prototype._transformStack = function(stack, doc, op) { var newStackIndex = 0; for (var i = stack.length - 1; i >= 0; --i) { - var stackOp = stack[i].op; + var item = stack[i]; + if (item.doc !== doc) { + newStack[newStackIndex++] = item; + continue; + } + var stackOp = item.op; var transformedStackOp; var transformedOp; diff --git a/test/client/undo-redo.js b/test/client/undo-redo.js index 55a5bc0f5..e5d8444a8 100644 --- a/test/client/undo-redo.js +++ b/test/client/undo-redo.js @@ -588,19 +588,6 @@ describe('client undo/redo', function() { expect(undoManager.canUndo()).to.equal(false); }); - it('clears stacks on del', function() { - var undoManager = this.connection.undoManager({ composeTimeout: -1 }); - this.doc.create({ test: 5 }); - this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); - this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); - undoManager.undo(); - expect(undoManager.canUndo()).to.equal(true); - expect(undoManager.canRedo()).to.equal(true); - this.doc.del(); - expect(undoManager.canUndo()).to.equal(false); - expect(undoManager.canRedo()).to.equal(false); - }); - it('transforms the stacks by remote operations', function(done) { var undoManager = this.connection.undoManager({ composeTimeout: -1 }); this.doc2.subscribe(); @@ -941,6 +928,168 @@ describe('client undo/redo', function() { }); }); + describe('UndoManager.clear', function() { + it('clears the stacks', function() { + var undoManager = this.connection.undoManager({ composeTimeout: -1 }); + var doc1 = this.connection.get('dogs', 'fido'); + var doc2 = this.connection.get('dogs', 'toby'); + doc1.create({ test: 5 }); + doc2.create({ test: 11 }); + doc1.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + doc2.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + doc1.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + doc2.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + undoManager.undo(); + undoManager.undo(); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(true); + undoManager.clear(); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); + }); + + it('clears the stacks for a specific document', function() { + var undoManager = this.connection.undoManager({ composeTimeout: -1 }); + var doc1 = this.connection.get('dogs', 'fido'); + var doc2 = this.connection.get('dogs', 'toby'); + doc1.create({ test: 5 }); + doc2.create({ test: 11 }); + doc1.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + doc2.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + doc1.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + doc2.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + undoManager.undo(); + undoManager.undo(); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(true); + + undoManager.clear(doc1); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(true); + + undoManager.undo(); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(true); + expect(doc1.data).to.eql({ test: 7 }); + expect(doc2.data).to.eql({ test: 11 }); + + undoManager.redo(); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(true); + expect(doc1.data).to.eql({ test: 7 }); + expect(doc2.data).to.eql({ test: 13 }); + + undoManager.redo(); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(false); + expect(doc1.data).to.eql({ test: 7 }); + expect(doc2.data).to.eql({ test: 15 }); + }); + + it('clears the stacks for a specific document on del', function() { + // NOTE we don't support undo/redo on del/create at the moment. + // See undoManager.js for more details. + var undoManager = this.connection.undoManager({ composeTimeout: -1 }); + var doc1 = this.connection.get('dogs', 'fido'); + var doc2 = this.connection.get('dogs', 'toby'); + doc1.create({ test: 5 }); + doc2.create({ test: 11 }); + doc1.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + doc2.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + doc1.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + doc2.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + undoManager.undo(); + undoManager.undo(); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(true); + doc1.del(); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(true); + doc2.del(); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); + }); + + it('clears the stacks for a specific document on load', function(done) { + var shouldReject = false; + this.backend.use('submit', function(request, next) { + if (shouldReject) return next(request.rejectedError()); + next(); + }); + + var undoManager = this.connection.undoManager({ composeTimeout: -1 }); + var doc1 = this.connection.get('dogs', 'fido'); + var doc2 = this.connection.get('dogs', 'toby'); + doc1.create([], otRichText.type.uri); + doc2.create([], otRichText.type.uri); + doc1.submitOp([ otRichText.Action.createInsertText('2') ], { undoable: true }); + doc2.submitOp([ otRichText.Action.createInsertText('b') ], { undoable: true }); + doc1.submitOp([ otRichText.Action.createInsertText('1') ], { undoable: true }); + doc2.submitOp([ otRichText.Action.createInsertText('a') ], { undoable: true }); + undoManager.undo(); + undoManager.undo(); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(true); + + this.connection.whenNothingPending(function() { + shouldReject = true; + doc1.submitOp([ otRichText.Action.createInsertText('!') ], function(err) { + if (err) return done(err); + shouldReject = false; + expect(doc1.data).to.eql([ otRichText.Action.createInsertText('2') ]); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(true); + + undoManager.undo(); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(true); + expect(doc1.data).to.eql([ otRichText.Action.createInsertText('2') ]); + expect(doc2.data).to.eql([]); + + undoManager.redo(); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(true); + expect(doc1.data).to.eql([ otRichText.Action.createInsertText('2') ]); + expect(doc2.data).to.eql([ otRichText.Action.createInsertText('b') ]); + + undoManager.redo(); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(false); + expect(doc1.data).to.eql([ otRichText.Action.createInsertText('2') ]); + expect(doc2.data).to.eql([ otRichText.Action.createInsertText('ab') ]); + + done(); + }); + }.bind(this)); + }); + + it.skip('clears the stacks for a specific document on doc destroy', function(done) { + var undoManager = this.connection.undoManager({ composeTimeout: -1 }); + var doc1 = this.connection.get('dogs', 'fido'); + var doc2 = this.connection.get('dogs', 'toby'); + doc1.create({ test: 5 }); + doc2.create({ test: 11 }); + doc1.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + doc2.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + doc1.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + doc2.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + undoManager.undo(); + undoManager.undo(); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(true); + doc1.destroy(function(err) { + if (err) return done(err); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(true); + doc2.destroy(function(err) { + if (err) return done(err); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); + }); + }); + }); + }); + describe('submitSnapshot', function() { describe('basic tests', function() { it('submits a snapshot when document is not created (no callback, no options)', function(done) { From 1f9cb2e9605fbd918e84d8b788f97861384cbfe3 Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Wed, 18 Jul 2018 16:49:27 +0200 Subject: [PATCH 29/45] Clear undo/redo stacks on doc destroy --- lib/client/connection.js | 6 ++++++ lib/client/doc.js | 2 ++ lib/client/undoManager.js | 4 ++++ test/client/undo-redo.js | 3 ++- 4 files changed, 14 insertions(+), 1 deletion(-) diff --git a/lib/client/connection.js b/lib/client/connection.js index 97b8c00ba..fe99a0048 100644 --- a/lib/client/connection.js +++ b/lib/client/connection.js @@ -608,6 +608,12 @@ Connection.prototype.onDocLoad = function(doc) { } }; +Connection.prototype.onDocDestroy = function(doc) { + for (var i = 0; i < this.undoManagers.length; i++) { + this.undoManagers[i].onDocDestroy(doc); + } +}; + Connection.prototype.onDocCreate = function(doc) { for (var i = 0; i < this.undoManagers.length; i++) { this.undoManagers[i].onDocCreate(doc); diff --git a/lib/client/doc.js b/lib/client/doc.js index d482da448..2df6b0242 100644 --- a/lib/client/doc.js +++ b/lib/client/doc.js @@ -112,10 +112,12 @@ Doc.prototype.destroy = function(callback) { return; } doc.connection._destroyDoc(doc); + doc.connection.onDocDestroy(doc); if (callback) callback(); }); } else { doc.connection._destroyDoc(doc); + doc.connection.onDocDestroy(doc); if (callback) callback(); } }); diff --git a/lib/client/undoManager.js b/lib/client/undoManager.js index f571cbb18..98f9c8827 100644 --- a/lib/client/undoManager.js +++ b/lib/client/undoManager.js @@ -138,6 +138,10 @@ UndoManager.prototype.onDocLoad = function(doc) { this.clear(doc); }; +UndoManager.prototype.onDocDestroy = function(doc) { + this.clear(doc); +}; + UndoManager.prototype.onDocCreate = function(doc) { // NOTE We don't support undo on create because we can't support undo on delete. }; diff --git a/test/client/undo-redo.js b/test/client/undo-redo.js index e5d8444a8..5834656d5 100644 --- a/test/client/undo-redo.js +++ b/test/client/undo-redo.js @@ -1063,7 +1063,7 @@ describe('client undo/redo', function() { }.bind(this)); }); - it.skip('clears the stacks for a specific document on doc destroy', function(done) { + it('clears the stacks for a specific document on doc destroy', function(done) { var undoManager = this.connection.undoManager({ composeTimeout: -1 }); var doc1 = this.connection.get('dogs', 'fido'); var doc2 = this.connection.get('dogs', 'toby'); @@ -1085,6 +1085,7 @@ describe('client undo/redo', function() { if (err) return done(err); expect(undoManager.canUndo()).to.equal(false); expect(undoManager.canRedo()).to.equal(false); + done(); }); }); }); From 563bc80251ac75ee492607755a617b738006efed Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Wed, 18 Jul 2018 16:57:16 +0200 Subject: [PATCH 30/45] Add test for undoManager.destroy() --- test/client/undo-redo.js | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/test/client/undo-redo.js b/test/client/undo-redo.js index 5834656d5..a661ca79c 100644 --- a/test/client/undo-redo.js +++ b/test/client/undo-redo.js @@ -928,6 +928,34 @@ describe('client undo/redo', function() { }); }); + it('destroys UndoManager', function() { + var undoManager = this.connection.undoManager({ composeTimeout: -1 }); + var doc1 = this.connection.get('dogs', 'fido'); + var doc2 = this.connection.get('dogs', 'toby'); + doc1.create({ test: 5 }); + doc2.create({ test: 11 }); + doc1.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + doc2.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + doc1.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + doc2.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + undoManager.undo(); + undoManager.undo(); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(true); + undoManager.destroy(); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); + doc1.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + doc1.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); + expect(doc1.data).to.eql({ test: 11 }); + undoManager.undo(); + expect(doc1.data).to.eql({ test: 11 }); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); + }); + describe('UndoManager.clear', function() { it('clears the stacks', function() { var undoManager = this.connection.undoManager({ composeTimeout: -1 }); @@ -941,11 +969,18 @@ describe('client undo/redo', function() { doc2.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); undoManager.undo(); undoManager.undo(); + expect(doc1.data).to.eql({ test: 7 }); expect(undoManager.canUndo()).to.equal(true); expect(undoManager.canRedo()).to.equal(true); undoManager.clear(); expect(undoManager.canUndo()).to.equal(false); expect(undoManager.canRedo()).to.equal(false); + doc1.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + doc1.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + undoManager.undo(); + expect(doc1.data).to.eql({ test: 9 }); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(true); }); it('clears the stacks for a specific document', function() { From 11ec724155d72be5500311daf6651c0df8cdf9fe Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Wed, 18 Jul 2018 17:26:01 +0200 Subject: [PATCH 31/45] Add more tests --- lib/client/undoManager.js | 7 ++++--- test/client/undo-redo.js | 24 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/lib/client/undoManager.js b/lib/client/undoManager.js index 98f9c8827..d874d9762 100644 --- a/lib/client/undoManager.js +++ b/lib/client/undoManager.js @@ -9,12 +9,14 @@ function findLastIndex(stack, doc) { function getLast(list) { var lastIndex = list.length - 1; + /* istanbul ignore if */ if (lastIndex < 0) throw new Error('List empty'); return list[lastIndex]; } function setLast(list, item) { var lastIndex = list.length - 1; + /* istanbul ignore if */ if (lastIndex < 0) throw new Error('List empty'); list[lastIndex] = item; } @@ -220,6 +222,7 @@ UndoManager.prototype._updateStacksUndoable = function(doc, op, undoOp) { }; UndoManager.prototype._updateStacksUndo = function(doc, op, undoOp) { + /* istanbul ignore else */ if (!doc.type.isNoop || !doc.type.isNoop(undoOp)) { this._redoStack.push(new Item(undoOp, doc)); } @@ -227,6 +230,7 @@ UndoManager.prototype._updateStacksUndo = function(doc, op, undoOp) { }; UndoManager.prototype._updateStacksRedo = function(doc, op, undoOp) { + /* istanbul ignore else */ if (!doc.type.isNoop || !doc.type.isNoop(undoOp)) { this._undoStack.push(new Item(undoOp, doc)); } @@ -234,9 +238,6 @@ UndoManager.prototype._updateStacksRedo = function(doc, op, undoOp) { }; UndoManager.prototype._updateStacksFixed = function(doc, op, undoOp, fixUp) { - var fixUpUndoStack = false; - var fixUpRedoStack = false; - if (fixUp && undoOp && doc.type.compose) { var lastUndoIndex = findLastIndex(this._undoStack, doc); if (lastUndoIndex >= 0) { diff --git a/test/client/undo-redo.js b/test/client/undo-redo.js index a661ca79c..1a44e482f 100644 --- a/test/client/undo-redo.js +++ b/test/client/undo-redo.js @@ -847,6 +847,15 @@ describe('client undo/redo', function() { expect(undoManager.canRedo()).to.equal(true); }); + it('does not fix up anything', function() { + var undoManager = this.connection.undoManager(); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); + this.submitOp('!', { fixUp: true }).assert('!cd'); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); + }); + it('submits an op and does not fix up stacks (insert)', function() { this.submitOp('!').assert('!cd'); this.undo().assert('!d'); @@ -926,6 +935,21 @@ describe('client undo/redo', function() { this.redo().assert('abcd'); this.redo().assert('abcd'); }); + + it('submits a op and fixes up stacks (redo up becomes no-op and is removed from the stack)', function() { + this.redo().redo().assert('abcd'); + this.submitOp(-1, { undoable: true }).assert('bcd'); + this.submitOp(-1, { undoable: true }).assert('cd'); + this.submitOp(-1, { undoable: true }).assert('d'); + this.submitOp(-1, { undoable: true }).assert(''); + this.undo().undo().assert('cd'); + this.submitOp(-1, { fixUp: true }).assert('d'); + this.redo().assert(''); + this.redo().assert(''); + this.undo().assert('d'); + this.undo().assert('bcd'); + this.undo().assert('abcd'); + }); }); it('destroys UndoManager', function() { From 33be4b9571b285c3b4a6a0d262e28a8798462910 Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Wed, 18 Jul 2018 17:41:43 +0200 Subject: [PATCH 32/45] Add a test --- test/client/undo-redo.js | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/test/client/undo-redo.js b/test/client/undo-redo.js index 1a44e482f..66f632600 100644 --- a/test/client/undo-redo.js +++ b/test/client/undo-redo.js @@ -936,7 +936,7 @@ describe('client undo/redo', function() { this.redo().assert('abcd'); }); - it('submits a op and fixes up stacks (redo up becomes no-op and is removed from the stack)', function() { + it('submits a op and fixes up stacks (redo op becomes no-op and is removed from the stack)', function() { this.redo().redo().assert('abcd'); this.submitOp(-1, { undoable: true }).assert('bcd'); this.submitOp(-1, { undoable: true }).assert('cd'); @@ -950,6 +950,35 @@ describe('client undo/redo', function() { this.undo().assert('bcd'); this.undo().assert('abcd'); }); + + it('fixes up the correct ops', function() { + var doc = this.connection.get('dogs', 'toby'); + this.submitSnapshot('', { undoable: true }).assert(''); + this.submitSnapshot('d', { undoable: true }).assert('d'); + this.submitSnapshot('cd', { undoable: true }).assert('cd'); + doc.create({ test: 5 }); + doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + this.submitSnapshot('bcd', { undoable: true }).assert('bcd'); + this.submitSnapshot('abcd', { undoable: true }).assert('abcd'); + this.undo().assert('bcd'); + this.undo().assert('cd'); + this.undo().assert('cd'); // undo one of the `doc` ops + expect(doc.data).to.eql({ test: 7 }); + this.submitSnapshot('!cd', { fixUp: true }).assert('!cd'); + this.undo().assert('!cd'); // undo one of the `doc` ops + expect(doc.data).to.eql({ test: 5 }); + this.undo().assert('d'); + this.undo().assert(''); + this.redo().assert('d'); + this.redo().assert('!cd'); + this.redo().assert('!cd'); // redo one of the `doc` ops + expect(doc.data).to.eql({ test: 7 }); + this.redo().assert('!cd'); // redo one of the `doc` ops + expect(doc.data).to.eql({ test: 9 }); + this.redo().assert('bcd'); + this.redo().assert('abcd'); + }); }); it('destroys UndoManager', function() { From 14e5180fd1bdc27882167a47ce1f52f46f1ad570 Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Thu, 19 Jul 2018 15:20:53 +0200 Subject: [PATCH 33/45] Update mocha and fix 2 tests See https://github.com/mochajs/mocha/releases/tag/v5.0.2 --- package.json | 2 +- test/client/submit.js | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 35fc64bc6..4e38aba84 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "expect.js": "^0.3.1", "istanbul": "^0.4.2", "jshint": "^2.9.2", - "mocha": "^3.2.0", + "mocha": "^5.2.0", "sharedb-mingo-memory": "^1.0.0-beta" }, "scripts": { diff --git a/test/client/submit.js b/test/client/submit.js index 4e508e66e..ee3ae3e0d 100644 --- a/test/client/submit.js +++ b/test/client/submit.js @@ -608,11 +608,16 @@ describe('client submit', function() { doc2.del(function(err) { if (err) return done(err); doc.pause(); + var calledBack = false; + doc.on('error', function(err) { + expect(calledBack).equal(true); + done(); + }); doc.submitOp({p: ['age'], na: 1}, function(err) { expect(err).ok(); expect(doc.version).equal(2); expect(doc.data).eql(undefined); - done(); + calledBack = true; }); doc.fetch(); }); @@ -632,11 +637,16 @@ describe('client submit', function() { doc2.create({age: 5}, function(err) { if (err) return done(err); doc.pause(); + var calledBack = false; + doc.on('error', function() { + expect(calledBack).equal(true); + done(); + }); doc.create({age: 9}, function(err) { expect(err).ok(); expect(doc.version).equal(3); expect(doc.data).eql({age: 5}); - done(); + calledBack = true; }); doc.fetch(); }); From e45dcc9ffa415a2afd845d161b1c051c9380919b Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Thu, 19 Jul 2018 16:05:07 +0200 Subject: [PATCH 34/45] Fix sharedb does not exist --- .travis.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 7bd066b20..736e5fe78 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,8 @@ language: node_js node_js: - "10" - - "9" - "8" - "6" - - "4" -script: "npm run jshint && npm run test-cover" +script: "ln -s .. node_modules/sharedb; npm run jshint && npm run test-cover" # Send coverage data to Coveralls after_script: "cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js" From d5a03f3c2c1f1b72a67d3c76e6816972061fa773 Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Thu, 19 Jul 2018 16:40:39 +0200 Subject: [PATCH 35/45] Remove unused variable --- test/client/submit.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/client/submit.js b/test/client/submit.js index ee3ae3e0d..6523880a8 100644 --- a/test/client/submit.js +++ b/test/client/submit.js @@ -609,7 +609,7 @@ describe('client submit', function() { if (err) return done(err); doc.pause(); var calledBack = false; - doc.on('error', function(err) { + doc.on('error', function() { expect(calledBack).equal(true); done(); }); From d09e506c687a3d2983adf4d7e1febd63436f0215 Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Thu, 19 Jul 2018 16:48:56 +0200 Subject: [PATCH 36/45] Update dependencies --- package-lock.json | 1450 --------------------------------------------- package.json | 6 +- 2 files changed, 3 insertions(+), 1453 deletions(-) delete mode 100644 package-lock.json diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 027e6b09c..000000000 --- a/package-lock.json +++ /dev/null @@ -1,1450 +0,0 @@ -{ - "name": "sharedb", - "version": "1.0.0-beta.9", - "lockfileVersion": 1, - "requires": true, - "dependencies": { - "@teamwork/ot-rich-text": { - "version": "6.3.3", - "resolved": "https://registry.npmjs.org/@teamwork/ot-rich-text/-/ot-rich-text-6.3.3.tgz", - "integrity": "sha512-cYHZPTRMY6N7GxJ3SENzHyGVtgLlDfMdtfRs1CG+6+6DzUkfP/VkEIoR+K5jp02DyI5yvMErkyJkv4BvM23sLg==", - "dev": true, - "requires": { - "fast-diff": "^1.1.2" - } - }, - "abbrev": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.0.9.tgz", - "integrity": "sha1-kbR5JYinc4wl813W9jdSovh3YTU=", - "dev": true - }, - "align-text": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz", - "integrity": "sha1-DNkKVhCT810KmSVsIrcGlDP60Rc=", - "dev": true, - "requires": { - "kind-of": "^3.0.2", - "longest": "^1.0.1", - "repeat-string": "^1.5.2" - } - }, - "amdefine": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", - "integrity": "sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU=", - "dev": true - }, - "ansi-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", - "dev": true - }, - "ansi-styles": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", - "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", - "dev": true - }, - "argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "requires": { - "sprintf-js": "~1.0.2" - } - }, - "arraydiff": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/arraydiff/-/arraydiff-0.1.3.tgz", - "integrity": "sha1-hqVDbXty8b3aX9bXTock5C+Dzk0=" - }, - "asn1": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz", - "integrity": "sha1-2sh4dxPJlmhJ/IGAd36+nB3fO4Y=", - "dev": true - }, - "assert-plus": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-0.2.0.tgz", - "integrity": "sha1-104bh+ev/A24qttwIfP+SBAasjQ=", - "dev": true - }, - "async": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", - "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=" - }, - "asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", - "dev": true - }, - "aws-sign2": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.6.0.tgz", - "integrity": "sha1-FDQt0428yU0OW4fXY81jYSwOeU8=", - "dev": true - }, - "aws4": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.7.0.tgz", - "integrity": "sha512-32NDda82rhwD9/JBCCkB+MRYDp0oSvlo2IL6rQWA10PQi7tDUM3eqMSltXmY+Oyl/7N3P3qNtAlv7X0d9bI28w==", - "dev": true - }, - "balanced-match": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", - "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", - "dev": true - }, - "bcrypt-pbkdf": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.1.tgz", - "integrity": "sha1-Y7xdy2EzG5K8Bf1SiVPDNGKgb40=", - "dev": true, - "optional": true, - "requires": { - "tweetnacl": "^0.14.3" - } - }, - "boom": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/boom/-/boom-2.10.1.tgz", - "integrity": "sha1-OciRjO/1eZ+D+UkqhI9iWt0Mdm8=", - "dev": true, - "requires": { - "hoek": "2.x.x" - } - }, - "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "browser-stdout": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", - "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", - "dev": true - }, - "camelcase": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz", - "integrity": "sha1-m7UwTS4LVmmLLHWLCKPqqdqlijk=", - "dev": true, - "optional": true - }, - "caseless": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.11.0.tgz", - "integrity": "sha1-cVuW6phBWTzDMGeSP17GDr2k99c=", - "dev": true - }, - "center-align": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/center-align/-/center-align-0.1.3.tgz", - "integrity": "sha1-qg0yYptu6XIgBBHL1EYckHvCt60=", - "dev": true, - "optional": true, - "requires": { - "align-text": "^0.1.3", - "lazy-cache": "^1.0.3" - } - }, - "chalk": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", - "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", - "dev": true, - "requires": { - "ansi-styles": "^2.2.1", - "escape-string-regexp": "^1.0.2", - "has-ansi": "^2.0.0", - "strip-ansi": "^3.0.0", - "supports-color": "^2.0.0" - } - }, - "cli": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/cli/-/cli-1.0.1.tgz", - "integrity": "sha1-IoF1NPJL+klQw01TLUjsvGIbjBQ=", - "dev": true, - "requires": { - "exit": "0.1.2", - "glob": "^7.1.1" - }, - "dependencies": { - "glob": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", - "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - } - } - }, - "cliui": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-2.1.0.tgz", - "integrity": "sha1-S0dXYP+AJkx2LDoXGQMukcf+oNE=", - "dev": true, - "optional": true, - "requires": { - "center-align": "^0.1.1", - "right-align": "^0.1.1", - "wordwrap": "0.0.2" - }, - "dependencies": { - "wordwrap": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz", - "integrity": "sha1-t5Zpu0LstAn4PVg8rVLKF+qhZD8=", - "dev": true, - "optional": true - } - } - }, - "combined-stream": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.6.tgz", - "integrity": "sha1-cj599ugBrFYTETp+RFqbactjKBg=", - "dev": true, - "requires": { - "delayed-stream": "~1.0.0" - } - }, - "commander": { - "version": "2.15.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.15.1.tgz", - "integrity": "sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag==", - "dev": true - }, - "concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true - }, - "console-browserify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.1.0.tgz", - "integrity": "sha1-8CQcRXMKn8YyOyBtvzjtx0HQuxA=", - "dev": true, - "requires": { - "date-now": "^0.1.4" - } - }, - "core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", - "dev": true - }, - "coveralls": { - "version": "2.13.3", - "resolved": "https://registry.npmjs.org/coveralls/-/coveralls-2.13.3.tgz", - "integrity": "sha512-iiAmn+l1XqRwNLXhW8Rs5qHZRFMYp9ZIPjEOVRpC/c4so6Y/f4/lFi0FfR5B9cCqgyhkJ5cZmbvcVRfP8MHchw==", - "dev": true, - "requires": { - "js-yaml": "3.6.1", - "lcov-parse": "0.0.10", - "log-driver": "1.2.5", - "minimist": "1.2.0", - "request": "2.79.0" - } - }, - "cryptiles": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/cryptiles/-/cryptiles-2.0.5.tgz", - "integrity": "sha1-O9/s3GCBR8HGcgL6KR59ylnqo7g=", - "dev": true, - "requires": { - "boom": "2.x.x" - } - }, - "dashdash": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", - "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", - "dev": true, - "requires": { - "assert-plus": "^1.0.0" - }, - "dependencies": { - "assert-plus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", - "dev": true - } - } - }, - "date-now": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/date-now/-/date-now-0.1.4.tgz", - "integrity": "sha1-6vQ5/U1ISK105cx9vvIAZyueNFs=", - "dev": true - }, - "debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", - "dev": true, - "requires": { - "ms": "2.0.0" - } - }, - "decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=", - "dev": true, - "optional": true - }, - "deep-equal": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", - "integrity": "sha1-9dJgKStmDghO/0zbyfCK0yR0SLU=", - "dev": true - }, - "deep-is": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", - "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=" - }, - "delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", - "dev": true - }, - "diff": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", - "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", - "dev": true - }, - "dom-serializer": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.1.0.tgz", - "integrity": "sha1-BzxpdUbOB4DOI75KKOKT5AvDDII=", - "dev": true, - "requires": { - "domelementtype": "~1.1.1", - "entities": "~1.1.1" - }, - "dependencies": { - "domelementtype": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.1.3.tgz", - "integrity": "sha1-vSh3PiZCiBrsUVRJJCmcXNgiGFs=", - "dev": true - }, - "entities": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.1.tgz", - "integrity": "sha1-blwtClYhtdra7O+AuQ7ftc13cvA=", - "dev": true - } - } - }, - "domelementtype": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.0.tgz", - "integrity": "sha1-sXrtguirWeUt2cGbF1bg/BhyBMI=", - "dev": true - }, - "domhandler": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.3.0.tgz", - "integrity": "sha1-LeWaCCLVAn+r/28DLCsloqir5zg=", - "dev": true, - "requires": { - "domelementtype": "1" - } - }, - "domutils": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz", - "integrity": "sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8=", - "dev": true, - "requires": { - "dom-serializer": "0", - "domelementtype": "1" - } - }, - "ecc-jsbn": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz", - "integrity": "sha1-D8c6ntXw1Tw4GTOYUj735UN3dQU=", - "dev": true, - "optional": true, - "requires": { - "jsbn": "~0.1.0" - } - }, - "entities": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-1.0.0.tgz", - "integrity": "sha1-sph6o4ITR/zeZCsk/fyeT7cSvyY=", - "dev": true - }, - "escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", - "dev": true - }, - "escodegen": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.8.1.tgz", - "integrity": "sha1-WltTr0aTEQvrsIZ6o0MN07cKEBg=", - "dev": true, - "requires": { - "esprima": "^2.7.1", - "estraverse": "^1.9.1", - "esutils": "^2.0.2", - "optionator": "^0.8.1", - "source-map": "~0.2.0" - } - }, - "esprima": { - "version": "2.7.3", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-2.7.3.tgz", - "integrity": "sha1-luO3DVd59q1JzQMmc9HDEnZ7pYE=", - "dev": true - }, - "estraverse": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-1.9.3.tgz", - "integrity": "sha1-r2fy3JIlgkFZUJJgkaQAXSnJu0Q=", - "dev": true - }, - "esutils": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz", - "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=", - "dev": true - }, - "exit": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", - "integrity": "sha1-BjJjj42HfMghB9MKD/8aF8uhzQw=", - "dev": true - }, - "expect.js": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/expect.js/-/expect.js-0.3.1.tgz", - "integrity": "sha1-sKWaDS7/VDdUTr8M6qYBWEHQm1s=", - "dev": true - }, - "extend": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.1.tgz", - "integrity": "sha1-p1Xqe8Gt/MWjHOfnYtuq3F5jZEQ=", - "dev": true - }, - "extsprintf": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=", - "dev": true - }, - "fast-diff": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz", - "integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==", - "dev": true - }, - "fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", - "dev": true - }, - "forever-agent": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=", - "dev": true - }, - "form-data": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.1.4.tgz", - "integrity": "sha1-M8GDrPGTJ27KqYFDpp6Uv+4XUNE=", - "dev": true, - "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.5", - "mime-types": "^2.1.12" - } - }, - "fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", - "dev": true - }, - "generate-function": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.0.0.tgz", - "integrity": "sha1-aFj+fAlpt9TpCTM3ZHrHn2DfvnQ=", - "dev": true - }, - "generate-object-property": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/generate-object-property/-/generate-object-property-1.2.0.tgz", - "integrity": "sha1-nA4cQDCM6AT0eDYYuTf6iPmdUNA=", - "dev": true, - "requires": { - "is-property": "^1.0.0" - } - }, - "getpass": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", - "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", - "dev": true, - "requires": { - "assert-plus": "^1.0.0" - }, - "dependencies": { - "assert-plus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", - "dev": true - } - } - }, - "glob": { - "version": "5.0.15", - "resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz", - "integrity": "sha1-G8k2ueAvSmA/zCIuz3Yz0wuLk7E=", - "dev": true, - "requires": { - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "2 || 3", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "growl": { - "version": "1.10.5", - "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", - "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", - "dev": true - }, - "handlebars": { - "version": "4.0.11", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.0.11.tgz", - "integrity": "sha1-Ywo13+ApS8KB7a5v/F0yn8eYLcw=", - "dev": true, - "requires": { - "async": "^1.4.0", - "optimist": "^0.6.1", - "source-map": "^0.4.4", - "uglify-js": "^2.6" - }, - "dependencies": { - "source-map": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz", - "integrity": "sha1-66T12pwNyZneaAMti092FzZSA2s=", - "dev": true, - "requires": { - "amdefine": ">=0.0.4" - } - } - } - }, - "har-validator": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-2.0.6.tgz", - "integrity": "sha1-zcvAgYgmWtEZtqWnyKtw7s+10n0=", - "dev": true, - "requires": { - "chalk": "^1.1.1", - "commander": "^2.9.0", - "is-my-json-valid": "^2.12.4", - "pinkie-promise": "^2.0.0" - } - }, - "has-ansi": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", - "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", - "dev": true, - "requires": { - "ansi-regex": "^2.0.0" - } - }, - "has-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz", - "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo=", - "dev": true - }, - "hat": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/hat/-/hat-0.0.3.tgz", - "integrity": "sha1-uwFKnmSzeIrtgAWRdBPU/z1QLYo=" - }, - "hawk": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/hawk/-/hawk-3.1.3.tgz", - "integrity": "sha1-B4REvXwWQLD+VA0sm3PVlnjo4cQ=", - "dev": true, - "requires": { - "boom": "2.x.x", - "cryptiles": "2.x.x", - "hoek": "2.x.x", - "sntp": "1.x.x" - } - }, - "he": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz", - "integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0=", - "dev": true - }, - "hoek": { - "version": "2.16.3", - "resolved": "https://registry.npmjs.org/hoek/-/hoek-2.16.3.tgz", - "integrity": "sha1-ILt0A9POo5jpHcRxCo/xuCdKJe0=", - "dev": true - }, - "htmlparser2": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.8.3.tgz", - "integrity": "sha1-mWwosZFRaovoZQGn15dX5ccMEGg=", - "dev": true, - "requires": { - "domelementtype": "1", - "domhandler": "2.3", - "domutils": "1.5", - "entities": "1.0", - "readable-stream": "1.1" - } - }, - "http-signature": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.1.1.tgz", - "integrity": "sha1-33LiZwZs0Kxn+3at+OE0qPvPkb8=", - "dev": true, - "requires": { - "assert-plus": "^0.2.0", - "jsprim": "^1.2.2", - "sshpk": "^1.7.0" - } - }, - "inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", - "dev": true, - "requires": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "inherits": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", - "dev": true - }, - "is-buffer": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", - "dev": true - }, - "is-my-ip-valid": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-my-ip-valid/-/is-my-ip-valid-1.0.0.tgz", - "integrity": "sha512-gmh/eWXROncUzRnIa1Ubrt5b8ep/MGSnfAUI3aRp+sqTCs1tv1Isl8d8F6JmkN3dXKc3ehZMrtiPN9eL03NuaQ==", - "dev": true - }, - "is-my-json-valid": { - "version": "2.17.2", - "resolved": "https://registry.npmjs.org/is-my-json-valid/-/is-my-json-valid-2.17.2.tgz", - "integrity": "sha512-IBhBslgngMQN8DDSppmgDv7RNrlFotuuDsKcrCP3+HbFaVivIBU7u9oiiErw8sH4ynx3+gOGQ3q2otkgiSi6kg==", - "dev": true, - "requires": { - "generate-function": "^2.0.0", - "generate-object-property": "^1.1.0", - "is-my-ip-valid": "^1.0.0", - "jsonpointer": "^4.0.0", - "xtend": "^4.0.0" - } - }, - "is-property": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", - "integrity": "sha1-V/4cTkhHTt1lsJkR8msc1Ald2oQ=", - "dev": true - }, - "is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", - "dev": true - }, - "isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", - "dev": true - }, - "isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", - "dev": true - }, - "isstream": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=", - "dev": true - }, - "istanbul": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/istanbul/-/istanbul-0.4.5.tgz", - "integrity": "sha1-ZcfXPUxNqE1POsMQuRj7C4Azczs=", - "dev": true, - "requires": { - "abbrev": "1.0.x", - "async": "1.x", - "escodegen": "1.8.x", - "esprima": "2.7.x", - "glob": "^5.0.15", - "handlebars": "^4.0.1", - "js-yaml": "3.x", - "mkdirp": "0.5.x", - "nopt": "3.x", - "once": "1.x", - "resolve": "1.1.x", - "supports-color": "^3.1.0", - "which": "^1.1.1", - "wordwrap": "^1.0.0" - }, - "dependencies": { - "supports-color": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz", - "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=", - "dev": true, - "requires": { - "has-flag": "^1.0.0" - } - } - } - }, - "js-yaml": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.6.1.tgz", - "integrity": "sha1-bl/mfYsgXOTSL60Ft3geja3MSzA=", - "dev": true, - "requires": { - "argparse": "^1.0.7", - "esprima": "^2.6.0" - } - }, - "jsbn": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", - "dev": true, - "optional": true - }, - "jshint": { - "version": "2.9.5", - "resolved": "https://registry.npmjs.org/jshint/-/jshint-2.9.5.tgz", - "integrity": "sha1-HnJSkVzmgbQIJ+4UJIxG006apiw=", - "dev": true, - "requires": { - "cli": "~1.0.0", - "console-browserify": "1.1.x", - "exit": "0.1.x", - "htmlparser2": "3.8.x", - "lodash": "3.7.x", - "minimatch": "~3.0.2", - "shelljs": "0.3.x", - "strip-json-comments": "1.0.x" - } - }, - "json-schema": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", - "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=", - "dev": true - }, - "json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", - "dev": true - }, - "jsonpointer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-4.0.1.tgz", - "integrity": "sha1-T9kss04OnbPInIYi7PUfm5eMbLk=", - "dev": true - }, - "jsprim": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", - "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", - "dev": true, - "requires": { - "assert-plus": "1.0.0", - "extsprintf": "1.3.0", - "json-schema": "0.2.3", - "verror": "1.10.0" - }, - "dependencies": { - "assert-plus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", - "dev": true - } - } - }, - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - }, - "lazy-cache": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz", - "integrity": "sha1-odePw6UEdMuAhF07O24dpJpEbo4=", - "dev": true, - "optional": true - }, - "lcov-parse": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/lcov-parse/-/lcov-parse-0.0.10.tgz", - "integrity": "sha1-GwuP+ayceIklBYK3C3ExXZ2m2aM=", - "dev": true - }, - "levn": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", - "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=", - "dev": true, - "requires": { - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2" - } - }, - "lodash": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.7.0.tgz", - "integrity": "sha1-Nni9irmVBXwHreg27S7wh9qBHUU=", - "dev": true - }, - "log-driver": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/log-driver/-/log-driver-1.2.5.tgz", - "integrity": "sha1-euTsJXMC/XkNVXyxDJcQDYV7AFY=", - "dev": true - }, - "lolex": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/lolex/-/lolex-2.7.1.tgz", - "integrity": "sha512-Oo2Si3RMKV3+lV5MsSWplDQFoTClz/24S0MMHYcgGWWmFXr6TMlqcqk/l1GtH+d5wLBwNRiqGnwDRMirtFalJw==", - "dev": true - }, - "longest": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz", - "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=", - "dev": true - }, - "make-error": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.4.tgz", - "integrity": "sha512-0Dab5btKVPhibSalc9QGXb559ED7G7iLjFXBaj9Wq8O3vorueR5K5jaE3hkG6ZQINyhA/JgG6Qk4qdFQjsYV6g==" - }, - "mime-db": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", - "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==", - "dev": true - }, - "mime-types": { - "version": "2.1.18", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz", - "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", - "dev": true, - "requires": { - "mime-db": "~1.33.0" - } - }, - "mingo": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/mingo/-/mingo-2.2.2.tgz", - "integrity": "sha1-vmnUhq5uCsVLl53F9EEtshhR9pM=", - "dev": true - }, - "minimatch": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", - "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", - "dev": true, - "requires": { - "brace-expansion": "^1.1.7" - } - }, - "minimist": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", - "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", - "dev": true - }, - "mkdirp": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", - "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", - "dev": true, - "requires": { - "minimist": "0.0.8" - }, - "dependencies": { - "minimist": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", - "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", - "dev": true - } - } - }, - "mocha": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-5.2.0.tgz", - "integrity": "sha512-2IUgKDhc3J7Uug+FxMXuqIyYzH7gJjXECKe/w43IGgQHTSj3InJi+yAA7T24L9bQMRKiUEHxEX37G5JpVUGLcQ==", - "dev": true, - "requires": { - "browser-stdout": "1.3.1", - "commander": "2.15.1", - "debug": "3.1.0", - "diff": "3.5.0", - "escape-string-regexp": "1.0.5", - "glob": "7.1.2", - "growl": "1.10.5", - "he": "1.1.1", - "minimatch": "3.0.4", - "mkdirp": "0.5.1", - "supports-color": "5.4.0" - }, - "dependencies": { - "glob": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", - "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", - "dev": true - }, - "supports-color": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz", - "integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==", - "dev": true, - "requires": { - "has-flag": "^3.0.0" - } - } - } - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true - }, - "nopt": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz", - "integrity": "sha1-xkZdvwirzU2zWTF/eaxopkayj/k=", - "dev": true, - "requires": { - "abbrev": "1" - } - }, - "oauth-sign": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz", - "integrity": "sha1-Rqarfwrq2N6unsBWV4C31O/rnUM=", - "dev": true - }, - "once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "dev": true, - "requires": { - "wrappy": "1" - } - }, - "optimist": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", - "integrity": "sha1-2j6nRob6IaGaERwybpDrFaAZZoY=", - "dev": true, - "requires": { - "minimist": "~0.0.1", - "wordwrap": "~0.0.2" - }, - "dependencies": { - "minimist": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz", - "integrity": "sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8=", - "dev": true - }, - "wordwrap": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", - "integrity": "sha1-o9XabNXAvAAI03I0u68b7WMFkQc=", - "dev": true - } - } - }, - "optionator": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.2.tgz", - "integrity": "sha1-NkxeQJ0/TWMB1sC0wFu6UBgK62Q=", - "dev": true, - "requires": { - "deep-is": "~0.1.3", - "fast-levenshtein": "~2.0.4", - "levn": "~0.3.0", - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2", - "wordwrap": "~1.0.0" - } - }, - "ot-json0": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/ot-json0/-/ot-json0-1.1.0.tgz", - "integrity": "sha512-wf5fci7GGpMYRDnbbdIFQymvhsbFACMHtxjivQo5KgvAHlxekyfJ9aPsRr6YfFQthQkk4bmsl5yESrZwC/oMYQ==" - }, - "ot-text": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ot-text/-/ot-text-1.0.1.tgz", - "integrity": "sha1-P4UPbuhYvDbvRayapR0Gx354388=", - "dev": true - }, - "path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", - "dev": true - }, - "pinkie": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", - "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=", - "dev": true - }, - "pinkie-promise": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", - "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=", - "dev": true, - "requires": { - "pinkie": "^2.0.0" - } - }, - "prelude-ls": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", - "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=", - "dev": true - }, - "punycode": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", - "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=", - "dev": true - }, - "qs": { - "version": "6.3.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.3.2.tgz", - "integrity": "sha1-51vV9uJoEioqDgvaYwslUMFmUCw=", - "dev": true - }, - "quill-delta": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.2.tgz", - "integrity": "sha512-grWEQq9woEidPDogtDNxQKmy2LFf9zBC0EU/YTSw6TwKmMjtihTxdnPtPRfrqazB2MSJ7YdCWxmsJ7aQKRSEgg==", - "dev": true, - "requires": { - "deep-equal": "^1.0.1", - "extend": "^3.0.1", - "fast-diff": "1.1.2" - } - }, - "readable-stream": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", - "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=", - "dev": true, - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.1", - "isarray": "0.0.1", - "string_decoder": "~0.10.x" - } - }, - "repeat-string": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", - "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=", - "dev": true - }, - "request": { - "version": "2.79.0", - "resolved": "https://registry.npmjs.org/request/-/request-2.79.0.tgz", - "integrity": "sha1-Tf5b9r6LjNw3/Pk+BLZVd3InEN4=", - "dev": true, - "requires": { - "aws-sign2": "~0.6.0", - "aws4": "^1.2.1", - "caseless": "~0.11.0", - "combined-stream": "~1.0.5", - "extend": "~3.0.0", - "forever-agent": "~0.6.1", - "form-data": "~2.1.1", - "har-validator": "~2.0.6", - "hawk": "~3.1.3", - "http-signature": "~1.1.0", - "is-typedarray": "~1.0.0", - "isstream": "~0.1.2", - "json-stringify-safe": "~5.0.1", - "mime-types": "~2.1.7", - "oauth-sign": "~0.8.1", - "qs": "~6.3.0", - "stringstream": "~0.0.4", - "tough-cookie": "~2.3.0", - "tunnel-agent": "~0.4.1", - "uuid": "^3.0.0" - } - }, - "resolve": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.1.7.tgz", - "integrity": "sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs=", - "dev": true - }, - "rich-text": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/rich-text/-/rich-text-3.1.0.tgz", - "integrity": "sha1-BMlx3tzo64IBDPrP9uegzjXHqCU=", - "dev": true, - "requires": { - "quill-delta": "^3.2.0" - } - }, - "right-align": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/right-align/-/right-align-0.1.3.tgz", - "integrity": "sha1-YTObci/mo1FWiSENJOFMlhSGE+8=", - "dev": true, - "optional": true, - "requires": { - "align-text": "^0.1.1" - } - }, - "safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true - }, - "sharedb": { - "version": "1.0.0-beta.9", - "resolved": "https://registry.npmjs.org/sharedb/-/sharedb-1.0.0-beta.9.tgz", - "integrity": "sha1-LX20J83hIJJNLasIzpLZq6134As=", - "dev": true, - "requires": { - "arraydiff": "^0.1.1", - "async": "^1.4.2", - "deep-is": "^0.1.3", - "hat": "0.0.3", - "make-error": "^1.1.1", - "ot-json0": "^1.0.1" - } - }, - "sharedb-mingo-memory": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/sharedb-mingo-memory/-/sharedb-mingo-memory-1.0.0.tgz", - "integrity": "sha1-vS5171YTCrheE5uMlMVSFv4+TQM=", - "dev": true, - "requires": { - "mingo": "^2.2.0", - "sharedb": "^1.0.0-beta" - } - }, - "shelljs": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.3.0.tgz", - "integrity": "sha1-NZbmMHp4FUT1kfN9phg2DzHbV7E=", - "dev": true - }, - "sntp": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/sntp/-/sntp-1.0.9.tgz", - "integrity": "sha1-ZUEYTMkK7qbG57NeJlkIJEPGYZg=", - "dev": true, - "requires": { - "hoek": "2.x.x" - } - }, - "source-map": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.2.0.tgz", - "integrity": "sha1-2rc/vPwrqBm03gO9b26qSBZLP50=", - "dev": true, - "optional": true, - "requires": { - "amdefine": ">=0.0.4" - } - }, - "sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", - "dev": true - }, - "sshpk": { - "version": "1.14.2", - "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.14.2.tgz", - "integrity": "sha1-xvxhZIo9nE52T9P8306hBeSSupg=", - "dev": true, - "requires": { - "asn1": "~0.2.3", - "assert-plus": "^1.0.0", - "bcrypt-pbkdf": "^1.0.0", - "dashdash": "^1.12.0", - "ecc-jsbn": "~0.1.1", - "getpass": "^0.1.1", - "jsbn": "~0.1.0", - "safer-buffer": "^2.0.2", - "tweetnacl": "~0.14.0" - }, - "dependencies": { - "assert-plus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", - "dev": true - } - } - }, - "string_decoder": { - "version": "0.10.31", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", - "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=", - "dev": true - }, - "stringstream": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/stringstream/-/stringstream-0.0.6.tgz", - "integrity": "sha512-87GEBAkegbBcweToUrdzf3eLhWNg06FJTebl4BVJz/JgWy8CvEr9dRtX5qWphiynMSQlxxi+QqN0z5T32SLlhA==", - "dev": true - }, - "strip-ansi": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", - "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", - "dev": true, - "requires": { - "ansi-regex": "^2.0.0" - } - }, - "strip-json-comments": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-1.0.4.tgz", - "integrity": "sha1-HhX7ysl9Pumb8tc7TGVrCCu6+5E=", - "dev": true - }, - "supports-color": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", - "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", - "dev": true - }, - "tough-cookie": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.4.tgz", - "integrity": "sha512-TZ6TTfI5NtZnuyy/Kecv+CnoROnyXn2DN97LontgQpCwsX2XyLYCC0ENhYkehSOwAp8rTQKc/NUIF7BkQ5rKLA==", - "dev": true, - "requires": { - "punycode": "^1.4.1" - } - }, - "tunnel-agent": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.4.3.tgz", - "integrity": "sha1-Y3PbdpCf5XDgjXNYM2Xtgop07us=", - "dev": true - }, - "tweetnacl": { - "version": "0.14.5", - "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", - "dev": true, - "optional": true - }, - "type-check": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", - "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=", - "dev": true, - "requires": { - "prelude-ls": "~1.1.2" - } - }, - "uglify-js": { - "version": "2.8.29", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.8.29.tgz", - "integrity": "sha1-KcVzMUgFe7Th913zW3qcty5qWd0=", - "dev": true, - "optional": true, - "requires": { - "source-map": "~0.5.1", - "uglify-to-browserify": "~1.0.0", - "yargs": "~3.10.0" - }, - "dependencies": { - "source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", - "dev": true, - "optional": true - } - } - }, - "uglify-to-browserify": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz", - "integrity": "sha1-bgkk1r2mta/jSeOabWMoUKD4grc=", - "dev": true, - "optional": true - }, - "uuid": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.2.1.tgz", - "integrity": "sha512-jZnMwlb9Iku/O3smGWvZhauCf6cvvpKi4BKRiliS3cxnI+Gz9j5MEpTz2UFuXiKPJocb7gnsLHwiS05ige5BEA==", - "dev": true - }, - "verror": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", - "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", - "dev": true, - "requires": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" - }, - "dependencies": { - "assert-plus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", - "dev": true - } - } - }, - "which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, - "requires": { - "isexe": "^2.0.0" - } - }, - "window-size": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz", - "integrity": "sha1-VDjNLqk7IC76Ohn+iIeu58lPnJ0=", - "dev": true, - "optional": true - }, - "wordwrap": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", - "integrity": "sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=", - "dev": true - }, - "wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "dev": true - }, - "xtend": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz", - "integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68=", - "dev": true - }, - "yargs": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.10.0.tgz", - "integrity": "sha1-9+572FfdfB0tOMDnTvvWgdFDH9E=", - "dev": true, - "optional": true, - "requires": { - "camelcase": "^1.0.2", - "cliui": "^2.1.0", - "decamelize": "^1.0.0", - "window-size": "0.1.0" - } - } - } -} diff --git a/package.json b/package.json index 055267de5..2e34445e8 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "lib/index.js", "dependencies": { "arraydiff": "^0.1.1", - "async": "^1.4.2", + "async": "^2.6.1", "deep-is": "^0.1.3", "hat": "0.0.3", "make-error": "^1.1.1", @@ -13,7 +13,7 @@ }, "devDependencies": { "@teamwork/ot-rich-text": "^6.3.3", - "coveralls": "^2.11.8", + "coveralls": "^3.0.2", "expect.js": "^0.3.1", "istanbul": "^0.4.2", "jshint": "^2.9.2", @@ -21,7 +21,7 @@ "mocha": "^5.2.0", "ot-text": "^1.0.1", "rich-text": "^3.1.0", - "sharedb-mingo-memory": "^1.0.0-beta" + "sharedb-mingo-memory": "^1.0.1" }, "scripts": { "test": "./node_modules/.bin/mocha && npm run jshint", From bfba7c0c346bc96e0c464c04d27f2dcad1f83ea7 Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Thu, 19 Jul 2018 16:59:01 +0200 Subject: [PATCH 37/45] Clean up after merge conflict --- test/client/submit.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/client/submit.js b/test/client/submit.js index 6b49f12d7..69a98aabd 100644 --- a/test/client/submit.js +++ b/test/client/submit.js @@ -620,7 +620,6 @@ describe('client submit', function() { expect(doc.data).eql(undefined); calledBack = true; }); - doc.submitOp({p: ['age'], na: 1}); doc.fetch(); }); }); @@ -650,7 +649,6 @@ describe('client submit', function() { expect(doc.data).eql({age: 5}); calledBack = true; }); - doc.create({age: 9}); doc.fetch(); }); }); From c7170d711f6a1fa4e070b5cb49fe4c9b42062825 Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Thu, 19 Jul 2018 22:29:33 +0200 Subject: [PATCH 38/45] Transform by undo and redo ops --- lib/client/doc.js | 26 +++-- lib/client/undoManager.js | 61 +++++------ test/client/undo-redo.js | 218 +++++++++++++++++++++++++++++++++++++- 3 files changed, 253 insertions(+), 52 deletions(-) diff --git a/lib/client/doc.js b/lib/client/doc.js index 2df6b0242..a79a956d9 100644 --- a/lib/client/doc.js +++ b/lib/client/doc.js @@ -519,7 +519,7 @@ Doc.prototype._otApply = function(op, options) { var err = new ShareDBError(4015, 'Cannot apply op to uncreated document. ' + this.collection + '.' + this.id); return this.emit('error', err); } - var undoOp = options && options.undoOp && options.undoOp.op || null; + var undoOp = options && options.undoOp || null; var undoable = options && options.undoable || false; var fixUp = options && options.fixUp || false; @@ -553,7 +553,7 @@ Doc.prototype._otApply = function(op, options) { } // Apply the individual op component this.emit('before op', componentOp.op, source); - this._applyOp(componentOp.op, undoOp, source, undoable, fixUp); + this._applyOp(componentOp, undoOp, source, undoable, fixUp); this.emit('op', componentOp.op, source); } // Pop whatever was submitted since we started applying this op @@ -565,7 +565,7 @@ Doc.prototype._otApply = function(op, options) { // the snapshot before it gets changed this.emit('before op', op.op, source); // Apply the operation to the local data, mutating it in place - this._applyOp(op.op, undoOp, source, undoable, fixUp); + this._applyOp(op, undoOp, source, undoable, fixUp); // Emit an 'op' event once the local data includes the changes from the // op. For locally submitted ops, this will be synchronously with // submission and before the server or other clients have received the op. @@ -598,17 +598,17 @@ Doc.prototype._otApply = function(op, options) { // Applies `op` to `this.data` and updates the undo/redo stacks. Doc.prototype._applyOp = function(op, undoOp, source, undoable, fixUp) { - if (undoOp == null && (undoable || fixUp)) { + if (undoOp == null && (undoable || fixUp || op.needsUndoOp)) { if (this.type.applyAndInvert) { - var result = this.type.applyAndInvert(this.data, op); + var result = this.type.applyAndInvert(this.data, op.op); this.data = result[0]; - undoOp = result[1]; + undoOp = { op: result[1] }; } else { - this.data = this.type.apply(this.data, op); - undoOp = this.type.invert(op); + this.data = this.type.apply(this.data, op.op); + undoOp = { op: this.type.invert(op.op) }; } } else { - this.data = this.type.apply(this.data, op); + this.data = this.type.apply(this.data, op.op); } this.connection.onDocOp(this, op, undoOp, source, undoable, fixUp); @@ -682,7 +682,7 @@ Doc.prototype._submit = function(op, options, callback) { } var undoable = options && options.undoable; var fixUp = options && options.fixUp; - var needsUndoOp = undoable || fixUp; + var needsUndoOp = undoable || fixUp || op.needsUndoOp; if (needsUndoOp && !this.type.invert && !this.type.applyAndInvert) { var err = new ShareDBError(4025, 'Cannot submit op. OT type does not support invert not applyAndInvert. ' + this.collection + '.' + this.id); if (callback) return callback(err); @@ -789,8 +789,7 @@ Doc.prototype._tryCompose = function(op) { // @param options.skipNoop should processing be skipped entirely, if `component` is a no-op. // @param options.undoable should the operation be undoable // @param options.fixUp If true, this operation is meant to fix the current invalid state of the snapshot. -// It also updates UndoManagers in such a way that undo/redo will "skip" over the current state of the snapshot. -// This feature requires the OT type to implement `compose`. +// It also updates UndoManagers accordingly. This feature requires the OT type to implement `compose`. // @param [callback] called after operation submitted // // @fires before op, op @@ -820,8 +819,7 @@ Doc.prototype.submitOp = function(component, options, callback) { // @param options.skipNoop should processing be skipped entirely, if the generated operation is a no-op. // @param options.undoable should the operation be undoable // @param options.fixUp If true, this operation is meant to fix the current invalid state of the snapshot. -// It also updates UndoManagers in such a way that undo/redo will "skip" over the current state of the snapshot. -// This feature requires the OT type to implement `compose`. +// It also updates UndoManagers accordingly. This feature requires the OT type to implement `compose`. // @param options.diffHint a hint passed into diff/diffX // @param [callback] called after operation submitted diff --git a/lib/client/undoManager.js b/lib/client/undoManager.js index d874d9762..372caab1c 100644 --- a/lib/client/undoManager.js +++ b/lib/client/undoManager.js @@ -21,9 +21,10 @@ function setLast(list, item) { list[lastIndex] = item; } -function Item(op, doc) { +function Op(op, doc) { this.op = op; this.doc = doc; + this.needsUndoOp = true; } // Manages an undo/redo stack for all operations from the specified `source`. @@ -51,10 +52,6 @@ function UndoManager(connection, options) { // The timestamp of the previous reversible operation. Used to determine if // the next reversible operation can be composed on the undoStack. this._previousUndoableOperationTime = -Infinity; - - // The type of operation that is currently in progress. - // It depends on the `op` event being triggered synchronously when submitting an operation or snapshot. - this._operationInProgress = null; } UndoManager.prototype.destroy = function() { @@ -97,12 +94,8 @@ UndoManager.prototype.undo = function(options, callback) { return; } - this._operationInProgress = 'undo'; - var op = this._undoStack.pop(); - var submitOptions = { - source: options && options.source, - undoable: true - }; + var op = getLast(this._undoStack); + var submitOptions = { source: options && options.source }; op.doc._submit(op, submitOptions, callback); }; @@ -127,12 +120,8 @@ UndoManager.prototype.redo = function(options, callback) { return; } - this._operationInProgress = 'redo'; - var op = this._redoStack.pop(); - var submitOptions = { - source: options && options.source, - undoable: true - }; + var op = getLast(this._redoStack); + var submitOptions = { source: options && options.source }; op.doc._submit(op, submitOptions, callback); }; @@ -163,19 +152,19 @@ UndoManager.prototype.onDocDelete = function(doc) { }; UndoManager.prototype.onDocOp = function(doc, op, undoOp, source, undoable, fixUp) { - if (this._operationInProgress === 'undo') { - this._updateStacksUndo(doc, op, undoOp); - this._operationInProgress = null; + if (this.canUndo() && getLast(this._undoStack) === op) { + this._undoStack.pop(); + this._updateStacksUndo(doc, op.op, undoOp.op); - } else if (this._operationInProgress === 'redo') { - this._updateStacksRedo(doc, op, undoOp); - this._operationInProgress = null; + } else if (this.canRedo() && getLast(this._redoStack) === op) { + this._redoStack.pop(); + this._updateStacksRedo(doc, op.op, undoOp.op); } else if (!fixUp && undoable && (this._source == null || this._source === source)) { - this._updateStacksUndoable(doc, op, undoOp); + this._updateStacksUndoable(doc, op.op, undoOp.op); } else { - this._updateStacksFixed(doc, op, undoOp, fixUp); + this._updateStacksFixed(doc, op.op, undoOp && undoOp.op, fixUp); } }; @@ -187,24 +176,24 @@ UndoManager.prototype._updateStacksUndoable = function(doc, op, undoOp) { getLast(this._undoStack).doc !== doc || now - this._previousUndoableOperationTime > this._composeTimeout ) { - this._undoStack.push(new Item(undoOp, doc)); + this._undoStack.push(new Op(undoOp, doc)); } else if (doc.type.composeSimilar) { var lastOp = getLast(this._undoStack); var composedOp = doc.type.composeSimilar(undoOp, lastOp.op); if (composedOp != null) { - setLast(this._undoStack, new Item(composedOp, doc)); + setLast(this._undoStack, new Op(composedOp, doc)); } else { - this._undoStack.push(new Item(undoOp, doc)); + this._undoStack.push(new Op(undoOp, doc)); } } else if (doc.type.compose) { var lastOp = getLast(this._undoStack); var composedOp = doc.type.compose(undoOp, lastOp.op); - setLast(this._undoStack, new Item(composedOp, doc)); + setLast(this._undoStack, new Op(composedOp, doc)); } else { - this._undoStack.push(new Item(undoOp, doc)); + this._undoStack.push(new Op(undoOp, doc)); } this._redoStack.length = 0; @@ -224,7 +213,7 @@ UndoManager.prototype._updateStacksUndoable = function(doc, op, undoOp) { UndoManager.prototype._updateStacksUndo = function(doc, op, undoOp) { /* istanbul ignore else */ if (!doc.type.isNoop || !doc.type.isNoop(undoOp)) { - this._redoStack.push(new Item(undoOp, doc)); + this._redoStack.push(new Op(undoOp, doc)); } this._previousUndoableOperationTime = -Infinity; }; @@ -232,19 +221,19 @@ UndoManager.prototype._updateStacksUndo = function(doc, op, undoOp) { UndoManager.prototype._updateStacksRedo = function(doc, op, undoOp) { /* istanbul ignore else */ if (!doc.type.isNoop || !doc.type.isNoop(undoOp)) { - this._undoStack.push(new Item(undoOp, doc)); + this._undoStack.push(new Op(undoOp, doc)); } this._previousUndoableOperationTime = -Infinity; }; UndoManager.prototype._updateStacksFixed = function(doc, op, undoOp, fixUp) { - if (fixUp && undoOp && doc.type.compose) { + if (fixUp && undoOp != null && doc.type.compose) { var lastUndoIndex = findLastIndex(this._undoStack, doc); if (lastUndoIndex >= 0) { var lastOp = this._undoStack[lastUndoIndex]; var composedOp = doc.type.compose(undoOp, lastOp.op); if (!doc.type.isNoop || !doc.type.isNoop(composedOp)) { - this._undoStack[lastUndoIndex] = new Item(composedOp, doc); + this._undoStack[lastUndoIndex] = new Op(composedOp, doc); } else { this._undoStack.splice(lastUndoIndex, 1); } @@ -255,7 +244,7 @@ UndoManager.prototype._updateStacksFixed = function(doc, op, undoOp, fixUp) { var lastOp = this._redoStack[lastRedoIndex]; var composedOp = doc.type.compose(undoOp, lastOp.op); if (!doc.type.isNoop || !doc.type.isNoop(composedOp)) { - this._redoStack[lastRedoIndex] = new Item(composedOp, doc); + this._redoStack[lastRedoIndex] = new Op(composedOp, doc); } else { this._redoStack.splice(lastRedoIndex, 1); } @@ -294,7 +283,7 @@ UndoManager.prototype._transformStack = function(stack, doc, op) { } if (!isNoop || !isNoop(transformedStackOp)) { - newStack[newStackIndex++] = new Item(transformedStackOp, doc); + newStack[newStackIndex++] = new Op(transformedStackOp, doc); } op = transformedOp; diff --git a/test/client/undo-redo.js b/test/client/undo-redo.js index 66f632600..075df3df4 100644 --- a/test/client/undo-redo.js +++ b/test/client/undo-redo.js @@ -652,7 +652,7 @@ describe('client undo/redo', function() { }.bind(this)); }); - it('transforms the stacks by a local FIXED operation', function() { + it('transforms the stacks by a local operation', function() { var undoManager = this.connection.undoManager({ composeTimeout: -1 }); this.doc.create([], otRichText.type.uri); this.doc.submitOp([ otRichText.Action.createInsertText('4') ], { undoable: true }); @@ -677,7 +677,7 @@ describe('client undo/redo', function() { expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC1234') ]); }); - it('transforms the stacks by a local FIXED operation and removes no-ops', function() { + it('transforms the stacks by a local operation and removes no-ops', function() { var undoManager = this.connection.undoManager({ composeTimeout: -1 }); this.doc.create([], otRichText.type.uri); this.doc.submitOp([ otRichText.Action.createInsertText('4') ], { undoable: true }); @@ -700,6 +700,102 @@ describe('client undo/redo', function() { expect(undoManager.canRedo()).to.equal(false); }); + it('transforms stacks by an undoable op', function() { + var undoManager = this.connection.undoManager({ composeTimeout: -1, source: '1' }); + this.doc.create([], otRichText.type.uri); + this.doc.submitOp([ otRichText.Action.createInsertText('4') ], { undoable: true, source: '1' }); + this.doc.submitOp([ otRichText.Action.createInsertText('3') ], { undoable: true, source: '1' }); + this.doc.submitOp([ otRichText.Action.createInsertText('2') ], { undoable: true, source: '1' }); + this.doc.submitOp([ otRichText.Action.createInsertText('1') ], { undoable: true, source: '1' }); + undoManager.undo(); + undoManager.undo(); + + // The source does not match, so undoManager transforms its stacks rather than pushing this op on its undo stack. + this.doc.submitOp([ otRichText.Action.createInsertText('ABC') ], { undoable: true }); + + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC34') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC4') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC4') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC34') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC234') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC1234') ]); + }); + + it('transforms stacks by an undo op', function() { + var undoManager = this.connection.undoManager({ composeTimeout: -1, source: '1' }); + var undoManager2 = this.connection.undoManager({ composeTimeout: -1, source: '2' }); + this.doc.create([], otRichText.type.uri); + this.doc.submitOp([ otRichText.Action.createInsertText('4') ], { undoable: true, source: '1' }); + this.doc.submitOp([ otRichText.Action.createInsertText('3') ], { undoable: true, source: '1' }); + this.doc.submitOp([ otRichText.Action.createInsertText('2') ], { undoable: true, source: '1' }); + this.doc.submitOp([ otRichText.Action.createInsertText('1') ], { undoable: true, source: '1' }); + undoManager.undo(); + undoManager.undo(); + + // These 2 ops cancel each other out, so the undoManager's stacks remain unaffected, + // even though they are transformed against those ops. + // The second op has `source: '2'`, so it is inverted and added to the undo stack of undoManager2. + this.doc.submitOp([ otRichText.Action.createInsertText('ABC') ], { undoable: true }); + this.doc.submitOp([ otRichText.Action.createDelete(3) ], { undoable: true, source: '2' }); + // This inserts ABC at position 0 and the undoManager's stacks are transformed accordingly, ready for testing. + undoManager2.undo(); + + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC34') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC4') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC4') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC34') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC234') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC1234') ]); + }); + + it('transforms stacks by a redo op', function() { + var undoManager = this.connection.undoManager({ composeTimeout: -1, source: '1' }); + var undoManager2 = this.connection.undoManager({ composeTimeout: -1, source: '2' }); + this.doc.create([], otRichText.type.uri); + this.doc.submitOp([ otRichText.Action.createInsertText('4') ], { undoable: true, source: '1' }); + this.doc.submitOp([ otRichText.Action.createInsertText('3') ], { undoable: true, source: '1' }); + this.doc.submitOp([ otRichText.Action.createInsertText('2') ], { undoable: true, source: '1' }); + this.doc.submitOp([ otRichText.Action.createInsertText('1') ], { undoable: true, source: '1' }); + undoManager.undo(); + undoManager.undo(); + + // submitOp and undo cancel each other out, so the undoManager's stacks remain unaffected, + // even though they are transformed against those ops. + // The second op has `source: '2'`, so it is inverted and added to the undo stack of undoManager2. + this.doc.submitOp([ otRichText.Action.createInsertText('ABC') ], { undoable: true, source: '2' }); + undoManager2.undo(); + // This inserts ABC at position 0 and the undoManager's stacks are transformed accordingly, ready for testing. + undoManager2.redo(); + + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC34') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC4') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC4') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC34') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC234') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC1234') ]); + }); + it('transforms the stacks using transform', function() { var undoManager = this.connection.undoManager({ composeTimeout: -1 }); this.doc.create(0, invertibleType.type.uri); @@ -979,6 +1075,124 @@ describe('client undo/redo', function() { this.redo().assert('bcd'); this.redo().assert('abcd'); }); + + it('fixes up ops if both fixUp and undoable are true', function() { + this.submitOp('!', { undoable: true, fixUp: true }).assert('!cd'); + this.undo().assert('d'); + this.undo().assert(''); + this.redo().assert('d'); + this.redo().assert('!cd'); + this.redo().assert('bcd'); + this.redo().assert('abcd'); + }); + }); + + it('filters undo/redo ops by source', function() { + var undoManager1 = this.connection.undoManager({ composeTimeout: -1, source: '1' }); + var undoManager2 = this.connection.undoManager({ composeTimeout: -1, source: '2' }); + + this.doc.create({ test: 5 }); + expect(this.doc.data.test).to.equal(5); + expect(undoManager1.canUndo()).to.equal(false); + expect(undoManager1.canRedo()).to.equal(false); + expect(undoManager2.canUndo()).to.equal(false); + expect(undoManager2.canRedo()).to.equal(false); + + this.doc.submitOp([{ p: [ 'test' ], na: 2 }], { undoable: true }); + expect(this.doc.data.test).to.equal(7); + expect(undoManager1.canUndo()).to.equal(false); + expect(undoManager1.canRedo()).to.equal(false); + expect(undoManager2.canUndo()).to.equal(false); + expect(undoManager2.canRedo()).to.equal(false); + + this.doc.submitOp([{ p: [ 'test' ], na: 2 }], { undoable: true, source: '3' }); + expect(this.doc.data.test).to.equal(9); + expect(undoManager1.canUndo()).to.equal(false); + expect(undoManager1.canRedo()).to.equal(false); + expect(undoManager2.canUndo()).to.equal(false); + expect(undoManager2.canRedo()).to.equal(false); + + this.doc.submitOp([{ p: [ 'test' ], na: 7 }], { undoable: true, source: '1' }); + expect(this.doc.data.test).to.equal(16); + expect(undoManager1.canUndo()).to.equal(true); + expect(undoManager1.canRedo()).to.equal(false); + expect(undoManager2.canUndo()).to.equal(false); + expect(undoManager2.canRedo()).to.equal(false); + + this.doc.submitOp([{ p: [ 'test' ], na: 7 }], { undoable: true, source: '1' }); + expect(this.doc.data.test).to.equal(23); + expect(undoManager1.canUndo()).to.equal(true); + expect(undoManager1.canRedo()).to.equal(false); + expect(undoManager2.canUndo()).to.equal(false); + expect(undoManager2.canRedo()).to.equal(false); + + this.doc.submitOp([{ p: [ 'test' ], na: 13 }], { undoable: true, source: '2' }); + expect(this.doc.data.test).to.equal(36); + expect(undoManager1.canUndo()).to.equal(true); + expect(undoManager1.canRedo()).to.equal(false); + expect(undoManager2.canUndo()).to.equal(true); + expect(undoManager2.canRedo()).to.equal(false); + + this.doc.submitOp([{ p: [ 'test' ], na: 13 }], { undoable: true, source: '2' }); + expect(this.doc.data.test).to.equal(49); + expect(undoManager1.canUndo()).to.equal(true); + expect(undoManager1.canRedo()).to.equal(false); + expect(undoManager2.canUndo()).to.equal(true); + expect(undoManager2.canRedo()).to.equal(false); + + undoManager1.undo(); + expect(this.doc.data.test).to.equal(42); + expect(undoManager1.canUndo()).to.equal(true); + expect(undoManager1.canRedo()).to.equal(true); + expect(undoManager2.canUndo()).to.equal(true); + expect(undoManager2.canRedo()).to.equal(false); + + undoManager2.undo(); + expect(this.doc.data.test).to.equal(29); + expect(undoManager1.canUndo()).to.equal(true); + expect(undoManager1.canRedo()).to.equal(true); + expect(undoManager2.canUndo()).to.equal(true); + expect(undoManager2.canRedo()).to.equal(true); + + undoManager1.undo(); + expect(this.doc.data.test).to.equal(22); + expect(undoManager1.canUndo()).to.equal(false); + expect(undoManager1.canRedo()).to.equal(true); + expect(undoManager2.canUndo()).to.equal(true); + expect(undoManager2.canRedo()).to.equal(true); + + undoManager2.undo(); + expect(this.doc.data.test).to.equal(9); + expect(undoManager1.canUndo()).to.equal(false); + expect(undoManager1.canRedo()).to.equal(true); + expect(undoManager2.canUndo()).to.equal(false); + expect(undoManager2.canRedo()).to.equal(true); + }); + + it('cannot undo/redo an undo/redo operation', function() { + var undoManager1 = this.connection.undoManager(); + this.doc.create({ test: 5 }); + this.doc.submitOp([{ p: [ 'test' ], na: 2 }], { undoable: true }); + var undoManager2 = this.connection.undoManager(); + expect(this.doc.data.test).to.equal(7); + expect(undoManager1.canUndo()).to.equal(true); + expect(undoManager1.canRedo()).to.equal(false); + expect(undoManager2.canUndo()).to.equal(false); + expect(undoManager2.canRedo()).to.equal(false); + + undoManager1.undo(); + expect(this.doc.data.test).to.equal(5); + expect(undoManager1.canUndo()).to.equal(false); + expect(undoManager1.canRedo()).to.equal(true); + expect(undoManager2.canUndo()).to.equal(false); + expect(undoManager2.canRedo()).to.equal(false); + + undoManager1.redo(); + expect(this.doc.data.test).to.equal(7); + expect(undoManager1.canUndo()).to.equal(true); + expect(undoManager1.canRedo()).to.equal(false); + expect(undoManager2.canUndo()).to.equal(false); + expect(undoManager2.canRedo()).to.equal(false); }); it('destroys UndoManager', function() { From eb3cea2558270775e85b1e74d5297ba981430fb8 Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Thu, 19 Jul 2018 23:38:16 +0200 Subject: [PATCH 39/45] Rename a method and option for undo manager - connection.undoManager() -> connection.createUndoMananger() - composeTimeout -> composeInterval --- README.md | 6 +- lib/client/connection.js | 2 +- lib/client/undoManager.js | 4 +- test/client/undo-redo.js | 138 +++++++++++++++++++------------------- 4 files changed, 75 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index 4b1d4095e..1d8178d9f 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ var socket = new WebSocket('ws://' + window.location.host); var connection = new sharedb.Connection(socket); ``` -The native Websocket object that you feed to ShareDB's `Connection` constructor **does not** handle reconnections. +The native Websocket object that you feed to ShareDB's `Connection` constructor **does not** handle reconnections. The easiest way is to give it a WebSocket object that does reconnect. There are plenty of example on the web. The most important thing is that the custom reconnecting websocket, must have the same API as the native rfc6455 version. @@ -229,11 +229,11 @@ changes. Returns a [`ShareDB.Query`](#class-sharedbquery) instance. * `options.*` All other options are passed through to the database adapter. -`connection.undoManager(options)` creates a new `UndoManager`. +`connection.createUndoManager(options)` creates a new `UndoManager`. * `options.source` if specified, only the operations from that `source` will be undo-able. If `null` or `undefined`, the `source` filter is disabled. * `options.limit` the max number of operations to keep on the undo stack. -* `options.composeTimeout` the max time difference between operations in milliseconds, which still allows the operations to be composed on the undoStack. +* `options.composeInterval` the max time difference between operations in milliseconds, which still allows the operations to be composed on the undo stack. ### Class: `ShareDB.Doc` diff --git a/lib/client/connection.js b/lib/client/connection.js index fe99a0048..cbef375aa 100644 --- a/lib/client/connection.js +++ b/lib/client/connection.js @@ -589,7 +589,7 @@ Connection.prototype._firstQuery = function(fn) { } }; -Connection.prototype.undoManager = function(options) { +Connection.prototype.createUndoManager = function(options) { var undoManager = new UndoManager(this, options); this.undoManagers.push(undoManager); return undoManager; diff --git a/lib/client/undoManager.js b/lib/client/undoManager.js index 372caab1c..2c21312a4 100644 --- a/lib/client/undoManager.js +++ b/lib/client/undoManager.js @@ -41,7 +41,7 @@ function UndoManager(connection, options) { // The max time difference between operations in milliseconds, // which still allows the operations to be composed on the undoStack. - this._composeTimeout = options && typeof options.composeTimeout === 'number' ? options.composeTimeout : 1000; + this._composeInterval = options && typeof options.composeInterval === 'number' ? options.composeInterval : 1000; // Undo stack for local operations. this._undoStack = []; @@ -174,7 +174,7 @@ UndoManager.prototype._updateStacksUndoable = function(doc, op, undoOp) { if ( this._undoStack.length === 0 || getLast(this._undoStack).doc !== doc || - now - this._previousUndoableOperationTime > this._composeTimeout + now - this._previousUndoableOperationTime > this._composeInterval ) { this._undoStack.push(new Op(undoOp, doc)); diff --git a/test/client/undo-redo.js b/test/client/undo-redo.js index 075df3df4..90a53d2a3 100644 --- a/test/client/undo-redo.js +++ b/test/client/undo-redo.js @@ -36,7 +36,7 @@ describe('client undo/redo', function() { }); it('submits a non-undoable operation', function(allDone) { - var undoManager = this.connection.undoManager(); + var undoManager = this.connection.createUndoManager(); async.series([ this.doc.create.bind(this.doc, { test: 5 }), this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ]), @@ -51,7 +51,7 @@ describe('client undo/redo', function() { }); it('receives a remote operation', function(done) { - var undoManager = this.connection.undoManager(); + var undoManager = this.connection.createUndoManager(); this.doc2.preventCompose = true; this.doc.on('op', function() { expect(this.doc.version).to.equal(2); @@ -67,7 +67,7 @@ describe('client undo/redo', function() { }); it('submits an undoable operation', function(allDone) { - var undoManager = this.connection.undoManager(); + var undoManager = this.connection.createUndoManager(); async.series([ this.doc.create.bind(this.doc, { test: 5 }), this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ], { undoable: true }), @@ -82,7 +82,7 @@ describe('client undo/redo', function() { }); it('undoes an operation', function(allDone) { - var undoManager = this.connection.undoManager(); + var undoManager = this.connection.createUndoManager(); async.series([ this.doc.create.bind(this.doc, { test: 5 }), this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ], { undoable: true }), @@ -98,7 +98,7 @@ describe('client undo/redo', function() { }); it('redoes an operation', function(allDone) { - var undoManager = this.connection.undoManager(); + var undoManager = this.connection.createUndoManager(); async.series([ this.doc.create.bind(this.doc, { test: 5 }), this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ], { undoable: true }), @@ -115,7 +115,7 @@ describe('client undo/redo', function() { }); it('performs a series of undo and redo operations', function(allDone) { - var undoManager = this.connection.undoManager(); + var undoManager = this.connection.createUndoManager(); async.series([ this.doc.create.bind(this.doc, { test: 5 }), this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ], { undoable: true }), @@ -136,7 +136,7 @@ describe('client undo/redo', function() { }); it('performs a series of undo and redo operations synchronously', function() { - var undoManager = this.connection.undoManager(); + var undoManager = this.connection.createUndoManager(); this.doc.create({ test: 5 }), this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }), expect(this.doc.data).to.eql({ test: 7 }); @@ -157,7 +157,7 @@ describe('client undo/redo', function() { }); it('undoes one of two operations', function(allDone) { - var undoManager = this.connection.undoManager({ composeTimeout: -1 }); + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); async.series([ this.doc.create.bind(this.doc, { test: 5 }), this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ], { undoable: true }), @@ -174,7 +174,7 @@ describe('client undo/redo', function() { }); it('undoes two of two operations', function(allDone) { - var undoManager = this.connection.undoManager({ composeTimeout: -1 }); + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); async.series([ this.doc.create.bind(this.doc, { test: 5 }), this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ], { undoable: true }), @@ -192,7 +192,7 @@ describe('client undo/redo', function() { }); it('redoes one of two operations', function(allDone) { - var undoManager = this.connection.undoManager({ composeTimeout: -1 }); + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); async.series([ this.doc.create.bind(this.doc, { test: 5 }), this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ], { undoable: true }), @@ -211,7 +211,7 @@ describe('client undo/redo', function() { }); it('redoes two of two operations', function(allDone) { - var undoManager = this.connection.undoManager({ composeTimeout: -1 }); + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); async.series([ this.doc.create.bind(this.doc, { test: 5 }), this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ], { undoable: true }), @@ -231,25 +231,25 @@ describe('client undo/redo', function() { }); it('calls undo, when canUndo is false', function(done) { - var undoManager = this.connection.undoManager(); + var undoManager = this.connection.createUndoManager(); expect(undoManager.canUndo()).to.equal(false); undoManager.undo(done); }); it('calls undo, when canUndo is false - no callback', function() { - var undoManager = this.connection.undoManager(); + var undoManager = this.connection.createUndoManager(); expect(undoManager.canUndo()).to.equal(false); undoManager.undo(); }); it('calls redo, when canRedo is false', function(done) { - var undoManager = this.connection.undoManager(); + var undoManager = this.connection.createUndoManager(); expect(undoManager.canRedo()).to.equal(false); undoManager.redo(done); }); it('calls redo, when canRedo is false - no callback', function() { - var undoManager = this.connection.undoManager(); + var undoManager = this.connection.createUndoManager(); expect(undoManager.canRedo()).to.equal(false); undoManager.redo(); }); @@ -281,7 +281,7 @@ describe('client undo/redo', function() { }); it('preserves source on undo', function(done) { - var undoManager = this.connection.undoManager(); + var undoManager = this.connection.createUndoManager(); this.doc.create({ test: 5 }); this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); this.doc.on('op', function(op, source) { @@ -292,7 +292,7 @@ describe('client undo/redo', function() { }); it('preserves source on redo', function(done) { - var undoManager = this.connection.undoManager(); + var undoManager = this.connection.createUndoManager(); this.doc.create({ test: 5 }); this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); undoManager.undo(); @@ -316,7 +316,7 @@ describe('client undo/redo', function() { }); it('composes undoable operations within time limit', function(done) { - var undoManager = this.connection.undoManager(); + var undoManager = this.connection.createUndoManager(); this.doc.create({ test: 5 }); this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); setTimeout(function() { @@ -331,7 +331,7 @@ describe('client undo/redo', function() { }); it('composes undoable operations correctly', function() { - var undoManager = this.connection.undoManager(); + var undoManager = this.connection.createUndoManager(); this.doc.create({ a: 1, b: 2 }); this.doc.submitOp([ { p: [ 'a' ], od: 1 } ], { undoable: true }); this.doc.submitOp([ { p: [ 'b' ], od: 2 } ], { undoable: true }); @@ -352,7 +352,7 @@ describe('client undo/redo', function() { }); it('does not compose undoable operations outside time limit', function(done) { - var undoManager = this.connection.undoManager(); + var undoManager = this.connection.createUndoManager(); this.doc.create({ test: 5 }); this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); setTimeout(function () { @@ -369,8 +369,8 @@ describe('client undo/redo', function() { this.clock.runAll(); }); - it('does not compose undoable operations, if composeTimeout < 0', function() { - var undoManager = this.connection.undoManager({ composeTimeout: -1 }); + it('does not compose undoable operations, if composeInterval < 0', function() { + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); this.doc.create({ test: 5 }); this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); this.doc.submitOp([ { p: [ 'test' ], na: 3 } ], { undoable: true }); @@ -384,7 +384,7 @@ describe('client undo/redo', function() { }); it('does not compose undoable operations, if type does not support compose nor composeSimilar', function() { - var undoManager = this.connection.undoManager(); + var undoManager = this.connection.createUndoManager(); this.doc.create(5, invertibleType.type.uri); this.doc.submitOp(2, { undoable: true }); expect(this.doc.data).to.equal(7); @@ -403,7 +403,7 @@ describe('client undo/redo', function() { }); it('uses applyAndInvert, if available', function() { - var undoManager = this.connection.undoManager({ composeTimeout: -1 }); + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); this.doc.create([], otRichText.type.uri); this.doc.submitOp([ otRichText.Action.createInsertText('two') ], { undoable: true }); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('two') ]); @@ -456,7 +456,7 @@ describe('client undo/redo', function() { }); it('fails to submit with fixUp, if type is not invertible', function(done) { - var undoManager = this.connection.undoManager(); + var undoManager = this.connection.createUndoManager(); this.doc.create('two', otText.type.uri); this.doc.on('error', done); this.doc.submitOp([ 'one' ], { fixUp: true }, function(err) { @@ -466,7 +466,7 @@ describe('client undo/redo', function() { }); it('composes similar operations', function() { - var undoManager = this.connection.undoManager(); + var undoManager = this.connection.createUndoManager(); this.doc.create([], otRichText.type.uri); this.doc.submitOp([ otRichText.Action.createInsertText('one') @@ -486,7 +486,7 @@ describe('client undo/redo', function() { }); it('does not compose dissimilar operations', function() { - var undoManager = this.connection.undoManager(); + var undoManager = this.connection.createUndoManager(); this.doc.create([ otRichText.Action.createInsertText(' ') ], otRichText.type.uri); this.doc.submitOp([ otRichText.Action.createRetain(1), otRichText.Action.createInsertText('two') ], { undoable: true }); @@ -509,7 +509,7 @@ describe('client undo/redo', function() { }); it('does not add no-ops to the undo stack on undoable operation', function() { - var undoManager = this.connection.undoManager(); + var undoManager = this.connection.createUndoManager(); var opCalled = false; this.doc.create([ otRichText.Action.createInsertText('test', [ 'key', 'value' ]) ], otRichText.type.uri); this.doc.on('op', function(op, source) { @@ -524,7 +524,7 @@ describe('client undo/redo', function() { }); it('limits the size of the undo stack', function() { - var undoManager = this.connection.undoManager({ limit: 2, composeTimeout: -1 }); + var undoManager = this.connection.createUndoManager({ limit: 2, composeInterval: -1 }); this.doc.create({ test: 5 }); this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); @@ -540,7 +540,7 @@ describe('client undo/redo', function() { }); it('does not compose the next operation after undo', function() { - var undoManager = this.connection.undoManager(); + var undoManager = this.connection.createUndoManager(); this.doc.create({ test: 5 }); this.clock.tick(1001); this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); // not composed @@ -562,7 +562,7 @@ describe('client undo/redo', function() { }); it('does not compose the next operation after undo and redo', function() { - var undoManager = this.connection.undoManager(); + var undoManager = this.connection.createUndoManager(); this.doc.create({ test: 5 }); this.clock.tick(1001); this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); // not composed @@ -589,7 +589,7 @@ describe('client undo/redo', function() { }); it('transforms the stacks by remote operations', function(done) { - var undoManager = this.connection.undoManager({ composeTimeout: -1 }); + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); this.doc2.subscribe(); this.doc.subscribe(); this.doc.create([], otRichText.type.uri); @@ -622,7 +622,7 @@ describe('client undo/redo', function() { }); it('transforms the stacks by remote operations and removes no-ops', function(done) { - var undoManager = this.connection.undoManager({ composeTimeout: -1 }); + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); this.doc2.subscribe(); this.doc.subscribe(); this.doc.create([], otRichText.type.uri); @@ -653,7 +653,7 @@ describe('client undo/redo', function() { }); it('transforms the stacks by a local operation', function() { - var undoManager = this.connection.undoManager({ composeTimeout: -1 }); + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); this.doc.create([], otRichText.type.uri); this.doc.submitOp([ otRichText.Action.createInsertText('4') ], { undoable: true }); this.doc.submitOp([ otRichText.Action.createInsertText('3') ], { undoable: true }); @@ -678,7 +678,7 @@ describe('client undo/redo', function() { }); it('transforms the stacks by a local operation and removes no-ops', function() { - var undoManager = this.connection.undoManager({ composeTimeout: -1 }); + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); this.doc.create([], otRichText.type.uri); this.doc.submitOp([ otRichText.Action.createInsertText('4') ], { undoable: true }); this.doc.submitOp([ otRichText.Action.createInsertText('3') ], { undoable: true }); @@ -701,7 +701,7 @@ describe('client undo/redo', function() { }); it('transforms stacks by an undoable op', function() { - var undoManager = this.connection.undoManager({ composeTimeout: -1, source: '1' }); + var undoManager = this.connection.createUndoManager({ composeInterval: -1, source: '1' }); this.doc.create([], otRichText.type.uri); this.doc.submitOp([ otRichText.Action.createInsertText('4') ], { undoable: true, source: '1' }); this.doc.submitOp([ otRichText.Action.createInsertText('3') ], { undoable: true, source: '1' }); @@ -729,8 +729,8 @@ describe('client undo/redo', function() { }); it('transforms stacks by an undo op', function() { - var undoManager = this.connection.undoManager({ composeTimeout: -1, source: '1' }); - var undoManager2 = this.connection.undoManager({ composeTimeout: -1, source: '2' }); + var undoManager = this.connection.createUndoManager({ composeInterval: -1, source: '1' }); + var undoManager2 = this.connection.createUndoManager({ composeInterval: -1, source: '2' }); this.doc.create([], otRichText.type.uri); this.doc.submitOp([ otRichText.Action.createInsertText('4') ], { undoable: true, source: '1' }); this.doc.submitOp([ otRichText.Action.createInsertText('3') ], { undoable: true, source: '1' }); @@ -763,8 +763,8 @@ describe('client undo/redo', function() { }); it('transforms stacks by a redo op', function() { - var undoManager = this.connection.undoManager({ composeTimeout: -1, source: '1' }); - var undoManager2 = this.connection.undoManager({ composeTimeout: -1, source: '2' }); + var undoManager = this.connection.createUndoManager({ composeInterval: -1, source: '1' }); + var undoManager2 = this.connection.createUndoManager({ composeInterval: -1, source: '2' }); this.doc.create([], otRichText.type.uri); this.doc.submitOp([ otRichText.Action.createInsertText('4') ], { undoable: true, source: '1' }); this.doc.submitOp([ otRichText.Action.createInsertText('3') ], { undoable: true, source: '1' }); @@ -797,7 +797,7 @@ describe('client undo/redo', function() { }); it('transforms the stacks using transform', function() { - var undoManager = this.connection.undoManager({ composeTimeout: -1 }); + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); this.doc.create(0, invertibleType.type.uri); this.doc.submitOp(1, { undoable: true }); this.doc.submitOp(10, { undoable: true }); @@ -822,7 +822,7 @@ describe('client undo/redo', function() { }); it('transforms the stacks using transformX', function() { - var undoManager = this.connection.undoManager({ composeTimeout: -1 }); + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); this.doc.create(0, invertibleType.typeWithTransformX.uri); this.doc.submitOp(1, { undoable: true }); this.doc.submitOp(10, { undoable: true }); @@ -902,7 +902,7 @@ describe('client undo/redo', function() { describe('fixup operations', function() { beforeEach(function() { - var undoManager = this.connection.undoManager({ composeTimeout: -1 }); + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); this.assert = function(text) { var expected = text ? [ otRichText.Action.createInsertText(text) ] : []; @@ -944,7 +944,7 @@ describe('client undo/redo', function() { }); it('does not fix up anything', function() { - var undoManager = this.connection.undoManager(); + var undoManager = this.connection.createUndoManager(); expect(undoManager.canUndo()).to.equal(false); expect(undoManager.canRedo()).to.equal(false); this.submitOp('!', { fixUp: true }).assert('!cd'); @@ -1088,8 +1088,8 @@ describe('client undo/redo', function() { }); it('filters undo/redo ops by source', function() { - var undoManager1 = this.connection.undoManager({ composeTimeout: -1, source: '1' }); - var undoManager2 = this.connection.undoManager({ composeTimeout: -1, source: '2' }); + var undoManager1 = this.connection.createUndoManager({ composeInterval: -1, source: '1' }); + var undoManager2 = this.connection.createUndoManager({ composeInterval: -1, source: '2' }); this.doc.create({ test: 5 }); expect(this.doc.data.test).to.equal(5); @@ -1170,10 +1170,10 @@ describe('client undo/redo', function() { }); it('cannot undo/redo an undo/redo operation', function() { - var undoManager1 = this.connection.undoManager(); + var undoManager1 = this.connection.createUndoManager(); this.doc.create({ test: 5 }); this.doc.submitOp([{ p: [ 'test' ], na: 2 }], { undoable: true }); - var undoManager2 = this.connection.undoManager(); + var undoManager2 = this.connection.createUndoManager(); expect(this.doc.data.test).to.equal(7); expect(undoManager1.canUndo()).to.equal(true); expect(undoManager1.canRedo()).to.equal(false); @@ -1196,7 +1196,7 @@ describe('client undo/redo', function() { }); it('destroys UndoManager', function() { - var undoManager = this.connection.undoManager({ composeTimeout: -1 }); + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); var doc1 = this.connection.get('dogs', 'fido'); var doc2 = this.connection.get('dogs', 'toby'); doc1.create({ test: 5 }); @@ -1225,7 +1225,7 @@ describe('client undo/redo', function() { describe('UndoManager.clear', function() { it('clears the stacks', function() { - var undoManager = this.connection.undoManager({ composeTimeout: -1 }); + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); var doc1 = this.connection.get('dogs', 'fido'); var doc2 = this.connection.get('dogs', 'toby'); doc1.create({ test: 5 }); @@ -1251,7 +1251,7 @@ describe('client undo/redo', function() { }); it('clears the stacks for a specific document', function() { - var undoManager = this.connection.undoManager({ composeTimeout: -1 }); + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); var doc1 = this.connection.get('dogs', 'fido'); var doc2 = this.connection.get('dogs', 'toby'); doc1.create({ test: 5 }); @@ -1291,7 +1291,7 @@ describe('client undo/redo', function() { it('clears the stacks for a specific document on del', function() { // NOTE we don't support undo/redo on del/create at the moment. // See undoManager.js for more details. - var undoManager = this.connection.undoManager({ composeTimeout: -1 }); + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); var doc1 = this.connection.get('dogs', 'fido'); var doc2 = this.connection.get('dogs', 'toby'); doc1.create({ test: 5 }); @@ -1319,7 +1319,7 @@ describe('client undo/redo', function() { next(); }); - var undoManager = this.connection.undoManager({ composeTimeout: -1 }); + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); var doc1 = this.connection.get('dogs', 'fido'); var doc2 = this.connection.get('dogs', 'toby'); doc1.create([], otRichText.type.uri); @@ -1366,7 +1366,7 @@ describe('client undo/redo', function() { }); it('clears the stacks for a specific document on doc destroy', function(done) { - var undoManager = this.connection.undoManager({ composeTimeout: -1 }); + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); var doc1 = this.connection.get('dogs', 'fido'); var doc2 = this.connection.get('dogs', 'toby'); doc1.create({ test: 5 }); @@ -1432,7 +1432,7 @@ describe('client undo/redo', function() { }); it('submits a snapshot with source (no callback)', function(done) { - var undoManager = this.connection.undoManager(); + var undoManager = this.connection.createUndoManager(); this.doc.on('op', function(op, source) { expect(op).to.eql([ otRichText.Action.createInsertText('abc') ]); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcdef') ]); @@ -1446,7 +1446,7 @@ describe('client undo/redo', function() { }); it('submits a snapshot with source (with callback)', function(done) { - var undoManager = this.connection.undoManager(); + var undoManager = this.connection.createUndoManager(); var opEmitted = false; this.doc.on('op', function(op, source) { expect(op).to.eql([ otRichText.Action.createInsertText('abc') ]); @@ -1464,7 +1464,7 @@ describe('client undo/redo', function() { }); it('submits a snapshot without source (no callback)', function(done) { - var undoManager = this.connection.undoManager(); + var undoManager = this.connection.createUndoManager(); this.doc.on('op', function(op, source) { expect(op).to.eql([ otRichText.Action.createInsertText('abc') ]); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcdef') ]); @@ -1478,7 +1478,7 @@ describe('client undo/redo', function() { }); it('submits a snapshot without source (with callback)', function(done) { - var undoManager = this.connection.undoManager(); + var undoManager = this.connection.createUndoManager(); var opEmitted = false; this.doc.on('op', function(op, source) { expect(op).to.eql([ otRichText.Action.createInsertText('abc') ]); @@ -1496,7 +1496,7 @@ describe('client undo/redo', function() { }); it('submits snapshots and supports undo and redo', function() { - var undoManager = this.connection.undoManager({ composeTimeout: -1 }); + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); this.doc.create([ otRichText.Action.createInsertText('ghi') ], otRichText.type.uri); this.doc.submitSnapshot([ otRichText.Action.createInsertText('defghi') ], { undoable: true }); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('defghi') ]); @@ -1520,7 +1520,7 @@ describe('client undo/redo', function() { }); it('submits snapshots and composes operations', function() { - var undoManager = this.connection.undoManager(); + var undoManager = this.connection.createUndoManager(); this.doc.create([ otRichText.Action.createInsertText('ghi') ], otRichText.type.uri); this.doc.submitSnapshot([ otRichText.Action.createInsertText('defghi') ], { undoable: true }); expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('defghi') ]); @@ -1553,7 +1553,7 @@ describe('client undo/redo', function() { }); it('submits undoable and fixed operations', function() { - var undoManager = this.connection.undoManager({ composeTimeout: -1 }); + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); this.doc.create([], otRichText.type.uri); this.doc.submitSnapshot([ otRichText.Action.createInsertText('a') ], { undoable: true }); this.doc.submitSnapshot([ otRichText.Action.createInsertText('ab') ], { undoable: true }); @@ -1589,7 +1589,7 @@ describe('client undo/redo', function() { }); it('submits a snapshot without a diffHint', function() { - var undoManager = this.connection.undoManager(); + var undoManager = this.connection.createUndoManager(); var opCalled = 0; this.doc.create([ otRichText.Action.createInsertText('aaaa') ], otRichText.type.uri); this.doc.submitSnapshot([ otRichText.Action.createInsertText('aaaaa') ], { undoable: true }); @@ -1613,7 +1613,7 @@ describe('client undo/redo', function() { }); it('submits a snapshot with a diffHint', function() { - var undoManager = this.connection.undoManager(); + var undoManager = this.connection.createUndoManager(); var opCalled = 0; this.doc.create([ otRichText.Action.createInsertText('aaaa') ], otRichText.type.uri); this.doc.submitSnapshot([ otRichText.Action.createInsertText('aaaaa') ], { undoable: true, diffHint: 2 }); @@ -1661,7 +1661,7 @@ describe('client undo/redo', function() { describe('with diff', function () { it('submits a snapshot (non-undoable)', function() { - var undoManager = this.connection.undoManager(); + var undoManager = this.connection.createUndoManager(); this.doc.create(5, invertibleType.typeWithDiff.uri); this.doc.submitSnapshot(7); expect(this.doc.data).to.equal(7); @@ -1669,7 +1669,7 @@ describe('client undo/redo', function() { expect(undoManager.canRedo()).to.equal(false); }); it('submits a snapshot (undoable)', function() { - var undoManager = this.connection.undoManager(); + var undoManager = this.connection.createUndoManager(); this.doc.create(5, invertibleType.typeWithDiff.uri); this.doc.submitSnapshot(7, { undoable: true }); expect(this.doc.data).to.equal(7); @@ -1682,7 +1682,7 @@ describe('client undo/redo', function() { describe('with diffX', function () { it('submits a snapshot (non-undoable)', function() { - var undoManager = this.connection.undoManager(); + var undoManager = this.connection.createUndoManager(); this.doc.create(5, invertibleType.typeWithDiffX.uri); this.doc.submitSnapshot(7); expect(this.doc.data).to.equal(7); @@ -1690,7 +1690,7 @@ describe('client undo/redo', function() { expect(undoManager.canRedo()).to.equal(false); }); it('submits a snapshot (undoable)', function() { - var undoManager = this.connection.undoManager(); + var undoManager = this.connection.createUndoManager(); this.doc.create(5, invertibleType.typeWithDiffX.uri); this.doc.submitSnapshot(7, { undoable: true }); expect(this.doc.data).to.equal(7); @@ -1703,7 +1703,7 @@ describe('client undo/redo', function() { describe('with diff and diffX', function () { it('submits a snapshot (non-undoable)', function() { - var undoManager = this.connection.undoManager(); + var undoManager = this.connection.createUndoManager(); this.doc.create(5, invertibleType.typeWithDiffAndDiffX.uri); this.doc.submitSnapshot(7); expect(this.doc.data).to.equal(7); @@ -1711,7 +1711,7 @@ describe('client undo/redo', function() { expect(undoManager.canRedo()).to.equal(false); }); it('submits a snapshot (undoable)', function() { - var undoManager = this.connection.undoManager(); + var undoManager = this.connection.createUndoManager(); this.doc.create(5, invertibleType.typeWithDiffAndDiffX.uri); this.doc.submitSnapshot(7, { undoable: true }); expect(this.doc.data).to.equal(7); From 684725aa7d4d00cc73eb615d3f87c8e434fe6256 Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Fri, 20 Jul 2018 10:47:07 +0200 Subject: [PATCH 40/45] Update docs --- README.md | 63 ++++++++++++++++++++-------------------- lib/client/doc.js | 4 +-- test/client/submit.js | 5 ++-- test/client/undo-redo.js | 16 ++++++++++ 4 files changed, 51 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 1d8178d9f..77bea5808 100644 --- a/README.md +++ b/README.md @@ -246,13 +246,6 @@ Unique document ID `doc.data` _(Object)_ Document contents. Available after document is fetched or subscribed to. -`doc.undoLimit` _(Number, read-write, default=100)_ -The max number of operations to keep on the undo stack. - -`doc.undoComposeTimeout` _(Number, read-write, default=1000)_ -The max time difference between operations in milliseconds, -which still allows "UNDOABLE" operations to be composed on the undo stack. - `doc.fetch(function(err) {...})` Populate the fields on `doc` with a snapshot of the document from the server. @@ -273,11 +266,11 @@ same time as callbacks to `fetch` and `subscribe`. `doc.on('create', function(source) {...})` The document was created. Technically, this means it has a type. `source` will be `false` for ops received from the server and defaults to `true` for ops generated locally. -`doc.on('before op'), function(op, source, operationType) {...})` -An operation is about to be applied to the data. Params are the same as for the `op` event below. +`doc.on('before op'), function(op, source) {...})` +An operation is about to be applied to the data. `source` will be `false` for ops received from the server and defaults to `true` for ops generated locally. -`doc.on('op', function(op, source, operationType) {...})` -An operation was applied to the data. `source` will be `false` for ops received from the server and defaults to `true` for ops generated locally. `operationType` is one of the following: `"UNDOABLE"` _(local operation that can be undone)_, `"FIXED"` _(local or remote operation that can't be undone nor redone)_, `"UNDO"` _(local undo operation that can be redone)_ and `"REDO"` _(local redo operation that can be undone)_. +`doc.on('op', function(op, source) {...})` +An operation was applied to the data. `source` will be `false` for ops received from the server and defaults to `true` for ops generated locally. `doc.on('del', function(data, source) {...})` The document was deleted. Document contents before deletion are passed in as an argument. `source` will be `false` for ops received from the server and defaults to `true` for ops generated locally. @@ -302,19 +295,17 @@ Apply operation to document and send it to the server. Call this after you've either fetched or subscribed to the document. * `options.source` Argument passed to the `'op'` event locally. This is not sent to the server or other clients. Defaults to `true`. * `options.skipNoop` Should processing be skipped entirely, if `op` is a no-op. Defaults to `false`. -* `options.undoable` Should it be possible to undo this operation, default=false. -* `options.fixUpUndoStack` Determines how a non-undoable operation affects the undo stack. If `false` (default), the operation transforms the undo stack, otherwise it is inverted and composed into the last operation on the undo stack. -* `options.fixUpRedoStack` Determines how a non-undoable operation affects the redo stack. If `false` (default), the operation transforms the redo stack, otherwise it is inverted and composed into the last operation on the redo stack. +* `options.undoable` Should it be possible to undo this operation. Defaults to `false`. +* `options.fixUp` If true, this operation is meant to fix the current invalid state of the snapshot. It also updates UndoManagers accordingly. This feature requires the OT type to implement `compose`. `doc.submitSnapshot(snapshot[, options][, function(err) {...}])` Diff the current and the provided snapshots to generate an operation, apply the operation to the document and send it to the server. `snapshot` structure depends on the document type. Call this after you've either fetched or subscribed to the document. * `options.source` Argument passed to the `'op'` event locally. This is not sent to the server or other clients. Defaults to `true`. -* `options.skipNoop` Should processing be skipped entirely, if the generated operation is a no-op. Defaults to `false`. -* `options.undoable` Should it be possible to undo this operation, default=false. -* `options.fixUpUndoStack` Determines how a non-undoable operation affects the undo stack. If `false` (default), the operation transforms the undo stack, otherwise it is inverted and composed into the last operation on the undo stack. -* `options.fixUpRedoStack` Determines how a non-undoable operation affects the redo stack. If `false` (default), the operation transforms the redo stack, otherwise it is inverted and composed into the last operation on the redo stack. +* `options.skipNoop` Should processing be skipped entirely, if `op` is a no-op. Defaults to `false`. +* `options.undoable` Should it be possible to undo this operation. Defaults to `false`. +* `options.fixUp` If true, this operation is meant to fix the current invalid state of the snapshot. It also updates UndoManagers accordingly. This feature requires the OT type to implement `compose`. * `options.diffHint` A hint passed into the `diff`/`diffX` functions defined by the document type. `doc.del([options][, function(err) {...}])` @@ -330,20 +321,6 @@ Invokes the given callback function after Note that `whenNothingPending` does NOT wait for pending `model.query()` calls. -`doc.canUndo()` -Return `true`, if there's an operation on the undo stack that can be undone, otherwise `false`. - -`doc.undo([options][, function(err) {...}])` -Undo a previously applied "UNDOABLE" or "REDO" operation. -* `options.source` Argument passed to the `'op'` event locally. This is not sent to the server or other clients. Defaults to `true`. - -`doc.canRedo()` -Return `true`, if there's an operation on the redo stack that can be undone, otherwise `false`. - -`doc.redo([options][, function(err) {...}])` -Redo a previously applied "UNDO" operation. -* `options.source` Argument passed to the `'op'` event locally. This is not sent to the server or other clients. Defaults to `true`. - ### Class: `ShareDB.Query` `query.ready` _(Boolean)_ @@ -381,6 +358,28 @@ after a sequence of diffs are handled. `query.on('extra', function() {...}))` (Only fires on subscription queries) `query.extra` changed. +### Class: `ShareDB.UndoManager` + +`undoManager.canUndo()` +Return `true`, if there's an operation on the undo stack that can be undone, otherwise `false`. + +`undoManager.undo([options][, function(err) {...}])` +Undo a previously applied undoable or redo operation. +* `options.source` Argument passed to the `'op'` event locally. This is not sent to the server or other clients. Defaults to `true`. + +`undoManager.canRedo()` +Return `true`, if there's an operation on the redo stack that can be undone, otherwise `false`. + +`undoManager.redo([options][, function(err) {...}])` +Redo a previously applied undo operation. +* `options.source` Argument passed to the `'op'` event locally. This is not sent to the server or other clients. Defaults to `true`. + +`undoManager.clear(doc)` +Remove operations from the undo and redo stacks. +* `doc` if specified, only the operations on that doc are removed, otherwise all operations are removed. + +`undoManager.destroy()` +Remove all operations from the undo and redo stacks, and stop recording new operations. ## Error codes diff --git a/lib/client/doc.js b/lib/client/doc.js index a79a956d9..3848ca577 100644 --- a/lib/client/doc.js +++ b/lib/client/doc.js @@ -680,9 +680,7 @@ Doc.prototype._submit = function(op, options, callback) { if (callback) return callback(err); return this.emit('error', err); } - var undoable = options && options.undoable; - var fixUp = options && options.fixUp; - var needsUndoOp = undoable || fixUp || op.needsUndoOp; + var needsUndoOp = options.undoable || options.fixUp || op.needsUndoOp; if (needsUndoOp && !this.type.invert && !this.type.applyAndInvert) { var err = new ShareDBError(4025, 'Cannot submit op. OT type does not support invert not applyAndInvert. ' + this.collection + '.' + this.id); if (callback) return callback(err); diff --git a/test/client/submit.js b/test/client/submit.js index 69a98aabd..95bb6f560 100644 --- a/test/client/submit.js +++ b/test/client/submit.js @@ -598,7 +598,7 @@ describe('client submit', function() { }); }); - it('transforming pending op by server delete emits error', function(done) { + it('transforming pending op by server delete returns error', function(done) { var doc = this.backend.connect().get('dogs', 'fido'); var doc2 = this.backend.connect().get('dogs', 'fido'); doc.create({age: 3}, function(err) { @@ -626,7 +626,7 @@ describe('client submit', function() { }); }); - it('transforming pending op by server create emits error', function(done) { + it('transforming pending op by server create returns error', function(done) { var doc = this.backend.connect().get('dogs', 'fido'); var doc2 = this.backend.connect().get('dogs', 'fido'); doc.create({age: 3}, function(err) { @@ -645,6 +645,7 @@ describe('client submit', function() { }); doc.create({age: 9}, function(err) { expect(err).ok(); + expect(err.code).to.equal(4018); expect(doc.version).equal(3); expect(doc.data).eql({age: 5}); calledBack = true; diff --git a/test/client/undo-redo.js b/test/client/undo-redo.js index 90a53d2a3..3aff7b0b1 100644 --- a/test/client/undo-redo.js +++ b/test/client/undo-redo.js @@ -1223,6 +1223,22 @@ describe('client undo/redo', function() { expect(undoManager.canRedo()).to.equal(false); }); + it('destroys UndoManager twice', function() { + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); + this.doc.create({ test: 5 }); + this.doc.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + this.doc.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + undoManager.undo(); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(true); + undoManager.destroy(); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); + undoManager.destroy(); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); + }); + describe('UndoManager.clear', function() { it('clears the stacks', function() { var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); From e0787b7cc941cf452b3ef87aae81dd0bb06c175c Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Fri, 20 Jul 2018 10:55:08 +0200 Subject: [PATCH 41/45] Add more tests --- test/client/doc.js | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/test/client/doc.js b/test/client/doc.js index b44f52a2b..c789ad67b 100644 --- a/test/client/doc.js +++ b/test/client/doc.js @@ -1,7 +1,7 @@ var Backend = require('../../lib/backend'); var expect = require('expect.js'); -describe('client query subscribe', function() { +describe('client doc', function() { beforeEach(function() { this.backend = new Backend(); @@ -25,6 +25,39 @@ describe('client query subscribe', function() { expect(doc).not.equal(doc2); }); + it('calling doc.destroy on subscribed doc unregisters it (no callback)', function() { + var connection = this.connection; + var doc = connection.get('dogs', 'fido'); + expect(connection.getExisting('dogs', 'fido')).equal(doc); + + doc.subscribe(function(err) { + if (err) return done(err); + doc.destroy(); + doc.whenNothingPending(function() { + expect(connection.getExisting('dogs', 'fido')).equal(undefined); + + var doc2 = connection.get('dogs', 'fido'); + expect(doc).not.equal(doc2); + }); + }); + }); + + it('calling doc.destroy on subscribed doc unregisters it (with callback)', function() { + var connection = this.connection; + var doc = connection.get('dogs', 'fido'); + expect(connection.getExisting('dogs', 'fido')).equal(doc); + + doc.subscribe(function(err) { + if (err) return done(err); + doc.destroy(function() { + expect(connection.getExisting('dogs', 'fido')).equal(undefined); + + var doc2 = connection.get('dogs', 'fido'); + expect(doc).not.equal(doc2); + }); + }); + }); + it('getting then destroying then getting returns a new doc object', function() { var doc = this.connection.get('dogs', 'fido'); doc.destroy(); From a788f1e7ac9fe9f554738bd6f20071a547f3f3ba Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Fri, 20 Jul 2018 11:56:35 +0200 Subject: [PATCH 42/45] Move some tests --- test/client/submit.js | 223 ++++++++++++++++ test/client/undo-redo.js | 534 ++++++++++++--------------------------- 2 files changed, 391 insertions(+), 366 deletions(-) diff --git a/test/client/submit.js b/test/client/submit.js index 95bb6f560..8ee279b2e 100644 --- a/test/client/submit.js +++ b/test/client/submit.js @@ -2,8 +2,10 @@ var async = require('async'); var expect = require('expect.js'); var types = require('../../lib/types'); var deserializedType = require('./deserialized-type'); +var otRichText = require('@teamwork/ot-rich-text'); types.register(deserializedType.type); types.register(deserializedType.type2); +types.register(otRichText.type); module.exports = function() { describe('client submit', function() { @@ -1056,6 +1058,227 @@ describe('client submit', function() { }); }); + it('does not skip processing when submitting a no-op by default', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.on('op', function() { + expect(doc.data).to.eql([ otRichText.Action.createInsertText('test') ]); + done(); + }); + doc.create([ otRichText.Action.createInsertText('test') ], otRichText.type.uri); + doc.submitOp([]); + }); + + it('does not skip processing when submitting an identical snapshot by default', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.on('op', function() { + expect(doc.data).to.eql([ otRichText.Action.createInsertText('test') ]); + done(); + }); + doc.create([ otRichText.Action.createInsertText('test') ], otRichText.type.uri); + doc.submitSnapshot([ otRichText.Action.createInsertText('test') ]); + }); + + it('skips processing when submitting a no-op (no callback)', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.on('op', function() { + done(new Error('Should not emit `op`')); + }); + doc.create([ otRichText.Action.createInsertText('test') ], otRichText.type.uri); + doc.submitOp([], { skipNoop: true }); + expect(doc.data).to.eql([ otRichText.Action.createInsertText('test') ]); + done(); + }); + + it('skips processing when submitting a no-op (with callback)', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.on('op', function() { + done(new Error('Should not emit `op`')); + }); + doc.create([ otRichText.Action.createInsertText('test') ], otRichText.type.uri); + doc.submitOp([], { skipNoop: true }, done); + }); + + it('skips processing when submitting an identical snapshot (no callback)', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.on('op', function() { + done(new Error('Should not emit `op`')); + }); + doc.create([ otRichText.Action.createInsertText('test') ], otRichText.type.uri); + doc.submitSnapshot([ otRichText.Action.createInsertText('test') ], { skipNoop: true }); + expect(doc.data).to.eql([ otRichText.Action.createInsertText('test') ]); + done(); + }); + + it('skips processing when submitting an identical snapshot (with callback)', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.on('op', function() { + done(new Error('Should not emit `op`')); + }); + doc.create([ otRichText.Action.createInsertText('test') ], otRichText.type.uri); + doc.submitSnapshot([ otRichText.Action.createInsertText('test') ], { skipNoop: true }, done); + }); + + it('submits a snapshot when document is not created (no callback, no options)', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.on('error', function(error) { + expect(error).to.be.an(Error); + expect(error.code).to.equal(4015); + done(); + }); + doc.submitSnapshot(7); + }); + + it('submits a snapshot when document is not created (no callback, with options)', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.on('error', function(error) { + expect(error).to.be.an(Error); + expect(error.code).to.equal(4015); + done(); + }); + doc.submitSnapshot(7, { source: 'test' }); + }); + + it('submits a snapshot when document is not created (with callback, no options)', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.on('error', done); + doc.submitSnapshot(7, function(error) { + expect(error).to.be.an(Error); + expect(error.code).to.equal(4015); + done(); + }); + }); + + it('submits a snapshot when document is not created (with callback, with options)', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.on('error', done); + doc.submitSnapshot(7, { source: 'test' }, function(error) { + expect(error).to.be.an(Error); + expect(error.code).to.equal(4015); + done(); + }); + }); + + it('submits a snapshot with source (no callback)', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.on('op', function(op, source) { + expect(op).to.eql([ otRichText.Action.createInsertText('abc') ]); + expect(doc.data).to.eql([ otRichText.Action.createInsertText('abcdef') ]); + expect(source).to.equal('test'); + done(); + }); + doc.create([ otRichText.Action.createInsertText('def') ], otRichText.type.uri); + doc.submitSnapshot([ otRichText.Action.createInsertText('abcdef') ], { source: 'test' }); + }); + + it('submits a snapshot with source (with callback)', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + var opEmitted = false; + doc.on('op', function(op, source) { + expect(op).to.eql([ otRichText.Action.createInsertText('abc') ]); + expect(doc.data).to.eql([ otRichText.Action.createInsertText('abcdef') ]); + expect(source).to.equal('test'); + opEmitted = true; + }); + doc.create([ otRichText.Action.createInsertText('def') ], otRichText.type.uri); + doc.submitSnapshot([ otRichText.Action.createInsertText('abcdef') ], { source: 'test' }, function(error) { + expect(opEmitted).to.equal(true); + done(error); + }); + }); + + it('submits a snapshot without source (no callback)', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.on('op', function(op, source) { + expect(op).to.eql([ otRichText.Action.createInsertText('abc') ]); + expect(doc.data).to.eql([ otRichText.Action.createInsertText('abcdef') ]); + expect(source).to.equal(true); + done(); + }); + doc.create([ otRichText.Action.createInsertText('def') ], otRichText.type.uri); + doc.submitSnapshot([ otRichText.Action.createInsertText('abcdef') ]); + }); + + it('submits a snapshot without source (with callback)', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + var opEmitted = false; + doc.on('op', function(op, source) { + expect(op).to.eql([ otRichText.Action.createInsertText('abc') ]); + expect(doc.data).to.eql([ otRichText.Action.createInsertText('abcdef') ]); + expect(source).to.equal(true); + opEmitted = true; + }); + doc.create([ otRichText.Action.createInsertText('def') ], otRichText.type.uri); + doc.submitSnapshot([ otRichText.Action.createInsertText('abcdef') ], function(error) { + expect(opEmitted).to.equal(true); + done(error); + }); + }); + + it('submits a snapshot and syncs it', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + var doc2 = this.backend.connect().get('dogs', 'fido'); + doc2.on('create', function() { + doc2.submitSnapshot([ otRichText.Action.createInsertText('abcdef') ]); + }); + doc.on('op', function(op, source) { + expect(op).to.eql([ otRichText.Action.createInsertText('abc') ]); + expect(source).to.equal(false); + expect(doc.data).to.eql([ otRichText.Action.createInsertText('abcdef') ]); + done(); + }); + doc.subscribe(function(err) { + if (err) return done(err); + doc2.subscribe(function(err) { + if (err) return done(err); + doc.create([ otRichText.Action.createInsertText('def') ], otRichText.type.uri); + }); + }); + }); + + it('submits a snapshot (no diff, no diffX, no callback)', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.on('error', function(error) { + expect(error).to.be.an(Error); + expect(error.code).to.equal(4024); + done(); + }); + doc.create({ test: 5 }); + doc.submitSnapshot({ test: 7 }); + }); + + it('submits a snapshot (no diff, no diffX, with callback)', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.on('error', done); + doc.create({ test: 5 }); + doc.submitSnapshot({ test: 7 }, function(error) { + expect(error).to.be.an(Error); + expect(error.code).to.equal(4024); + done(); + }); + }); + + it('submits a snapshot without a diffHint', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.create([ otRichText.Action.createInsertText('aaaa') ], otRichText.type.uri); + doc.on('op', function(op) { + expect(doc.data).to.eql([ otRichText.Action.createInsertText('aaaaa') ]); + expect(op).to.eql([ otRichText.Action.createInsertText('a') ]); + done(); + }); + doc.submitSnapshot([ otRichText.Action.createInsertText('aaaaa') ]); + }); + + it('submits a snapshot with a diffHint', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.create([ otRichText.Action.createInsertText('aaaa') ], otRichText.type.uri); + doc.on('op', function(op) { + expect(doc.data).to.eql([ otRichText.Action.createInsertText('aaaaa') ]); + expect(op).to.eql([ otRichText.Action.createRetain(2), otRichText.Action.createInsertText('a') ]); + done(); + }); + doc.submitSnapshot([ otRichText.Action.createInsertText('aaaaa') ], { diffHint: 2 }); + }); + describe('type.deserialize', function() { it('can create a new doc', function(done) { var doc = this.backend.connect().get('dogs', 'fido'); diff --git a/test/client/undo-redo.js b/test/client/undo-redo.js index 3aff7b0b1..fe9681a54 100644 --- a/test/client/undo-redo.js +++ b/test/client/undo-redo.js @@ -846,60 +846,6 @@ describe('client undo/redo', function() { expect(this.doc.data).to.equal(11111); }); - it('does not skip processing when submitting a no-op by default', function(done) { - this.doc.on('op', function() { - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('test') ]); - done(); - }.bind(this)); - this.doc.create([ otRichText.Action.createInsertText('test') ], otRichText.type.uri); - this.doc.submitOp([]); - }); - - it('does not skip processing when submitting an identical snapshot by default', function(done) { - this.doc.on('op', function() { - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('test') ]); - done(); - }.bind(this)); - this.doc.create([ otRichText.Action.createInsertText('test') ], otRichText.type.uri); - this.doc.submitSnapshot([ otRichText.Action.createInsertText('test') ]); - }); - - it('skips processing when submitting a no-op (no callback)', function(done) { - this.doc.on('op', function() { - done(new Error('Should not emit `op`')); - }); - this.doc.create([ otRichText.Action.createInsertText('test') ], otRichText.type.uri); - this.doc.submitOp([], { skipNoop: true }); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('test') ]); - done(); - }); - - it('skips processing when submitting a no-op (with callback)', function(done) { - this.doc.on('op', function() { - done(new Error('Should not emit `op`')); - }); - this.doc.create([ otRichText.Action.createInsertText('test') ], otRichText.type.uri); - this.doc.submitOp([], { skipNoop: true }, done); - }); - - it('skips processing when submitting an identical snapshot (no callback)', function(done) { - this.doc.on('op', function() { - done(new Error('Should not emit `op`')); - }); - this.doc.create([ otRichText.Action.createInsertText('test') ], otRichText.type.uri); - this.doc.submitSnapshot([ otRichText.Action.createInsertText('test') ], { skipNoop: true }); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('test') ]); - done(); - }); - - it('skips processing when submitting an identical snapshot (with callback)', function(done) { - this.doc.on('op', function() { - done(new Error('Should not emit `op`')); - }); - this.doc.create([ otRichText.Action.createInsertText('test') ], otRichText.type.uri); - this.doc.submitSnapshot([ otRichText.Action.createInsertText('test') ], { skipNoop: true }, done); - }); - describe('fixup operations', function() { beforeEach(function() { var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); @@ -1409,333 +1355,189 @@ describe('client undo/redo', function() { }); }); - describe('submitSnapshot', function() { - describe('basic tests', function() { - it('submits a snapshot when document is not created (no callback, no options)', function(done) { - this.doc.on('error', function(error) { - expect(error).to.be.an(Error); - expect(error.code).to.equal(4015); - done(); - }); - this.doc.submitSnapshot(7); - }); - - it('submits a snapshot when document is not created (no callback, with options)', function(done) { - this.doc.on('error', function(error) { - expect(error).to.be.an(Error); - expect(error.code).to.equal(4015); - done(); - }); - this.doc.submitSnapshot(7, { source: 'test' }); - }); - - it('submits a snapshot when document is not created (with callback, no options)', function(done) { - this.doc.on('error', done); - this.doc.submitSnapshot(7, function(error) { - expect(error).to.be.an(Error); - expect(error.code).to.equal(4015); - done(); - }); - }); - - it('submits a snapshot when document is not created (with callback, with options)', function(done) { - this.doc.on('error', done); - this.doc.submitSnapshot(7, { source: 'test' }, function(error) { - expect(error).to.be.an(Error); - expect(error.code).to.equal(4015); - done(); - }); - }); - - it('submits a snapshot with source (no callback)', function(done) { - var undoManager = this.connection.createUndoManager(); - this.doc.on('op', function(op, source) { - expect(op).to.eql([ otRichText.Action.createInsertText('abc') ]); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcdef') ]); - expect(undoManager.canUndo()).to.equal(false); - expect(undoManager.canRedo()).to.equal(false); - expect(source).to.equal('test'); - done(); - }.bind(this)); - this.doc.create([ otRichText.Action.createInsertText('def') ], otRichText.type.uri); - this.doc.submitSnapshot([ otRichText.Action.createInsertText('abcdef') ], { source: 'test' }); - }); - - it('submits a snapshot with source (with callback)', function(done) { - var undoManager = this.connection.createUndoManager(); - var opEmitted = false; - this.doc.on('op', function(op, source) { - expect(op).to.eql([ otRichText.Action.createInsertText('abc') ]); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcdef') ]); - expect(source).to.equal('test'); - expect(undoManager.canUndo()).to.equal(false); - expect(undoManager.canRedo()).to.equal(false); - opEmitted = true; - }.bind(this)); - this.doc.create([ otRichText.Action.createInsertText('def') ], otRichText.type.uri); - this.doc.submitSnapshot([ otRichText.Action.createInsertText('abcdef') ], { source: 'test' }, function(error) { - expect(opEmitted).to.equal(true); - done(error); - }); - }); - - it('submits a snapshot without source (no callback)', function(done) { - var undoManager = this.connection.createUndoManager(); - this.doc.on('op', function(op, source) { - expect(op).to.eql([ otRichText.Action.createInsertText('abc') ]); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcdef') ]); - expect(undoManager.canUndo()).to.equal(false); - expect(undoManager.canRedo()).to.equal(false); - expect(source).to.equal(true); - done(); - }.bind(this)); - this.doc.create([ otRichText.Action.createInsertText('def') ], otRichText.type.uri); - this.doc.submitSnapshot([ otRichText.Action.createInsertText('abcdef') ]); - }); + it('submits snapshots and supports undo and redo', function() { + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); + this.doc.create([ otRichText.Action.createInsertText('ghi') ], otRichText.type.uri); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('defghi') ], { undoable: true }); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('defghi') ]); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('abcdefghi') ], { undoable: true }); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcdefghi') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('defghi') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ghi') ]); + expect(undoManager.canUndo()).to.equal(false); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('defghi') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcdefghi') ]); + expect(undoManager.canRedo()).to.equal(false); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('defghi') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ghi') ]); + expect(undoManager.canUndo()).to.equal(false); + }); - it('submits a snapshot without source (with callback)', function(done) { - var undoManager = this.connection.createUndoManager(); - var opEmitted = false; - this.doc.on('op', function(op, source) { - expect(op).to.eql([ otRichText.Action.createInsertText('abc') ]); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcdef') ]); - expect(source).to.equal(true); - expect(undoManager.canUndo()).to.equal(false); - expect(undoManager.canRedo()).to.equal(false); - opEmitted = true; - }.bind(this)); - this.doc.create([ otRichText.Action.createInsertText('def') ], otRichText.type.uri); - this.doc.submitSnapshot([ otRichText.Action.createInsertText('abcdef') ], function(error) { - expect(opEmitted).to.equal(true); - done(error); - }); - }); + it('submits snapshots and composes operations', function() { + var undoManager = this.connection.createUndoManager(); + this.doc.create([ otRichText.Action.createInsertText('ghi') ], otRichText.type.uri); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('defghi') ], { undoable: true }); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('defghi') ]); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('abcdefghi') ], { undoable: true }); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcdefghi') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ghi') ]); + expect(undoManager.canUndo()).to.equal(false); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcdefghi') ]); + expect(undoManager.canRedo()).to.equal(false); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ghi') ]); + expect(undoManager.canUndo()).to.equal(false); + }); - it('submits snapshots and supports undo and redo', function() { - var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); - this.doc.create([ otRichText.Action.createInsertText('ghi') ], otRichText.type.uri); - this.doc.submitSnapshot([ otRichText.Action.createInsertText('defghi') ], { undoable: true }); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('defghi') ]); - this.doc.submitSnapshot([ otRichText.Action.createInsertText('abcdefghi') ], { undoable: true }); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcdefghi') ]); - undoManager.undo(); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('defghi') ]); - undoManager.undo(); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ghi') ]); - expect(undoManager.canUndo()).to.equal(false); - undoManager.redo(); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('defghi') ]); - undoManager.redo(); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcdefghi') ]); - expect(undoManager.canRedo()).to.equal(false); - undoManager.undo(); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('defghi') ]); - undoManager.undo(); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ghi') ]); - expect(undoManager.canUndo()).to.equal(false); - }); + it('submits undoable and non-undoable snapshots', function() { + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); + this.doc.create([], otRichText.type.uri); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('a') ], { undoable: true }); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('ab') ], { undoable: true }); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('abc') ], { undoable: true }); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('abcd') ], { undoable: true }); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('abcde') ], { undoable: true }); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('abcdef') ], { undoable: true }); + undoManager.undo(); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcd') ]); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('abc123d') ]); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abc123d') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abc123') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ab123') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('a123') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('123') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('a123') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ab123') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abc123') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abc123d') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abc123de') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abc123def') ]); + }); - it('submits snapshots and composes operations', function() { - var undoManager = this.connection.createUndoManager(); - this.doc.create([ otRichText.Action.createInsertText('ghi') ], otRichText.type.uri); - this.doc.submitSnapshot([ otRichText.Action.createInsertText('defghi') ], { undoable: true }); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('defghi') ]); - this.doc.submitSnapshot([ otRichText.Action.createInsertText('abcdefghi') ], { undoable: true }); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcdefghi') ]); - undoManager.undo(); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ghi') ]); - expect(undoManager.canUndo()).to.equal(false); - undoManager.redo(); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcdefghi') ]); - expect(undoManager.canRedo()).to.equal(false); - undoManager.undo(); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ghi') ]); - expect(undoManager.canUndo()).to.equal(false); - }); + it('submits a snapshot without a diffHint', function() { + var undoManager = this.connection.createUndoManager(); + var opCalled = 0; + this.doc.create([ otRichText.Action.createInsertText('aaaa') ], otRichText.type.uri); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('aaaaa') ], { undoable: true }); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('aaaaa') ]); - it('submits a snapshot and syncs it', function(done) { - this.doc2.on('create', function() { - this.doc2.submitSnapshot([ otRichText.Action.createInsertText('abcdef') ]); - }.bind(this)); - this.doc.on('op', function(op, source) { - expect(op).to.eql([ otRichText.Action.createInsertText('abc') ]); - expect(source).to.equal(false); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcdef') ]); - done(); - }.bind(this)); - this.doc2.subscribe(); - this.doc.subscribe(); - this.doc.create([ otRichText.Action.createInsertText('def') ], otRichText.type.uri); - }); + this.doc.once('op', function(op) { + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('aaaa') ]); + expect(op).to.eql([ otRichText.Action.createDelete(1) ]); + opCalled++; + }.bind(this)); + undoManager.undo(); - it('submits undoable and fixed operations', function() { - var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); - this.doc.create([], otRichText.type.uri); - this.doc.submitSnapshot([ otRichText.Action.createInsertText('a') ], { undoable: true }); - this.doc.submitSnapshot([ otRichText.Action.createInsertText('ab') ], { undoable: true }); - this.doc.submitSnapshot([ otRichText.Action.createInsertText('abc') ], { undoable: true }); - this.doc.submitSnapshot([ otRichText.Action.createInsertText('abcd') ], { undoable: true }); - this.doc.submitSnapshot([ otRichText.Action.createInsertText('abcde') ], { undoable: true }); - this.doc.submitSnapshot([ otRichText.Action.createInsertText('abcdef') ], { undoable: true }); - undoManager.undo(); - undoManager.undo(); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcd') ]); - this.doc.submitSnapshot([ otRichText.Action.createInsertText('abc123d') ]); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abc123d') ]); - undoManager.undo(); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abc123') ]); - undoManager.undo(); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ab123') ]); - undoManager.undo(); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('a123') ]); - undoManager.undo(); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('123') ]); - undoManager.redo(); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('a123') ]); - undoManager.redo(); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ab123') ]); - undoManager.redo(); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abc123') ]); - undoManager.redo(); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abc123d') ]); - undoManager.redo(); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abc123de') ]); - undoManager.redo(); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abc123def') ]); - }); + this.doc.once('op', function(op) { + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('aaaaa') ]); + expect(op).to.eql([ otRichText.Action.createInsertText('a') ]); + opCalled++; + }.bind(this)); + undoManager.redo(); - it('submits a snapshot without a diffHint', function() { - var undoManager = this.connection.createUndoManager(); - var opCalled = 0; - this.doc.create([ otRichText.Action.createInsertText('aaaa') ], otRichText.type.uri); - this.doc.submitSnapshot([ otRichText.Action.createInsertText('aaaaa') ], { undoable: true }); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('aaaaa') ]); - - this.doc.once('op', function(op) { - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('aaaa') ]); - expect(op).to.eql([ otRichText.Action.createDelete(1) ]); - opCalled++; - }.bind(this)); - undoManager.undo(); + expect(opCalled).to.equal(2); + }); - this.doc.once('op', function(op) { - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('aaaaa') ]); - expect(op).to.eql([ otRichText.Action.createInsertText('a') ]); - opCalled++; - }.bind(this)); - undoManager.redo(); + it('submits a snapshot with a diffHint', function() { + var undoManager = this.connection.createUndoManager(); + var opCalled = 0; + this.doc.create([ otRichText.Action.createInsertText('aaaa') ], otRichText.type.uri); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('aaaaa') ], { undoable: true, diffHint: 2 }); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('aaaaa') ]); - expect(opCalled).to.equal(2); - }); + this.doc.once('op', function(op) { + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('aaaa') ]); + expect(op).to.eql([ otRichText.Action.createRetain(2), otRichText.Action.createDelete(1) ]); + opCalled++; + }.bind(this)); + undoManager.undo(); - it('submits a snapshot with a diffHint', function() { - var undoManager = this.connection.createUndoManager(); - var opCalled = 0; - this.doc.create([ otRichText.Action.createInsertText('aaaa') ], otRichText.type.uri); - this.doc.submitSnapshot([ otRichText.Action.createInsertText('aaaaa') ], { undoable: true, diffHint: 2 }); - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('aaaaa') ]); - - this.doc.once('op', function(op) { - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('aaaa') ]); - expect(op).to.eql([ otRichText.Action.createRetain(2), otRichText.Action.createDelete(1) ]); - opCalled++; - }.bind(this)); - undoManager.undo(); + this.doc.once('op', function(op) { + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('aaaaa') ]); + expect(op).to.eql([ otRichText.Action.createRetain(2), otRichText.Action.createInsertText('a') ]); + opCalled++; + }.bind(this)); + undoManager.redo(); - this.doc.once('op', function(op) { - expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('aaaaa') ]); - expect(op).to.eql([ otRichText.Action.createRetain(2), otRichText.Action.createInsertText('a') ]); - opCalled++; - }.bind(this)); - undoManager.redo(); + expect(opCalled).to.equal(2); + }); - expect(opCalled).to.equal(2); - }); - }); + it('submits a snapshot (with diff, non-undoable)', function() { + var undoManager = this.connection.createUndoManager(); + this.doc.create(5, invertibleType.typeWithDiff.uri); + this.doc.submitSnapshot(7); + expect(this.doc.data).to.equal(7); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); + }); - describe('no diff nor diffX', function() { - it('submits a snapshot (no callback)', function(done) { - this.doc.on('error', function(error) { - expect(error).to.be.an(Error); - expect(error.code).to.equal(4024); - done(); - }); - this.doc.create(5, invertibleType.type.uri); - this.doc.submitSnapshot(7); - }); + it('submits a snapshot (with diff, undoable)', function() { + var undoManager = this.connection.createUndoManager(); + this.doc.create(5, invertibleType.typeWithDiff.uri); + this.doc.submitSnapshot(7, { undoable: true }); + expect(this.doc.data).to.equal(7); + undoManager.undo(); + expect(this.doc.data).to.equal(5); + undoManager.redo(); + expect(this.doc.data).to.equal(7); + }); - it('submits a snapshot (with callback)', function(done) { - this.doc.on('error', done); - this.doc.create(5, invertibleType.type.uri); - this.doc.submitSnapshot(7, function(error) { - expect(error).to.be.an(Error); - expect(error.code).to.equal(4024); - done(); - }); - }); - }); + it('submits a snapshot (with diffX, non-undoable)', function() { + var undoManager = this.connection.createUndoManager(); + this.doc.create(5, invertibleType.typeWithDiffX.uri); + this.doc.submitSnapshot(7); + expect(this.doc.data).to.equal(7); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); + }); - describe('with diff', function () { - it('submits a snapshot (non-undoable)', function() { - var undoManager = this.connection.createUndoManager(); - this.doc.create(5, invertibleType.typeWithDiff.uri); - this.doc.submitSnapshot(7); - expect(this.doc.data).to.equal(7); - expect(undoManager.canUndo()).to.equal(false); - expect(undoManager.canRedo()).to.equal(false); - }); - it('submits a snapshot (undoable)', function() { - var undoManager = this.connection.createUndoManager(); - this.doc.create(5, invertibleType.typeWithDiff.uri); - this.doc.submitSnapshot(7, { undoable: true }); - expect(this.doc.data).to.equal(7); - undoManager.undo(); - expect(this.doc.data).to.equal(5); - undoManager.redo(); - expect(this.doc.data).to.equal(7); - }); - }); + it('submits a snapshot (with diffX, undoable)', function() { + var undoManager = this.connection.createUndoManager(); + this.doc.create(5, invertibleType.typeWithDiffX.uri); + this.doc.submitSnapshot(7, { undoable: true }); + expect(this.doc.data).to.equal(7); + undoManager.undo(); + expect(this.doc.data).to.equal(5); + undoManager.redo(); + expect(this.doc.data).to.equal(7); + }); - describe('with diffX', function () { - it('submits a snapshot (non-undoable)', function() { - var undoManager = this.connection.createUndoManager(); - this.doc.create(5, invertibleType.typeWithDiffX.uri); - this.doc.submitSnapshot(7); - expect(this.doc.data).to.equal(7); - expect(undoManager.canUndo()).to.equal(false); - expect(undoManager.canRedo()).to.equal(false); - }); - it('submits a snapshot (undoable)', function() { - var undoManager = this.connection.createUndoManager(); - this.doc.create(5, invertibleType.typeWithDiffX.uri); - this.doc.submitSnapshot(7, { undoable: true }); - expect(this.doc.data).to.equal(7); - undoManager.undo(); - expect(this.doc.data).to.equal(5); - undoManager.redo(); - expect(this.doc.data).to.equal(7); - }); - }); + it('submits a snapshot (with diff and diffX, non-undoable)', function() { + var undoManager = this.connection.createUndoManager(); + this.doc.create(5, invertibleType.typeWithDiffAndDiffX.uri); + this.doc.submitSnapshot(7); + expect(this.doc.data).to.equal(7); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); + }); - describe('with diff and diffX', function () { - it('submits a snapshot (non-undoable)', function() { - var undoManager = this.connection.createUndoManager(); - this.doc.create(5, invertibleType.typeWithDiffAndDiffX.uri); - this.doc.submitSnapshot(7); - expect(this.doc.data).to.equal(7); - expect(undoManager.canUndo()).to.equal(false); - expect(undoManager.canRedo()).to.equal(false); - }); - it('submits a snapshot (undoable)', function() { - var undoManager = this.connection.createUndoManager(); - this.doc.create(5, invertibleType.typeWithDiffAndDiffX.uri); - this.doc.submitSnapshot(7, { undoable: true }); - expect(this.doc.data).to.equal(7); - undoManager.undo(); - expect(this.doc.data).to.equal(5); - undoManager.redo(); - expect(this.doc.data).to.equal(7); - }); - }); + it('submits a snapshot (with diff and diffX, undoable)', function() { + var undoManager = this.connection.createUndoManager(); + this.doc.create(5, invertibleType.typeWithDiffAndDiffX.uri); + this.doc.submitSnapshot(7, { undoable: true }); + expect(this.doc.data).to.equal(7); + undoManager.undo(); + expect(this.doc.data).to.equal(5); + undoManager.redo(); + expect(this.doc.data).to.equal(7); }); }); From 762496aae09e90468c51fe6f1b88dddfb4cbde1b Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Tue, 10 Jul 2018 11:30:02 +0200 Subject: [PATCH 43/45] Remove cached ops without using setTimeout See https://github.com/share/sharedb/issues/219 --- lib/client/doc.js | 20 ++++++++++++--- test/client/presence.js | 54 +++++++++++++++++++---------------------- 2 files changed, 41 insertions(+), 33 deletions(-) diff --git a/lib/client/doc.js b/lib/client/doc.js index b07554353..42d8d37dc 100644 --- a/lib/client/doc.js +++ b/lib/client/doc.js @@ -83,7 +83,7 @@ function Doc(connection, collection, id) { this.requestReplyPresence = true; // A list of ops sent by the server. These are needed for transforming presence data, // if we get that presence data for an older version of the document. - // The ops are cached for 1 minute by default, which should be lots, considering that the presence + // The ops are cached for at least 1 minute by default, which should be lots, considering that the presence // data is supposed to be synced in real-time. this.cachedOps = []; this.cachedOpsTimeout = 60000; @@ -362,6 +362,7 @@ Doc.prototype._handleOp = function(err, message) { var serverOp = { src: message.src, + time: Date.now(), create: !!message.create, op: message.op, del: !!message.del @@ -914,6 +915,7 @@ Doc.prototype._opAcknowledged = function(message) { this.version++; this._cacheOp({ src: this.inflightOp.src, + time: Date.now(), create: !!this.inflightOp.create, op: this.inflightOp.op, del: !!this.inflightOp.del @@ -1283,8 +1285,18 @@ Doc.prototype._emitPresence = function(srcList, submitted) { }; Doc.prototype._cacheOp = function(op) { + // Remove the old ops. + var oldOpTime = Date.now() - this.cachedOpsTimeout; + var i; + for (i = 0; i < this.cachedOps.length; i++) { + if (this.cachedOps[i].time >= oldOpTime) { + break; + } + } + if (i > 0) { + this.cachedOps.splice(0, i); + } + + // Cache the new op. this.cachedOps.push(op); - setTimeout(function() { - if (this.cachedOps[0] === op) this.cachedOps.shift(); - }.bind(this), this.cachedOpsTimeout); }; diff --git a/test/client/presence.js b/test/client/presence.js index 4f9a3d31e..2bf7e7dab 100644 --- a/test/client/presence.js +++ b/test/client/presence.js @@ -473,48 +473,44 @@ types.register(presenceType.type3); ], allDone); }); - it('removes cached ops', function(allDone) { - var op = { index: 1, value: 'b' }; - this.doc.cachedOpsTimeout = 0; + it('expires cached ops', function(allDone) { + var op1 = { index: 1, value: 'b' }; + var op2 = { index: 2, value: 'b' }; + var op3 = { index: 3, value: 'b' }; + this.doc.cachedOpsTimeout = 60; async.series([ + // Cache 2 ops. this.doc.create.bind(this.doc, [ 'a' ], typeName), - this.doc.submitOp.bind(this.doc, op), - this.doc.del.bind(this.doc), + this.doc.submitOp.bind(this.doc, op1), function(done) { - expect(this.doc.cachedOps.length).to.equal(3); + expect(this.doc.cachedOps.length).to.equal(2); expect(this.doc.cachedOps[0].create).to.equal(true); - expect(this.doc.cachedOps[1].op).to.equal(op); - expect(this.doc.cachedOps[2].del).to.equal(true); + expect(this.doc.cachedOps[1].op).to.equal(op1); done(); }.bind(this), - setTimeout, - function(done) { - expect(this.doc.cachedOps.length).to.equal(0); - done(); - }.bind(this) - ], allDone); - }); - it('removes correct cached ops', function(allDone) { - var op = { index: 1, value: 'b' }; - this.doc.cachedOpsTimeout = 0; - async.series([ - this.doc.create.bind(this.doc, [ 'a' ], typeName), - this.doc.submitOp.bind(this.doc, op), - this.doc.del.bind(this.doc), + // Cache another op before the first 2 expire. + function (callback) { + setTimeout(callback, 30); + }, + this.doc.submitOp.bind(this.doc, op2), function(done) { expect(this.doc.cachedOps.length).to.equal(3); expect(this.doc.cachedOps[0].create).to.equal(true); - expect(this.doc.cachedOps[1].op).to.equal(op); - expect(this.doc.cachedOps[2].del).to.equal(true); - this.doc.cachedOps.shift(); - this.doc.cachedOps.push({ op: true }); + expect(this.doc.cachedOps[1].op).to.equal(op1); + expect(this.doc.cachedOps[2].op).to.equal(op2); done(); }.bind(this), - setTimeout, + + // Cache another op after the first 2 expire. + function (callback) { + setTimeout(callback, 31); + }, + this.doc.submitOp.bind(this.doc, op3), function(done) { - expect(this.doc.cachedOps.length).to.equal(1); - expect(this.doc.cachedOps[0].op).to.equal(true); + expect(this.doc.cachedOps.length).to.equal(2); + expect(this.doc.cachedOps[0].op).to.equal(op2); + expect(this.doc.cachedOps[1].op).to.equal(op3); done(); }.bind(this) ], allDone); From e4c5e6d827656fe3781be702cdd6b8fc7f512a02 Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Fri, 20 Jul 2018 13:44:05 +0200 Subject: [PATCH 44/45] Remove --exit mocha option --- test/mocha.opts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/mocha.opts b/test/mocha.opts index 7ca4707b0..34f904192 100644 --- a/test/mocha.opts +++ b/test/mocha.opts @@ -1,4 +1,3 @@ --reporter spec --check-leaks --recursive ---exit From 428c46a61b6ea5fdefb440a3740f677051d7ed8e Mon Sep 17 00:00:00 2001 From: Greg Kubisa Date: Fri, 20 Jul 2018 13:50:46 +0200 Subject: [PATCH 45/45] Workaround for circular dependency --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 21efafe46..736e5fe78 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,6 +3,6 @@ node_js: - "10" - "8" - "6" -script: "npm run jshint && npm run test-cover" +script: "ln -s .. node_modules/sharedb; npm run jshint && npm run test-cover" # Send coverage data to Coveralls after_script: "cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js"