Merge branch 'diffdom' into netflux

Simple cleanup and unit tests
This commit is contained in:
ansuz
2016-04-12 14:15:04 +02:00
9 changed files with 723 additions and 93 deletions

View File

@@ -0,0 +1,277 @@
/*
* Copyright 2014 XWiki SAS
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
define([
'/common/messages.js',
'/bower_components/reconnectingWebsocket/reconnecting-websocket.js',
'/common/crypto.js',
'/common/TextPatcher.js',
'/common/chainpad.js',
'/bower_components/jquery/dist/jquery.min.js',
], function (Messages, ReconnectingWebSocket, Crypto, TextPatcher) {
var $ = window.jQuery;
var ChainPad = window.ChainPad;
var PARANOIA = true;
var module = { exports: {} };
/**
* If an error is encountered but it is recoverable, do not immediately fail
* but if it keeps firing errors over and over, do fail.
*/
var MAX_RECOVERABLE_ERRORS = 15;
var recoverableErrors = 0;
/** Maximum number of milliseconds of lag before we fail the connection. */
var MAX_LAG_BEFORE_DISCONNECT = 20000;
var debug = function (x) { console.log(x); };
var warn = function (x) { console.error(x); };
var verbose = function (x) { /*console.log(x);*/ };
var error = function (x) {
console.error(x);
recoverableErrors++;
if (recoverableErrors >= MAX_RECOVERABLE_ERRORS) {
window.alert("FAIL");
}
};
/* websocket stuff */
var isSocketDisconnected = function (socket, realtime) {
var sock = socket._socket;
return sock.readyState === sock.CLOSING
|| sock.readyState === sock.CLOSED
|| (realtime.getLag().waiting && realtime.getLag().lag > MAX_LAG_BEFORE_DISCONNECT);
};
// this differs from other functions with similar names in that
// you are expected to pass a socket into it.
var checkSocket = function (socket) {
if (isSocketDisconnected(socket, socket.realtime) &&
!socket.intentionallyClosing) {
return true;
} else {
return false;
}
};
// TODO before removing websocket implementation
// bind abort to onLeaving
var abort = function (socket, realtime) {
realtime.abort();
try { socket._socket.close(); } catch (e) { warn(e); }
};
var handleError = function (socket, realtime, err, docHTML, allMessages) {
// var internalError = createDebugInfo(err, realtime, docHTML, allMessages);
abort(socket, realtime);
};
var makeWebsocket = function (url) {
var socket = new ReconnectingWebSocket(url);
/* create a set of handlers to use instead of the native socket handler
these handlers will iterate over all of the functions pushed to the
arrays bearing their name.
The first such function to return `false` will prevent subsequent
functions from being executed. */
var out = {
onOpen: [], // takes care of launching the post-open logic
onClose: [], // takes care of cleanup
onError: [], // in case of error, socket will close, and fire this
onMessage: [], // used for the bulk of our logic
send: function (msg) { socket.send(msg); },
close: function () { socket.close(); },
_socket: socket
};
var mkHandler = function (name) {
return function (evt) {
for (var i = 0; i < out[name].length; i++) {
if (out[name][i](evt) === false) {
console.log(name +"Handler");
return;
}
}
};
};
// bind your new handlers to the important listeners on the socket
socket.onopen = mkHandler('onOpen');
socket.onclose = mkHandler('onClose');
socket.onerror = mkHandler('onError');
socket.onmessage = mkHandler('onMessage');
return out;
};
/* end websocket stuff */
var start = module.exports.start = function (config) {
//var textarea = config.textarea;
var websocketUrl = config.websocketURL;
var userName = config.userName;
var channel = config.channel;
var cryptKey = config.cryptKey;
var passwd = 'y';
var doc = config.doc || null;
// wrap up the reconnecting websocket with our additional stack logic
var socket = makeWebsocket(websocketUrl);
var allMessages = window.chainpad_allMessages = [];
var isErrorState = false;
var initializing = true;
var recoverableErrorCount = 0;
var toReturn = { socket: socket };
socket.onOpen.push(function (evt) {
var realtime = toReturn.realtime = socket.realtime =
// everybody has a username, and we assume they don't collide
// usernames are used to determine whether a message is remote
// or local in origin. This could mess with expected behaviour
// if someone spoofed.
ChainPad.create(userName,
passwd, // password, to be deprecated (maybe)
channel, // the channel we're to connect to
/* optional unless your application expects JSON
from getUserDoc */
config.initialState || '',
// transform function (optional), which handles conflicts
{ transformFunction: config.transformFunction });
var onEvent = toReturn.onEvent = function (newText) {
if (isErrorState || initializing) { return; }
// assert things here...
if (realtime.getUserDoc() !== newText) {
// this is a problem
warn("realtime.getUserDoc() !== newText");
}
};
// pass your shiny new realtime into initialization functions
if (config.onInit) {
// extend as you wish
config.onInit({
realtime: realtime
});
}
/* UI hints on userList changes are handled within the toolbar
so we don't actually need to do anything here except confirm
whether we've successfully joined the session, and call our
'onReady' function */
realtime.onUserListChange(function (userList) {
if (!initializing || userList.indexOf(userName) === -1) {
return;
}
// if we spot ourselves being added to the document, we'll switch
// 'initializing' off because it means we're fully synced.
initializing = false;
// execute an onReady callback if one was supplied
// pass an object so we can extend this later
if (config.onReady) {
// extend as you wish
config.onReady({
userList: userList,
realtime: realtime
});
}
});
// when a message is ready to send
// Don't confuse this onMessage with socket.onMessage
realtime.onMessage(function (message) {
if (isErrorState) { return; }
message = Crypto.encrypt(message, cryptKey);
try {
socket.send(message);
} catch (e) {
warn(e);
}
});
realtime.onPatch(function () {
if (config.onRemote) {
config.onRemote({
realtime: realtime
//realtime.getUserDoc()
});
}
});
// when you receive a message...
socket.onMessage.push(function (evt) {
verbose(evt.data);
if (isErrorState) { return; }
var message = Crypto.decrypt(evt.data, cryptKey);
verbose(message);
allMessages.push(message);
if (!initializing) {
if (toReturn.onLocal) {
toReturn.onLocal();
}
}
realtime.message(message);
});
// actual socket bindings
socket.onmessage = function (evt) {
for (var i = 0; i < socket.onMessage.length; i++) {
if (socket.onMessage[i](evt) === false) { return; }
}
};
socket.onclose = function (evt) {
for (var i = 0; i < socket.onMessage.length; i++) {
if (socket.onClose[i](evt) === false) { return; }
}
};
socket.onerror = warn;
var socketChecker = setInterval(function () {
if (checkSocket(socket)) {
warn("Socket disconnected!");
recoverableErrorCount += 1;
if (recoverableErrorCount >= MAX_RECOVERABLE_ERRORS) {
warn("Giving up!");
abort(socket, realtime);
if (config.onAbort) {
config.onAbort({
socket: socket
});
}
if (socketChecker) { clearInterval(socketChecker); }
}
} // it's working as expected, continue
}, 200);
toReturn.patchText = TextPatcher.create({
realtime: realtime
});
realtime.start();
debug('started');
});
return toReturn;
};
return module.exports;
});

