sharedb 5.0.3 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/agent.js CHANGED
@@ -4,6 +4,7 @@ var logger = require('./logger');
4
4
  var ACTIONS = require('./message-actions').ACTIONS;
5
5
  var types = require('./types');
6
6
  var util = require('./util');
7
+ var protocol = require('./protocol');
7
8
 
8
9
  var ERROR_CODE = ShareDBError.CODES;
9
10
 
@@ -48,6 +49,10 @@ function Agent(backend, stream) {
48
49
  // request if the client disconnects ungracefully. This is a
49
50
  // map of channel -> id -> request
50
51
  this.presenceRequests = Object.create(null);
52
+ // Keep track of the latest known Doc version, so that we can avoid fetching
53
+ // ops to transform presence if not needed
54
+ this.latestDocVersionStreams = Object.create(null);
55
+ this.latestDocVersions = Object.create(null);
51
56
 
52
57
  // We need to track this manually to make sure we don't reply to messages
53
58
  // after the stream was closed.
@@ -58,6 +63,8 @@ function Agent(backend, stream) {
58
63
  // active, and it is passed to each middleware call
59
64
  this.custom = Object.create(null);
60
65
 
66
+ this.protocol = Object.create(null);
67
+
61
68
  // The first message received over the connection. Stored to warn if messages
62
69
  // are being sent before the handshake.
63
70
  this._firstReceivedMessage = null;
@@ -108,6 +115,12 @@ Agent.prototype._cleanup = function() {
108
115
  emitter.destroy();
109
116
  }
110
117
  this.subscribedQueries = Object.create(null);
118
+
119
+ for (var collection in this.latestDocVersionStreams) {
120
+ var streams = this.latestDocVersionStreams[collection];
121
+ for (var id in streams) streams[id].destroy();
122
+ }
123
+ this.latestDocVersionStreams = Object.create(null);
111
124
  };
112
125
 
113
126
  /**
@@ -115,17 +128,8 @@ Agent.prototype._cleanup = function() {
115
128
  * _sendOp()
116
129
  */
117
130
  Agent.prototype._subscribeToStream = function(collection, id, stream) {
118
- if (this.closed) return stream.destroy();
119
-
120
- var streams = this.subscribedDocs[collection] || (this.subscribedDocs[collection] = Object.create(null));
121
-
122
- // If already subscribed to this document, destroy the previously subscribed stream
123
- var previous = streams[id];
124
- if (previous) previous.destroy();
125
- streams[id] = stream;
126
-
127
131
  var agent = this;
128
- stream.on('data', function(data) {
132
+ this._subscribeMapToStream(this.subscribedDocs, collection, id, stream, function(data) {
129
133
  if (data.error) {
130
134
  // Log then silently ignore errors in a subscription stream, since these
131
135
  // may not be the client's fault, and they were not the result of a
@@ -135,13 +139,26 @@ Agent.prototype._subscribeToStream = function(collection, id, stream) {
135
139
  }
136
140
  agent._onOp(collection, id, data);
137
141
  });
142
+ };
143
+
144
+ Agent.prototype._subscribeMapToStream = function(map, collection, id, stream, dataHandler) {
145
+ if (this.closed) return stream.destroy();
146
+
147
+ var streams = map[collection] || (map[collection] = Object.create(null));
148
+
149
+ // If already subscribed to this document, destroy the previously subscribed stream
150
+ var previous = streams[id];
151
+ if (previous) previous.destroy();
152
+ streams[id] = stream;
153
+
154
+ stream.on('data', dataHandler);
138
155
  stream.on('end', function() {
139
156
  // The op stream is done sending, so release its reference
140
- var streams = agent.subscribedDocs[collection];
157
+ var streams = map[collection];
141
158
  if (!streams || streams[id] !== stream) return;
142
159
  delete streams[id];
143
160
  if (util.hasKeys(streams)) return;
144
- delete agent.subscribedDocs[collection];
161
+ delete map[collection];
145
162
  });
146
163
  };
147
164
 
@@ -423,6 +440,7 @@ Agent.prototype._handleMessage = function(request, callback) {
423
440
  switch (request.a) {
424
441
  case ACTIONS.handshake:
425
442
  if (request.id) this.src = request.id;
443
+ this._setProtocol(request);
426
444
  return callback(null, this._initMessage(ACTIONS.handshake));
427
445
  case ACTIONS.queryFetch:
428
446
  return this._queryFetch(request.id, request.c, request.q, getQueryOptions(request), callback);
@@ -774,8 +792,8 @@ Agent.prototype._fetchSnapshotByTimestamp = function(collection, id, timestamp,
774
792
  Agent.prototype._initMessage = function(action) {
775
793
  return {
776
794
  a: action,
777
- protocol: 1,
778
- protocolMinor: 1,
795
+ protocol: protocol.major,
796
+ protocolMinor: protocol.minor,
779
797
  id: this._src(),
780
798
  type: types.defaultType.uri
781
799
  };
@@ -794,25 +812,74 @@ Agent.prototype._broadcastPresence = function(presence, callback) {
794
812
  collection: presence.c
795
813
  };
796
814
  var start = Date.now();
797
- backend.trigger(backend.MIDDLEWARE_ACTIONS.receivePresence, this, context, function(error) {
815
+
816
+ var subscriptionUpdater = presence.p === null ?
817
+ this._unsubscribeDocVersion.bind(this) :
818
+ this._subscribeDocVersion.bind(this);
819
+
820
+ subscriptionUpdater(presence.c, presence.d, function(error) {
798
821
  if (error) return callback(error);
799
- var requests = presenceRequests[presence.ch] || (presenceRequests[presence.ch] = Object.create(null));
800
- var previousRequest = requests[presence.id];
801
- if (!previousRequest || previousRequest.pv < presence.pv) {
802
- presenceRequests[presence.ch][presence.id] = presence;
803
- }
804
- backend.transformPresenceToLatestVersion(agent, presence, function(error, presence) {
822
+ backend.trigger(backend.MIDDLEWARE_ACTIONS.receivePresence, agent, context, function(error) {
805
823
  if (error) return callback(error);
806
- var channel = agent._getPresenceChannel(presence.ch);
807
- agent.backend.pubsub.publish([channel], presence, function(error) {
808
- if (error) return callback(error);
809
- backend.emit('timing', 'presence.broadcast', Date.now() - start, context);
824
+ var requests = presenceRequests[presence.ch] || (presenceRequests[presence.ch] = Object.create(null));
825
+ var previousRequest = requests[presence.id];
826
+ if (!previousRequest || previousRequest.pv < presence.pv) {
827
+ presenceRequests[presence.ch][presence.id] = presence;
828
+ }
829
+
830
+ var transformer = function(agent, presence, callback) {
810
831
  callback(null, presence);
832
+ };
833
+
834
+ var latestDocVersion = util.dig(agent.latestDocVersions, presence.c, presence.d);
835
+ var presenceIsUpToDate = presence.v === latestDocVersion;
836
+ if (!presenceIsUpToDate) {
837
+ transformer = backend.transformPresenceToLatestVersion.bind(backend);
838
+ }
839
+
840
+ transformer(agent, presence, function(error, presence) {
841
+ if (error) return callback(error);
842
+ var channel = agent._getPresenceChannel(presence.ch);
843
+ agent.backend.pubsub.publish([channel], presence, function(error) {
844
+ if (error) return callback(error);
845
+ backend.emit('timing', 'presence.broadcast', Date.now() - start, context);
846
+ callback(null, presence);
847
+ });
811
848
  });
812
849
  });
813
850
  });
814
851
  };
815
852
 
853
+ Agent.prototype._subscribeDocVersion = function(collection, id, callback) {
854
+ if (!collection || !id) return callback();
855
+
856
+ var latestDocVersions = this.latestDocVersions;
857
+ var isSubscribed = util.dig(latestDocVersions, collection, id) !== undefined;
858
+ if (isSubscribed) return callback();
859
+
860
+ var agent = this;
861
+ this.backend.subscribe(this, collection, id, null, function(error, stream, snapshot) {
862
+ if (error) return callback(error);
863
+
864
+ var versions = latestDocVersions[collection] || (latestDocVersions[collection] = Object.create(null));
865
+ versions[id] = snapshot.v;
866
+
867
+ agent._subscribeMapToStream(agent.latestDocVersionStreams, collection, id, stream, function(op) {
868
+ // op.v behind snapshot.v by 1
869
+ latestDocVersions[collection][id] = op.v + 1;
870
+ });
871
+
872
+ callback();
873
+ });
874
+ };
875
+
876
+ Agent.prototype._unsubscribeDocVersion = function(collection, id, callback) {
877
+ var stream = util.dig(this.latestDocVersionStreams, collection, id);
878
+ if (stream) stream.destroy();
879
+ util.digAndRemove(this.latestDocVersions, collection, id);
880
+ util.nextTick(callback);
881
+ };
882
+
816
883
  Agent.prototype._createPresence = function(request) {
817
884
  return {
818
885
  a: ACTIONS.presence,
@@ -910,6 +977,11 @@ Agent.prototype._checkFirstMessage = function(request) {
910
977
  }
911
978
  };
912
979
 
980
+ Agent.prototype._setProtocol = function(request) {
981
+ this.protocol.major = request.protocol;
982
+ this.protocol.minor = request.protocolMinor;
983
+ };
984
+
913
985
  function createClientOp(request, clientId) {
914
986
  // src can be provided if it is not the same as the current agent,
915
987
  // such as a resubmission after a reconnect, but it usually isn't needed
package/lib/backend.js CHANGED
@@ -43,6 +43,7 @@ function Backend(options) {
43
43
  'new Backend({doNotForwardSendPresenceErrorsToClient: true})\n\n'
44
44
  );
45
45
  }
46
+ this.doNotCommitNoOps = !!options.doNotCommitNoOps;
46
47
 
47
48
  // Map from event name to a list of middleware
48
49
  this.middleware = Object.create(null);
@@ -11,6 +11,7 @@ var types = require('../types');
11
11
  var util = require('../util');
12
12
  var logger = require('../logger');
13
13
  var DocPresenceEmitter = require('./presence/doc-presence-emitter');
14
+ var protocol = require('../protocol');
14
15
 
15
16
  var ERROR_CODE = ShareDBError.CODES;
16
17
 
@@ -728,16 +729,21 @@ Connection.prototype._handleSnapshotFetch = function(error, message) {
728
729
  };
729
730
 
730
731
  Connection.prototype._handleLegacyInit = function(message) {
731
- // If the minor protocol version has been set, we want to use the
732
+ // If the protocol is at least 1.1, we want to use the
732
733
  // new handshake protocol. Let's send a handshake initialize, because
733
734
  // we now know the server is ready. If we've already sent it, we'll
734
735
  // just ignore the response anyway.
735
- if (message.protocolMinor) return this._initializeHandshake();
736
+ if (protocol.checkAtLeast(message, '1.1')) return this._initializeHandshake();
736
737
  this._initialize(message);
737
738
  };
738
739
 
739
740
  Connection.prototype._initializeHandshake = function() {
740
- this.send({a: ACTIONS.handshake, id: this.id});
741
+ this.send({
742
+ a: ACTIONS.handshake,
743
+ id: this.id,
744
+ protocol: protocol.major,
745
+ protocolMinor: protocol.minor
746
+ });
741
747
  };
742
748
 
743
749
  Connection.prototype._handleHandshake = function(error, message) {
@@ -753,7 +759,7 @@ Connection.prototype._handlePingPong = function(error) {
753
759
  Connection.prototype._initialize = function(message) {
754
760
  if (this.state !== 'connecting') return;
755
761
 
756
- if (message.protocol !== 1) {
762
+ if (message.protocol !== protocol.major) {
757
763
  return this.emit('error', new ShareDBError(
758
764
  ERROR_CODE.ERR_PROTOCOL_VERSION_NOT_SUPPORTED,
759
765
  'Unsupported protocol version: ' + message.protocol
package/lib/client/doc.js CHANGED
@@ -328,6 +328,15 @@ Doc.prototype._handleSubscribe = function(error, snapshot) {
328
328
 
329
329
  Doc.prototype._handleOp = function(err, message) {
330
330
  if (err) {
331
+ if (err.code === ERROR_CODE.ERR_NO_OP && message.seq === this.inflightOp.seq) {
332
+ // Our op was a no-op, either because we submitted a no-op, or - more
333
+ // likely - because our op was transformed into a no-op by the server
334
+ // because of a similar remote op. In this case, the server has avoided
335
+ // committing the op to the database, and we should just clear the in-flight
336
+ // op and call the callbacks. However, let's first catch ourselves up to
337
+ // the remote, so that we're in a nice consistent state
338
+ return this.fetch(this._clearInflightOp.bind(this));
339
+ }
331
340
  if (this.inflightOp) {
332
341
  return this._rollback(err);
333
342
  }
package/lib/error.js CHANGED
@@ -35,6 +35,7 @@ ShareDBError.CODES = {
35
35
  ERR_MAX_SUBMIT_RETRIES_EXCEEDED: 'ERR_MAX_SUBMIT_RETRIES_EXCEEDED',
36
36
  ERR_MESSAGE_BADLY_FORMED: 'ERR_MESSAGE_BADLY_FORMED',
37
37
  ERR_MILESTONE_ARGUMENT_INVALID: 'ERR_MILESTONE_ARGUMENT_INVALID',
38
+ ERR_NO_OP: 'ERR_NO_OP',
38
39
  ERR_OP_ALREADY_SUBMITTED: 'ERR_OP_ALREADY_SUBMITTED',
39
40
  ERR_OP_NOT_ALLOWED_IN_PROJECTION: 'ERR_OP_NOT_ALLOWED_IN_PROJECTION',
40
41
  ERR_OP_SUBMIT_REJECTED: 'ERR_OP_SUBMIT_REJECTED',
@@ -0,0 +1,28 @@
1
+ module.exports = {
2
+ major: 1,
3
+ minor: 2,
4
+ checkAtLeast: checkAtLeast
5
+ };
6
+
7
+ function checkAtLeast(toCheck, checkAgainst) {
8
+ toCheck = normalizedProtocol(toCheck);
9
+ checkAgainst = normalizedProtocol(checkAgainst);
10
+ if (toCheck.major > checkAgainst.major) return true;
11
+ return toCheck.major === checkAgainst.major &&
12
+ toCheck.minor >= checkAgainst.minor;
13
+ }
14
+
15
+ function normalizedProtocol(protocol) {
16
+ if (typeof protocol === 'string') {
17
+ var segments = protocol.split('.');
18
+ protocol = {
19
+ major: segments[0],
20
+ minor: segments[1]
21
+ };
22
+ }
23
+
24
+ return {
25
+ major: +(protocol.protocol || protocol.major || 0),
26
+ minor: +(protocol.protocolMinor || protocol.minor || 0)
27
+ };
28
+ }
@@ -2,6 +2,7 @@ var ot = require('./ot');
2
2
  var projections = require('./projections');
3
3
  var ShareDBError = require('./error');
4
4
  var types = require('./types');
5
+ var protocol = require('./protocol');
5
6
 
6
7
  var ERROR_CODE = ShareDBError.CODES;
7
8
 
@@ -152,6 +153,18 @@ SubmitRequest.prototype.submit = function(callback) {
152
153
  err = request._transformOp(ops);
153
154
  if (err) return callback(err);
154
155
 
156
+ var skipNoOp = backend.doNotCommitNoOps &&
157
+ protocol.checkAtLeast(request.agent.protocol, '1.2') &&
158
+ request.op.op &&
159
+ request.op.op.length === 0;
160
+
161
+ if (skipNoOp) {
162
+ // The op is a no-op, either because it was submitted as such, or - more
163
+ // likely - because it was transformed into one. Let's avoid committing it
164
+ // and tell the client.
165
+ return callback(request.noOpError());
166
+ }
167
+
155
168
  if (op.v !== snapshot.v) {
156
169
  // This shouldn't happen, but is just a final sanity check to make
157
170
  // sure we have transformed the op to the current snapshot version
@@ -361,3 +374,9 @@ SubmitRequest.prototype.maxRetriesError = function() {
361
374
  'Op submit failed. Exceeded max submit retries of ' + this.maxRetries
362
375
  );
363
376
  };
377
+ SubmitRequest.prototype.noOpError = function() {
378
+ return new ShareDBError(
379
+ ERROR_CODE.ERR_NO_OP,
380
+ 'Op is a no-op. Skipping apply.'
381
+ );
382
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sharedb",
3
- "version": "5.0.3",
3
+ "version": "5.1.0",
4
4
  "description": "JSON OT database backend",
5
5
  "main": "lib/index.js",
6
6
  "dependencies": {
package/test/agent.js CHANGED
@@ -5,6 +5,7 @@ var StreamSocket = require('../lib/stream-socket');
5
5
  var expect = require('chai').expect;
6
6
  var ACTIONS = require('../lib/message-actions').ACTIONS;
7
7
  var Connection = require('../lib/client/connection');
8
+ var protocol = require('../lib/protocol');
8
9
  var LegacyConnection = require('sharedb-legacy/lib/client').Connection;
9
10
 
10
11
  describe('Agent', function() {
@@ -70,5 +71,16 @@ describe('Agent', function() {
70
71
  done();
71
72
  });
72
73
  });
74
+
75
+ it('records the client protocol on the agent', function(done) {
76
+ var connection = backend.connect();
77
+ connection.once('connected', function() {
78
+ expect(connection.agent.protocol).to.eql({
79
+ major: protocol.major,
80
+ minor: protocol.minor
81
+ });
82
+ done();
83
+ });
84
+ });
73
85
  });
74
86
  });
@@ -5,6 +5,7 @@ var types = require('../../../lib/types');
5
5
  var presenceTestType = require('./presence-test-type');
6
6
  var errorHandler = require('../../util').errorHandler;
7
7
  var PresencePauser = require('./presence-pauser');
8
+ var sinon = require('sinon');
8
9
  types.register(presenceTestType.type);
9
10
 
10
11
  describe('DocPresence', function() {
@@ -297,6 +298,23 @@ describe('DocPresence', function() {
297
298
  ], done);
298
299
  });
299
300
 
301
+ it('does not call getOps() when presence is already up-to-date', function(done) {
302
+ var localPresence1 = presence1.create('presence-1');
303
+
304
+ async.series([
305
+ doc1.fetch.bind(doc1), // Ensure up-to-date
306
+ function(next) {
307
+ sinon.spy(Backend.prototype, 'getOps');
308
+ next();
309
+ },
310
+ localPresence1.submit.bind(localPresence1, {index: 1}),
311
+ function(next) {
312
+ expect(Backend.prototype.getOps).not.to.have.been.called;
313
+ next();
314
+ }
315
+ ], done);
316
+ });
317
+
300
318
  // This test case attempts to force us into a tight race condition corner case:
301
319
  // 1. doc1 sends presence, as well as submits an op
302
320
  // 2. doc2 receives the op first, followed by the presence, which is now out-of-date
@@ -1290,6 +1290,119 @@ module.exports = function() {
1290
1290
  });
1291
1291
  });
1292
1292
 
1293
+ it('commits both of two identical ops submitted from different clients by default', function(done) {
1294
+ var backend = this.backend;
1295
+ backend.doNotCommitNoOps = false;
1296
+ var doc1 = backend.connect().get('dogs', id);
1297
+ var doc2 = backend.connect().get('dogs', id);
1298
+ var op = [{p: ['tricks', 0], ld: 'fetch'}];
1299
+
1300
+ async.series([
1301
+ doc1.create.bind(doc1, {tricks: ['fetch', 'sit']}),
1302
+ doc2.fetch.bind(doc2),
1303
+ async.parallel.bind(async.parallel, [
1304
+ doc1.submitOp.bind(doc1, op),
1305
+ doc2.submitOp.bind(doc2, op)
1306
+ ]),
1307
+ function(next) {
1308
+ expect(doc1.data).to.eql({tricks: ['sit']});
1309
+ expect(doc2.data).to.eql({tricks: ['sit']});
1310
+ next();
1311
+ },
1312
+ function(next) {
1313
+ backend.db.getOps('dogs', id, 0, null, {}, function(error, ops) {
1314
+ if (error) return next(error);
1315
+ // Expect:
1316
+ // v0: create
1317
+ // v1: update
1318
+ // v2: duplicate update
1319
+ expect(ops).to.have.length(3);
1320
+ next();
1321
+ });
1322
+ }
1323
+ ], done);
1324
+ });
1325
+
1326
+ it('only commits one of two identical ops submitted from different clients', function(done) {
1327
+ var backend = this.backend;
1328
+ backend.doNotCommitNoOps = true;
1329
+ var doc1 = backend.connect().get('dogs', id);
1330
+ var doc2 = backend.connect().get('dogs', id);
1331
+ var op = [{p: ['tricks', 0], ld: 'fetch'}];
1332
+
1333
+ async.series([
1334
+ doc1.create.bind(doc1, {tricks: ['fetch', 'sit']}),
1335
+ doc2.fetch.bind(doc2),
1336
+ async.parallel.bind(async.parallel, [
1337
+ doc1.submitOp.bind(doc1, op),
1338
+ doc2.submitOp.bind(doc2, op)
1339
+ ]),
1340
+ function(next) {
1341
+ expect(doc1.data).to.eql({tricks: ['sit']});
1342
+ expect(doc2.data).to.eql({tricks: ['sit']});
1343
+ next();
1344
+ },
1345
+ function(next) {
1346
+ backend.db.getOps('dogs', id, 0, null, {}, function(error, ops) {
1347
+ if (error) return next(error);
1348
+ // Expect:
1349
+ // v0: create
1350
+ // v1: update
1351
+ // no duplicate update
1352
+ expect(ops).to.have.length(2);
1353
+ next();
1354
+ });
1355
+ }
1356
+ ], done);
1357
+ });
1358
+
1359
+ it('can submit a new op after getting a no-op', function(done) {
1360
+ var backend = this.backend;
1361
+ backend.doNotCommitNoOps = true;
1362
+ var doc1 = backend.connect().get('dogs', id);
1363
+ var doc2 = backend.connect().get('dogs', id);
1364
+ var op = [{p: ['tricks', 0], ld: 'fetch'}];
1365
+
1366
+ async.series([
1367
+ doc1.create.bind(doc1, {tricks: ['fetch', 'sit']}),
1368
+ doc2.fetch.bind(doc2),
1369
+ async.parallel.bind(async.parallel, [
1370
+ doc1.submitOp.bind(doc1, op),
1371
+ doc2.submitOp.bind(doc2, op)
1372
+ ]),
1373
+ function(next) {
1374
+ expect(doc1.data).to.eql({tricks: ['sit']});
1375
+ expect(doc2.data).to.eql({tricks: ['sit']});
1376
+ next();
1377
+ },
1378
+ doc1.submitOp.bind(doc1, [{p: ['tricks', 0], li: 'play dead'}]),
1379
+ function(next) {
1380
+ expect(doc1.data.tricks).to.eql(['play dead', 'sit']);
1381
+ next();
1382
+ }
1383
+ ], done);
1384
+ });
1385
+
1386
+ it('fixes up even if an op is fixed up to become a no-op', function(done) {
1387
+ var backend = this.backend;
1388
+ backend.doNotCommitNoOps = true;
1389
+ var doc = backend.connect().get('dogs', id);
1390
+
1391
+ backend.use('apply', function(req, next) {
1392
+ req.$fixup([{p: ['fixme'], od: true}]);
1393
+ next();
1394
+ });
1395
+
1396
+ async.series([
1397
+ doc.create.bind(doc, {}),
1398
+ doc.submitOp.bind(doc, [{p: ['fixme'], oi: true}]),
1399
+ function(next) {
1400
+ expect(doc.data).to.eql({});
1401
+ next();
1402
+ }
1403
+ ], done);
1404
+ });
1405
+
1293
1406
  describe('type.deserialize', function() {
1294
1407
  it('can create a new doc', function(done) {
1295
1408
  var doc = this.backend.connect().get('dogs', id);
@@ -0,0 +1,31 @@
1
+ var protocol = require('../lib/protocol');
2
+ var expect = require('chai').expect;
3
+
4
+ describe('protocol', function() {
5
+ describe('checkAtLeast', function() {
6
+ var FIXTURES = [
7
+ ['1.0', '1.0', true],
8
+ ['1.1', '1.0', true],
9
+ ['1.0', '1.1', false],
10
+ ['1.0', '1', true],
11
+ ['1.10', '1.3', true],
12
+ ['2.0', '1.3', true],
13
+ [{major: 1, minor: 0}, {major: 1, minor: 0}, true],
14
+ [{major: 1, minor: 1}, {major: 1, minor: 0}, true],
15
+ [{major: 1, minor: 0}, {major: 1, minor: 1}, false],
16
+ [{protocol: 1, protocolMinor: 0}, {protocol: 1, protocolMinor: 0}, true],
17
+ [{protocol: 1, protocolMinor: 1}, {protocol: 1, protocolMinor: 0}, true],
18
+ [{protocol: 1, protocolMinor: 0}, {protocol: 1, protocolMinor: 1}, false],
19
+ [{}, '1.0', false],
20
+ ['', '1.0', false]
21
+ ];
22
+
23
+ FIXTURES.forEach(function(fixture) {
24
+ var is = fixture[2] ? ' is ' : ' is not ';
25
+ var name = 'checks ' + JSON.stringify(fixture[0]) + is + 'at least ' + JSON.stringify(fixture[1]);
26
+ it(name, function() {
27
+ expect(protocol.checkAtLeast(fixture[0], fixture[1])).to.equal(fixture[2]);
28
+ });
29
+ });
30
+ });
31
+ });