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..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" diff --git a/README.md b/README.md index 6e65f0187..47736e291 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ tracker](https://github.com/share/sharedb/issues). - Realtime synchronization of any JSON document - Concurrent multi-user collaboration +- Realtime synchronization of any ephemeral "presence" data +- 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) @@ -38,7 +40,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. @@ -73,6 +75,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 @@ -228,9 +234,15 @@ changes. Returns a [`ShareDB.Query`](#class-sharedbquery) instance. * `options.*` All other options are passed through to the database adapter. +`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.composeInterval` the max time difference between operations in milliseconds, which still allows the operations to be composed on the undo stack. + ### Class: `ShareDB.Doc` -`doc.type` _(String_) +`doc.type` _(String)_ The [OT type](https://github.com/ottypes/docs) of this document `doc.id` _(String)_ @@ -239,6 +251,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. @@ -268,6 +283,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, 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. @@ -287,6 +305,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.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`. + +`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 `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) {...}])` Delete the document locally and send delete operation to the server. @@ -301,6 +332,12 @@ 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)_ @@ -338,6 +375,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 @@ -376,6 +435,11 @@ 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 +* 4027 - OT Type does not support `diff` nor `diffX` +* 4028 - OT Type does not support `invert` nor `applyAndInvert` ### 5000 - Internal error diff --git a/lib/agent.js b/lib/agent.js index d1a944de4..ac9c12d70 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,34 @@ 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 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 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); + 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 3156cfc82..a8553683a 100644 --- a/lib/backend.js +++ b/lib/backend.js @@ -568,6 +568,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..19755d974 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; @@ -243,6 +247,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 +417,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 @@ -584,3 +610,46 @@ Connection.prototype._firstQuery = function(fn) { } } }; + +Connection.prototype.createUndoManager = 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.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); + } +}; + +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 05e17976d..1fee1e6e9 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 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; + // 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 @@ -104,11 +139,26 @@ 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(function(err) { + if (err) { + if (callback) callback(err); + else this.emit('error', err); + return; + } + doc.receivedPresence = Object.create(null); + doc.cachedOps.length = 0; + doc.connection._destroyDoc(doc); + doc.connection.onDocDestroy(doc); + if (callback) callback(); + }); + } else { + doc.receivedPresence = Object.create(null); + doc.cachedOps.length = 0; + doc.connection._destroyDoc(doc); + doc.connection.onDocDestroy(doc); + if (callback) callback(); } - if (callback) callback(); }); }; @@ -186,12 +236,15 @@ 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.connection.onDocLoad(this); this.emit('load'); + this._processAllReceivedPresence(); callback && callback(); }; @@ -210,7 +263,9 @@ Doc.prototype.hasPending = function() { this.inflightFetch.length || this.inflightSubscribe.length || this.inflightUnsubscribe.length || - this.pendingFetch.length + this.pendingFetch.length || + this.inflightPresence || + this.pendingPresence ); }; @@ -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,14 @@ Doc.prototype._handleOp = function(err, message) { return; } + var serverOp = { + src: message.src, + time: Date.now(), + 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 +382,9 @@ Doc.prototype._handleOp = function(err, message) { } this.version++; - this._otApply(message, false); + this._cacheOp(serverOp); + this._otApply(message); + this._processAllReceivedPresence(); return; }; @@ -342,7 +408,10 @@ Doc.prototype._onConnectionStateChanged = function() { if (this.inflightUnsubscribe.length) { var callbacks = this.inflightUnsubscribe; this.inflightUnsubscribe = []; + this._pausePresence(); callEach(callbacks); + } else { + this._pausePresence(); } } }; @@ -402,8 +471,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 +497,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. @@ -500,12 +579,16 @@ 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); return this.emit('error', err); } + var undoOp = options && options.undoOp || null; + 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 @@ -537,7 +620,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._applyOp(componentOp, undoOp, source, undoable, fixUp); + this._transformAllPresence(componentOp); this.emit('op', componentOp.op, source); } // Pop whatever was submitted since we started applying this op @@ -549,7 +633,8 @@ 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); + this._applyOp(op, undoOp, source, undoable, fixUp); + 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 +651,8 @@ 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.connection.onDocCreate(this); this.emit('create', source); return; } @@ -573,11 +660,30 @@ Doc.prototype._otApply = function(op, source) { if (op.del) { var oldData = this.data; this._setType(null); + this._transformAllPresence(op); + 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, source, undoable, fixUp) { + if (undoOp == null && (undoable || fixUp || op.needsUndoOp)) { + if (this.type.applyAndInvert) { + var result = this.type.applyAndInvert(this.data, op.op); + this.data = result[0]; + undoOp = { op: result[1] }; + } else { + 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); + } + + this.connection.onDocOp(this, op, undoOp, source, undoable, fixUp); +}; // ***** Sending operations @@ -630,10 +736,13 @@ Doc.prototype._sendOp = function() { // @param [op.op] // @param [op.del] // @param [op.create] +// @param options { source, skipNoop, undoable, undoOp, fixUp } // @param [callback] called when operation is submitted -Doc.prototype._submit = function(op, source, callback) { +Doc.prototype._submit = function(op, options, callback) { + if (!options) options = {}; + // 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) { @@ -642,12 +751,23 @@ Doc.prototype._submit = function(op, source, callback) { if (callback) return callback(err); return this.emit('error', err); } + var needsUndoOp = options.undoable || options.fixUp || op.needsUndoOp; + if (needsUndoOp && !this.type.invert && !this.type.applyAndInvert) { + var err = new ShareDBError(4028, '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. + if (options.skipNoop && 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 +853,85 @@ 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.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 accordingly. This feature requires the OT type to implement `compose`. // @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, + skipNoop: options && options.skipNoop, + undoable: options && options.undoable, + fixUp: options && options.fixUp + }; + 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.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 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 + +// @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(4027, '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 fixUp = options && options.fixUp; + var diffHint = options && options.diffHint; + var needsUndoOp = undoable || fixUp; + 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, + skipNoop: options && options.skipNoop, + undoable: undoable, + undoOp: undoOp, + fixUp: fixUp + }; + this._submit(op, submitOptions, callback); }; // Create the document, which in ShareJS semantics means to set its type. Every @@ -776,7 +962,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 +984,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); }; @@ -820,6 +1006,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 +1019,16 @@ Doc.prototype._opAcknowledged = function(message) { // The op was committed successfully. Increment the version number this.version++; + this._cacheOp({ + src: this.inflightOp.src, + time: Date.now(), + create: !!this.inflightOp.create, + op: this.inflightOp.op, + del: !!this.inflightOp.del + }); this._clearInflightOp(); + this._processAllReceivedPresence(); }; Doc.prototype._rollback = function(err) { @@ -858,7 +1053,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; @@ -868,29 +1063,54 @@ 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, false); // 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); }); }; 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(); @@ -908,3 +1128,281 @@ function callEach(callbacks, err) { } return called; } + +// *** Presence + +Doc.prototype.submitPresence = function (data, callback) { + if (data != null) { + if (!this.type) { + 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 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); + } + + 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() { + 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(); + this._emitNothingPending(); + } + 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, true); +}; + +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, false); +}; + +Doc.prototype._pausePresence = function() { + if (this.inflightPresence) { + this.pendingPresence = + this.pendingPresence ? + this.inflightPresence.concat(this.pendingPresence) : + 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; + 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, false); +}; + +// 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 ], true); + return true; +}; + +Doc.prototype._emitPresence = function(srcList, submitted) { + if (srcList && srcList.length > 0) { + var doc = this; + process.nextTick(function() { + doc.emit('presence', 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); +}; diff --git a/lib/client/undoManager.js b/lib/client/undoManager.js new file mode 100644 index 000000000..2c21312a4 --- /dev/null +++ b/lib/client/undoManager.js @@ -0,0 +1,293 @@ +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; + /* 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; +} + +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`. +module.exports = UndoManager; +function UndoManager(connection, options) { + // The Connection which created this UndoManager. + this._connection = connection; + + // 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. + 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._composeInterval = options && typeof options.composeInterval === 'number' ? options.composeInterval : 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; +} + +UndoManager.prototype.destroy = function() { + this._connection.removeUndoManager(this); + this.clear(); +}; + +// 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. +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; + } + + var op = getLast(this._undoStack); + var submitOptions = { source: options && options.source }; + 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; + } + + var op = getLast(this._redoStack); + var submitOptions = { source: options && options.source }; + op.doc._submit(op, submitOptions, callback); +}; + +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. +}; + +UndoManager.prototype.onDocDelete = function(doc) { + // 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) { + if (this.canUndo() && getLast(this._undoStack) === op) { + this._undoStack.pop(); + this._updateStacksUndo(doc, op.op, undoOp.op); + + } 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.op, undoOp.op); + + } else { + this._updateStacksFixed(doc, op.op, undoOp && undoOp.op, 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._composeInterval + ) { + 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 Op(composedOp, doc)); + } else { + 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 Op(composedOp, doc)); + + } else { + this._undoStack.push(new Op(undoOp, 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) { + /* istanbul ignore else */ + if (!doc.type.isNoop || !doc.type.isNoop(undoOp)) { + this._redoStack.push(new Op(undoOp, doc)); + } + this._previousUndoableOperationTime = -Infinity; +}; + +UndoManager.prototype._updateStacksRedo = function(doc, op, undoOp) { + /* istanbul ignore else */ + if (!doc.type.isNoop || !doc.type.isNoop(undoOp)) { + this._undoStack.push(new Op(undoOp, doc)); + } + this._previousUndoableOperationTime = -Infinity; +}; + +UndoManager.prototype._updateStacksFixed = function(doc, op, undoOp, fixUp) { + 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 Op(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, lastOp.op); + if (!doc.type.isNoop || !doc.type.isNoop(composedOp)) { + this._redoStack[lastRedoIndex] = new Op(composedOp, doc); + } else { + this._redoStack.splice(lastRedoIndex, 1); + } + } + + } else { + this._undoStack = this._transformStack(this._undoStack, doc, op); + this._redoStack = this._transformStack(this._redoStack, doc, 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 item = stack[i]; + if (item.doc !== doc) { + newStack[newStackIndex++] = item; + continue; + } + var stackOp = item.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 Op(transformedStackOp, doc); + } + + op = transformedOp; + } + + return newStack.reverse(); +}; 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.json b/package.json index 35fc64bc6..2e34445e8 100644 --- a/package.json +++ b/package.json @@ -5,19 +5,23 @@ "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", "ot-json0": "^1.0.1" }, "devDependencies": { - "coveralls": "^2.11.8", + "@teamwork/ot-rich-text": "^6.3.3", + "coveralls": "^3.0.2", "expect.js": "^0.3.1", "istanbul": "^0.4.2", "jshint": "^2.9.2", - "mocha": "^3.2.0", - "sharedb-mingo-memory": "^1.0.0-beta" + "lolex": "^2.7.1", + "mocha": "^5.2.0", + "ot-text": "^1.0.1", + "rich-text": "^3.1.0", + "sharedb-mingo-memory": "^1.0.1" }, "scripts": { "test": "./node_modules/.bin/mocha && npm run jshint", 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(); 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/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/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..2bf7e7dab --- /dev/null +++ b/test/client/presence.js @@ -0,0 +1,1441 @@ +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, 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(); + }.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, 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(); + }.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, 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(); + }.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, 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(); + }.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, 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(); + }.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, 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(); + }.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, 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(); + }.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, 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(); + }.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, 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(); + }.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, 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(); + }.bind(this)); + this.doc.requestReplyPresence = false; + this.doc.submitPresence(p(0), errorHandler(done)); + }.bind(this), + function(done) { + 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(); + }.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, 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(); + }.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, 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(); + }.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('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)), + setTimeout, + function(done) { + 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(); + }.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)), + setTimeout, + function(done) { + 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(); + }.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)), + setTimeout, + function(done) { + 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(); + }.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)), + setTimeout, + function(done) { + 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(); + }.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)), + setTimeout, + function(done) { + 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(); + }.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)), + setTimeout, + function(done) { + 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(); + }.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), + setTimeout, + 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('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, op1), + function(done) { + 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(op1); + done(); + }.bind(this), + + // 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(op1); + expect(this.doc.cachedOps[2].op).to.equal(op2); + done(); + }.bind(this), + + // 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(2); + expect(this.doc.cachedOps[0].op).to.equal(op2); + expect(this.doc.cachedOps[1].op).to.equal(op3); + 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, 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 { + 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, 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)); + 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, 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)); + 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, 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)); + 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, 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)); + this.doc.requestReplyPresence = false; + this.doc.submitPresence(p(0)); + }.bind(this) + ], 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) { + 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('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) { + 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); + }); + + 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)), + setTimeout, + function(done) { + expect(this.doc2.presence[this.connection.id]).to.eql(p(0)); + 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(1), 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)), + setTimeout, + 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, 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(); + }.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)), + setTimeout, + 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, 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(); + }.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)), + setTimeout, + 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, 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.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)), + setTimeout, + 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, 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.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('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)), + setTimeout, + 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)), + setTimeout, + 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), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + function(done) { + 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)); + 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, 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)); + 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, 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)); + 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)), + setTimeout, + function(done) { + 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(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)), + setTimeout, + function(done) { + var firstCall = true; + 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)); + 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, 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 { + 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)), + setTimeout, + function(done) { + 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 { + 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)), + setTimeout, + function(done) { + this.doc.on('error', done); + this.connection.seq--; + this.doc.submitPresence(p(1), 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)), + setTimeout, + 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(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)), + setTimeout, + 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)), + setTimeout, + 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); + }); + + 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), + setTimeout, + 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), + setTimeout, + 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)), + setTimeout, + 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); + }); + }; + 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, 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)); + + 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({}, null); + }.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)), + setTimeout, + 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); + }); + }; + process.nextTick(done); + }.bind(this), + function(done) { + 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() { // wait for presence event + this.doc.submitPresence(p(2), callback); // pendingPresence + process.nextTick(function() { // wait for presence event + 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)); + this.doc.on('error', done); + + // send an invalid op + this.doc._submit({ index: 3, value: 'b' }, null, callback); + }.bind(this)); + }.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), + setTimeout, + this.doc2.submitOp.bind(this.doc2, { index: 1, value: 'b' }), // forces processing of all received presence + setTimeout, + 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/client/submit.js b/test/client/submit.js index 4e508e66e..9966508af 100644 --- a/test/client/submit.js +++ b/test/client/submit.js @@ -2,8 +2,12 @@ var async = require('async'); var expect = require('expect.js'); var types = require('../../lib/types'); var deserializedType = require('./deserialized-type'); +var numberType = require('./number-type'); +var otRichText = require('@teamwork/ot-rich-text'); types.register(deserializedType.type); types.register(deserializedType.type2); +types.register(numberType.type); +types.register(otRichText.type); module.exports = function() { describe('client submit', function() { @@ -610,6 +614,7 @@ describe('client submit', function() { doc.pause(); doc.submitOp({p: ['age'], na: 1}, function(err) { expect(err).ok(); + expect(err.code).to.equal(4017); expect(doc.version).equal(2); expect(doc.data).eql(undefined); done(); @@ -634,6 +639,7 @@ describe('client submit', function() { doc.pause(); 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}); done(); @@ -1044,6 +1050,273 @@ 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(); + }); + }); + }); + + 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(); + }); + }); + }); + + 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(4027); + 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(4027); + 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/subscribe.js b/test/client/subscribe.js index 567031d0a..b24a94749 100644 --- a/test/client/subscribe.js +++ b/test/client/subscribe.js @@ -405,23 +405,37 @@ 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) { 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); + expect(connection2.getExisting('dogs', 'fido')).equal(undefined); doc.submitOp({p: ['age'], na: 1}, done); }); }); }); }); + 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(); diff --git a/test/client/undo-redo.js b/test/client/undo-redo.js new file mode 100644 index 000000000..84db73c5d --- /dev/null +++ b/test/client/undo-redo.js @@ -0,0 +1,1543 @@ +var async = require('async'); +var lolex = require("lolex"); +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 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); +types.register(invertibleType.typeWithDiffX); +types.register(invertibleType.typeWithDiffAndDiffX); +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(); + this.doc = this.connection.get('dogs', 'fido'); + this.doc2 = this.connection2.get('dogs', 'fido'); + }); + + afterEach(function(done) { + this.backend.close(done); + this.clock.uninstall(); + }); + + it('submits a non-undoable operation', function(allDone) { + 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 } ]), + function(done) { + 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) + ], allDone); + }); + + it('receives a remote operation', function(done) { + var undoManager = this.connection.createUndoManager(); + 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.createUndoManager(); + 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(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.createUndoManager(); + async.series([ + this.doc.create.bind(this.doc, { test: 5 }), + this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ], { undoable: true }), + undoManager.undo.bind(undoManager), + function(done) { + expect(this.doc.version).to.equal(3); + expect(this.doc.data).to.eql({ test: 5 }); + 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.createUndoManager(); + async.series([ + this.doc.create.bind(this.doc, { test: 5 }), + this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ], { undoable: true }), + 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(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.createUndoManager(); + async.series([ + this.doc.create.bind(this.doc, { test: 5 }), + this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ], { undoable: true }), + 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(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.createUndoManager(); + this.doc.create({ test: 5 }), + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }), + expect(this.doc.data).to.eql({ test: 7 }); + undoManager.undo(), + expect(this.doc.data).to.eql({ test: 5 }); + undoManager.redo(), + expect(this.doc.data).to.eql({ test: 7 }); + undoManager.undo(), + expect(this.doc.data).to.eql({ test: 5 }); + undoManager.redo(), + expect(this.doc.data).to.eql({ test: 7 }); + undoManager.undo(), + expect(this.doc.data).to.eql({ test: 5 }); + undoManager.redo(), + expect(this.doc.data).to.eql({ test: 7 }); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(false); + }); + + it('undoes one of two operations', function(allDone) { + 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 }), + this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 3 } ], { undoable: true }), + undoManager.undo.bind(undoManager), + function(done) { + expect(this.doc.version).to.equal(4); + expect(this.doc.data).to.eql({ test: 7 }); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(true); + done(); + }.bind(this) + ], allDone); + }); + + it('undoes two of two operations', function(allDone) { + 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 }), + this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 3 } ], { undoable: true }), + 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(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(true); + done(); + }.bind(this) + ], allDone); + }); + + it('redoes one of two operations', function(allDone) { + 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 }), + this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 3 } ], { undoable: true }), + 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(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(true); + done(); + }.bind(this) + ], allDone); + }); + + it('redoes two of two operations', function(allDone) { + 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 }), + this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 3 } ], { undoable: true }), + 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(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(false); + done(); + }.bind(this) + ], allDone); + }); + + it('calls undo, when canUndo is false', function(done) { + 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.createUndoManager(); + expect(undoManager.canUndo()).to.equal(false); + undoManager.undo(); + }); + + it('calls redo, when canRedo is false', function(done) { + 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.createUndoManager(); + expect(undoManager.canRedo()).to.equal(false); + undoManager.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) { + 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) { + expect(source).to.equal('test source'); + done(); + }); + undoManager.undo({ source: 'test source' }); + }); + + it('preserves source on redo', function(done) { + var undoManager = this.connection.createUndoManager(); + this.doc.create({ test: 5 }); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + undoManager.undo(); + this.doc.on('op', function(op, source) { + expect(source).to.equal('test source'); + done(); + }); + undoManager.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) { + var undoManager = this.connection.createUndoManager(); + 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 }); + undoManager.undo(); + expect(this.doc.data).to.eql({ test: 5 }); + expect(undoManager.canUndo()).to.equal(false); + done(); + }.bind(this), 1000); + this.clock.runAll(); + }); + + it('composes undoable operations correctly', function() { + 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 }); + expect(this.doc.data).to.eql({}); + 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 } ]); + }); + undoManager.undo(); + expect(opCalled).to.equal(true); + expect(this.doc.data).to.eql({ a: 1, b: 2 }); + expect(undoManager.canUndo()).to.equal(false); + undoManager.redo(); + expect(this.doc.data).to.eql({}); + expect(undoManager.canRedo()).to.equal(false); + }); + + it('does not compose undoable operations outside time limit', function(done) { + var undoManager = this.connection.createUndoManager(); + 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 }); + undoManager.undo(); + expect(this.doc.data).to.eql({ test: 7 }); + expect(undoManager.canUndo()).to.equal(true); + undoManager.undo(); + expect(this.doc.data).to.eql({ test: 5 }); + expect(undoManager.canUndo()).to.equal(false); + done(); + }.bind(this), 1001); + this.clock.runAll(); + }); + + 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 }); + expect(this.doc.data).to.eql({ test: 10 }); + undoManager.undo(); + expect(this.doc.data).to.eql({ test: 7 }); + expect(undoManager.canUndo()).to.equal(true); + undoManager.undo(); + expect(this.doc.data).to.eql({ test: 5 }); + 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.createUndoManager(); + 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); + undoManager.undo(); + expect(this.doc.data).to.equal(7); + undoManager.undo(); + expect(this.doc.data).to.equal(5); + expect(undoManager.canUndo()).to.equal(false); + undoManager.redo(); + expect(this.doc.data).to.equal(7); + undoManager.redo(); + expect(this.doc.data).to.equal(9); + expect(undoManager.canRedo()).to.equal(false); + }); + + it('uses applyAndInvert, if available', function() { + 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') ]); + this.doc.submitOp([ otRichText.Action.createInsertText('one') ], { undoable: true }); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('onetwo') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('two') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('two') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('onetwo') ]); + }); + + 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(4028); + 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(4028); + done(); + }); + this.doc.submitOp([ 'one' ], { undoable: true }); + }); + + 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(4028); + 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(4028); + done(); + }); + this.doc.submitSnapshot([ { insert: 'abc' } ], { undoable: true }); + }); + + it('fails to submit with fixUp, if type is not invertible', function(done) { + 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) { + expect(err.code).to.equal(4028); + done(); + }); + }); + + it('composes similar operations', function() { + var undoManager = this.connection.createUndoManager(); + 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(undoManager.canRedo()).to.equal(false); + undoManager.undo(); + expect(this.doc.data).to.eql([]); + expect(undoManager.canUndo()).to.equal(false); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('onetwo') ]); + expect(undoManager.canRedo()).to.equal(false); + }); + + it('does not compose dissimilar operations', function() { + 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 }); + 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') ]); + + 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.createUndoManager(); + 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(undoManager.canUndo()).to.eql(false); + expect(undoManager.canRedo()).to.eql(false); + }); + + it('limits the size of the undo stack', function() { + 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 }); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + expect(this.doc.data).to.eql({ test: 11 }); + 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('does not compose the next operation after undo', function() { + 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 + this.clock.tick(1001); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); // not composed + 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(undoManager.canUndo()).to.equal(true); + + undoManager.undo(); + expect(this.doc.data).to.eql({ test: 7 }); + expect(undoManager.canUndo()).to.equal(true); + + undoManager.undo(); + expect(this.doc.data).to.eql({ test: 5 }); + expect(undoManager.canUndo()).to.equal(false); + }); + + it('does not compose the next operation after undo and redo', function() { + 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 + this.clock.tick(1001); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); // not composed + 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(undoManager.canUndo()).to.equal(true); + + undoManager.undo(); + expect(this.doc.data).to.eql({ test: 9 }); + expect(undoManager.canUndo()).to.equal(true); + + undoManager.undo(); + expect(this.doc.data).to.eql({ test: 7 }); + expect(undoManager.canUndo()).to.equal(true); + + undoManager.undo(); + expect(this.doc.data).to.eql({ test: 5 }); + expect(undoManager.canUndo()).to.equal(false); + }); + + it('transforms the stacks by remote operations', function(done) { + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); + this.doc2.subscribe(); + this.doc.subscribe(); + 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 }); + 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') ]); + 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') ]); + 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) { + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); + this.doc2.subscribe(); + this.doc.subscribe(); + 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 }); + 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') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([]); + expect(undoManager.canUndo()).to.equal(false); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('4') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('24') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('124') ]); + expect(undoManager.canRedo()).to.equal(false); + done(); + }.bind(this)); + this.doc2.submitOp([ otRichText.Action.createDelete(1) ]); + }.bind(this)); + }); + + it('transforms the stacks by a local operation', function() { + 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 }); + this.doc.submitOp([ otRichText.Action.createInsertText('2') ], { undoable: true }); + this.doc.submitOp([ otRichText.Action.createInsertText('1') ], { undoable: true }); + undoManager.undo(); + undoManager.undo(); + this.doc.submitOp([ otRichText.Action.createInsertText('ABC') ]); + 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 by a local operation and removes no-ops', function() { + 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 }); + this.doc.submitOp([ otRichText.Action.createInsertText('2') ], { undoable: true }); + this.doc.submitOp([ otRichText.Action.createInsertText('1') ], { undoable: true }); + undoManager.undo(); + undoManager.undo(); + this.doc.submitOp([ otRichText.Action.createDelete(1) ]); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('4') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([]); + expect(undoManager.canUndo()).to.equal(false); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('4') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('24') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('124') ]); + expect(undoManager.canRedo()).to.equal(false); + }); + + it('transforms stacks by an undoable op', function() { + 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' }); + 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.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' }); + 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.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' }); + 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.createUndoManager({ composeInterval: -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 }); + undoManager.undo(); + undoManager.undo(); + expect(this.doc.data).to.equal(11); + this.doc.submitOp(10000); + undoManager.undo(); + expect(this.doc.data).to.equal(10001); + undoManager.undo(); + expect(this.doc.data).to.equal(10000); + undoManager.redo(); + expect(this.doc.data).to.equal(10001); + undoManager.redo(); + expect(this.doc.data).to.equal(10011); + undoManager.redo(); + expect(this.doc.data).to.equal(10111); + undoManager.redo(); + expect(this.doc.data).to.equal(11111); + }); + + it('transforms the stacks using transformX', function() { + 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 }); + this.doc.submitOp(100, { undoable: true }); + this.doc.submitOp(1000, { undoable: true }); + undoManager.undo(); + undoManager.undo(); + expect(this.doc.data).to.equal(11); + this.doc.submitOp(10000); + undoManager.undo(); + expect(this.doc.data).to.equal(10001); + undoManager.undo(); + expect(this.doc.data).to.equal(10000); + undoManager.redo(); + expect(this.doc.data).to.equal(10001); + undoManager.redo(); + expect(this.doc.data).to.equal(10011); + undoManager.redo(); + expect(this.doc.data).to.equal(10111); + undoManager.redo(); + expect(this.doc.data).to.equal(11111); + }); + + describe('fixup operations', function() { + beforeEach(function() { + var undoManager = this.connection.createUndoManager({ composeInterval: -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('does not fix up anything', function() { + var undoManager = this.connection.createUndoManager(); + 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'); + 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 (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('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('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('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'); + }); + + 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'); + }); + + 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('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'); + }); + + 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'); + 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('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('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.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); + 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.createUndoManager(); + this.doc.create({ test: 5 }); + this.doc.submitOp([{ p: [ 'test' ], na: 2 }], { undoable: true }); + 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); + 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() { + 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 }); + 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); + }); + + 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 }); + 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(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() { + 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 }); + 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.createUndoManager({ composeInterval: -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.createUndoManager({ composeInterval: -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('clears the stacks for a specific document on doc destroy', function(done) { + 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 }); + 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); + done(); + }); + }); + }); + }); + + 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 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 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 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(); + + 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(); + + expect(opCalled).to.equal(2); + }); + + 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(); + + 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); + }); + + 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 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); +}); diff --git a/test/util.js b/test/util.js index 2bb7e1114..362337588 100644 --- a/test/util.js +++ b/test/util.js @@ -15,6 +15,12 @@ exports.pluck = function(docs, key) { return values; }; +exports.errorHandler = function(callback) { + return function(err) { + if (err) callback(err); + }; +}; + // Wrap a done function to call back only after a specified number of calls. // For example, `var callbackAfter = callAfter(1, callback)` means that if // `callbackAfter` is called once, it won't call back. If it is called twice