120
www/common/TextPatcher.js Normal file
View File

@@ -0,0 +1,120 @@
define(function () {
/* diff takes two strings, the old content, and the desired content
it returns the difference between these two strings in the form
of an 'Operation' (as defined in chainpad.js).
diff is purely functional.
*/
var diff = function (oldval, newval) {
// Strings are immutable and have reference equality. I think this test is O(1), so its worth doing.
if (oldval === newval) {
return;
}
var commonStart = 0;
while (oldval.charAt(commonStart) === newval.charAt(commonStart)) {
commonStart++;
}
var commonEnd = 0;
while (oldval.charAt(oldval.length - 1 - commonEnd) === newval.charAt(newval.length - 1 - commonEnd) &&
commonEnd + commonStart < oldval.length && commonEnd + commonStart < newval.length) {
commonEnd++;
}
var toRemove;
var toInsert;
/* throw some assertions in here before dropping patches into the realtime */
if (oldval.length !== commonStart + commonEnd) {
toRemove = oldval.length - commonStart - commonEnd;
}
if (newval.length !== commonStart + commonEnd) {
toInsert = newval.slice(commonStart, newval.length - commonEnd);
}
return {
type: 'Operation',
offset: commonStart,
toInsert: toInsert,
toRemove: toRemove
};
}
/* patch accepts a realtime facade and an operation (which might be falsey)
it applies the operation to the realtime as components (remove/insert)
patch has no return value, and operates solely through side effects on
the realtime facade.
*/
var patch = function (ctx, op) {
if (!op) { return; }
if (op.toRemove) { ctx.remove(op.offset, op.toRemove); }
if (op.toInsert) { ctx.insert(op.offset, op.toInsert); }
};
/* log accepts a string and an operation, and prints an object to the console
the object will display the content which is to be removed, and the content
which will be inserted in its place.
log is useful for debugging, but can otherwise be disabled.
*/
var log = function (text, op) {
if (!op) { return; }
console.log({
insert: op.toInsert,
remove: text.slice(op.offset, op.offset + op.toRemove)
});
};
/* applyChange takes:
ctx: the context (aka the realtime)
oldval: the old value
newval: the new value
it performs a diff on the two values, and generates patches
which are then passed into `ctx.remove` and `ctx.insert`.
Due to its reliance on patch, applyChange has side effects on the supplied
realtime facade.
*/
var applyChange = function(ctx, oldval, newval) {
var op = diff(oldval, newval);
// log(oldval, op)
patch(ctx, op);
};
var create = function(config) {
var ctx = config.realtime;
// initial state will always fail the !== check in genop.
// because nothing will equal this object
var content = {};
// *** remote -> local changes
ctx.onPatch(function(pos, length) {
content = ctx.getUserDoc()
});
// propogate()
return function (newContent) {
if (newContent !== content) {
applyChange(ctx, ctx.getUserDoc(), newContent);
if (ctx.getUserDoc() !== newContent) {
console.log("Expected that: `ctx.getUserDoc() === newContent`!");
}
return true;
}
return false;
};
};
return {
create: create, // create a TextPatcher object
diff: diff, // diff two strings
patch: patch, // apply an operation to a chainpad's realtime facade
log: log, // print the components of an operation
applyChange: applyChange // a convenient wrapper around diff/log/patch
};
});

63
www/common/TypingTests.js Normal file
View File

@@ -0,0 +1,63 @@
define(function () {
var setRandomizedInterval = function (func, target, range) {
var timeout;
var again = function () {
timeout = setTimeout(function () {
again();
func();
}, target - (range / 2) + Math.random() * range);
};
again();
return {
cancel: function () {
if (timeout) {
clearTimeout(timeout);
timeout = undefined;
}
}
};
};
var testInput = function (doc, el, offset, cb) {
var i = 0,
j = offset,
input = " The quick red fox jumps over the lazy brown dog.",
l = input.length,
errors = 0,
max_errors = 15,
interval;
var cancel = function () {
if (interval) { interval.cancel(); }
};
interval = setRandomizedInterval(function () {
cb();
try {
el.replaceData(j, 0, input.charAt(i));
} catch (err) {
errors++;
if (errors >= max_errors) {
console.log("Max error number exceeded");
cancel();
}
console.error(err);
var next = document.createTextNode("");
doc.appendChild(next);
el = next;
j = -1;
}
i = (i + 1) % l;
j++;
}, 200, 50);
return {
cancel: cancel
};
};
return {
testInput: testInput,
setRandomizedInterval: setRandomizedInterval
};
});