Add collaboration support
Real-time collaboration through consumer-provided transport and presence interfaces. Also includes a sample backend app.
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
import { ribbit, resetDOM } from './setup';
|
||||
|
||||
const r = ribbit();
|
||||
|
||||
function mockTransport() {
|
||||
const receiveListeners: Array<(update: Uint8Array) => void> = [];
|
||||
const lockListeners: Array<(holder: any) => void> = [];
|
||||
return {
|
||||
connected: false,
|
||||
sent: [] as Uint8Array[],
|
||||
locked: false,
|
||||
connect() { this.connected = true; },
|
||||
disconnect() { this.connected = false; },
|
||||
send(update: Uint8Array) { this.sent.push(update); },
|
||||
onReceive(cb: (update: Uint8Array) => void) { receiveListeners.push(cb); },
|
||||
simulateRemote(content: string) {
|
||||
const encoded = new TextEncoder().encode(content);
|
||||
receiveListeners.forEach(cb => cb(encoded));
|
||||
},
|
||||
lock: async function() { this.locked = true; return true; },
|
||||
unlock() { this.locked = false; },
|
||||
forceLock: async function() { this.locked = true; return true; },
|
||||
onLockChange(cb: (holder: any) => void) { lockListeners.push(cb); },
|
||||
simulateLock(holder: any) { lockListeners.forEach(cb => cb(holder)); },
|
||||
};
|
||||
}
|
||||
|
||||
function mockPresence() {
|
||||
const listeners: Array<(peers: any[]) => void> = [];
|
||||
return {
|
||||
lastSent: null as any,
|
||||
send(info: any) { this.lastSent = info; },
|
||||
onUpdate(cb: (peers: any[]) => void) { listeners.push(cb); },
|
||||
simulatePeers(peers: any[]) { listeners.forEach(cb => cb(peers)); },
|
||||
};
|
||||
}
|
||||
|
||||
function mockRevisions() {
|
||||
const store: any[] = [];
|
||||
return {
|
||||
store,
|
||||
list: async () => store,
|
||||
get: async (id: string) => store.find((r: any) => r.id === id),
|
||||
create: async (content: string, meta?: any) => {
|
||||
const rev = { id: String(store.length + 1), timestamp: new Date().toISOString(), content, ...meta };
|
||||
store.push(rev);
|
||||
return rev;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('CollaborationManager', () => {
|
||||
beforeEach(() => resetDOM('initial'));
|
||||
|
||||
it('does not create manager without settings', () => {
|
||||
const editor = new r.Editor({});
|
||||
editor.run();
|
||||
expect(editor.collaboration).toBeUndefined();
|
||||
});
|
||||
|
||||
it('creates manager with settings', () => {
|
||||
const transport = mockTransport();
|
||||
const editor = new r.Editor({
|
||||
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||
});
|
||||
editor.run();
|
||||
expect(editor.collaboration).toBeDefined();
|
||||
});
|
||||
|
||||
describe('connection lifecycle', () => {
|
||||
it('connects on wysiwyg', () => {
|
||||
const transport = mockTransport();
|
||||
const editor = new r.Editor({
|
||||
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||
});
|
||||
editor.run();
|
||||
editor.wysiwyg();
|
||||
expect(transport.connected).toBe(true);
|
||||
});
|
||||
|
||||
it('connects on edit', () => {
|
||||
const transport = mockTransport();
|
||||
const editor = new r.Editor({
|
||||
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||
});
|
||||
editor.run();
|
||||
editor.edit();
|
||||
expect(transport.connected).toBe(true);
|
||||
});
|
||||
|
||||
it('disconnects on view', () => {
|
||||
const transport = mockTransport();
|
||||
const editor = new r.Editor({
|
||||
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||
});
|
||||
editor.run();
|
||||
editor.wysiwyg();
|
||||
editor.view();
|
||||
expect(transport.connected).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('source mode pausing', () => {
|
||||
it('pauses on entering source mode', () => {
|
||||
const transport = mockTransport();
|
||||
const editor = new r.Editor({
|
||||
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||
});
|
||||
editor.run();
|
||||
editor.edit();
|
||||
expect(editor.collaboration!.isPaused()).toBe(true);
|
||||
});
|
||||
|
||||
it('counts remote changes while paused', () => {
|
||||
const transport = mockTransport();
|
||||
const editor = new r.Editor({
|
||||
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||
});
|
||||
editor.run();
|
||||
editor.edit();
|
||||
transport.simulateRemote('change 1');
|
||||
transport.simulateRemote('change 2');
|
||||
expect(editor.collaboration!.getRemoteChangeCount()).toBe(2);
|
||||
});
|
||||
|
||||
it('fires remoteActivity event while paused', (done) => {
|
||||
const transport = mockTransport();
|
||||
const editor = new r.Editor({
|
||||
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||
on: { remoteActivity: ({ count }: any) => { if (count === 1) done(); } },
|
||||
});
|
||||
editor.run();
|
||||
editor.edit();
|
||||
transport.simulateRemote('change');
|
||||
});
|
||||
|
||||
it('resumes on switching to wysiwyg', () => {
|
||||
const transport = mockTransport();
|
||||
const editor = new r.Editor({
|
||||
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||
});
|
||||
editor.run();
|
||||
editor.edit();
|
||||
editor.wysiwyg();
|
||||
expect(editor.collaboration!.isPaused()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('locking', () => {
|
||||
it('lock returns true', async () => {
|
||||
const transport = mockTransport();
|
||||
const editor = new r.Editor({
|
||||
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||
});
|
||||
editor.run();
|
||||
expect(await editor.lockForEditing()).toBe(true);
|
||||
});
|
||||
|
||||
it('forceLock returns true', async () => {
|
||||
const transport = mockTransport();
|
||||
const editor = new r.Editor({
|
||||
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||
});
|
||||
editor.run();
|
||||
expect(await editor.forceLockEditing()).toBe(true);
|
||||
});
|
||||
|
||||
it('fires lockChange event', (done) => {
|
||||
const transport = mockTransport();
|
||||
const editor = new r.Editor({
|
||||
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||
on: { lockChange: ({ holder }: any) => { if (holder?.userId === 'alice') done(); } },
|
||||
});
|
||||
editor.run();
|
||||
transport.simulateLock({ userId: 'alice', displayName: 'Alice', status: 'active', lastActive: Date.now() });
|
||||
});
|
||||
});
|
||||
|
||||
describe('presence', () => {
|
||||
it('sends cursor with status', () => {
|
||||
const transport = mockTransport();
|
||||
const presence = mockPresence();
|
||||
const editor = new r.Editor({
|
||||
collaboration: { transport, presence, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now(), color: '#f00' } },
|
||||
});
|
||||
editor.run();
|
||||
editor.wysiwyg();
|
||||
editor.collaboration!.sendCursor(42);
|
||||
expect(presence.lastSent.status).toBe('active');
|
||||
expect(presence.lastSent.cursor).toBe(42);
|
||||
});
|
||||
|
||||
it('sends editing status when paused', () => {
|
||||
const transport = mockTransport();
|
||||
const presence = mockPresence();
|
||||
const editor = new r.Editor({
|
||||
collaboration: { transport, presence, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||
});
|
||||
editor.run();
|
||||
editor.edit();
|
||||
editor.collaboration!.sendCursor(10);
|
||||
expect(presence.lastSent.status).toBe('editing');
|
||||
});
|
||||
|
||||
it('applies idle status to peers', () => {
|
||||
const transport = mockTransport();
|
||||
const presence = mockPresence();
|
||||
const editor = new r.Editor({
|
||||
collaboration: { transport, presence, idleTimeout: 100, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||
});
|
||||
editor.run();
|
||||
presence.simulatePeers([
|
||||
{ userId: 'a', displayName: 'A', status: 'active', lastActive: Date.now() - 200 },
|
||||
{ userId: 'b', displayName: 'B', status: 'active', lastActive: Date.now() },
|
||||
]);
|
||||
const peers = editor.collaboration!.getPeers();
|
||||
expect(peers[0].status).toBe('idle');
|
||||
expect(peers[1].status).toBe('active');
|
||||
});
|
||||
});
|
||||
|
||||
describe('revisions', () => {
|
||||
it('lists revisions', async () => {
|
||||
const transport = mockTransport();
|
||||
const revisions = mockRevisions();
|
||||
await revisions.create('v1', { author: 'test' });
|
||||
const editor = new r.Editor({
|
||||
collaboration: { transport, revisions, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||
});
|
||||
editor.run();
|
||||
const list = await editor.listRevisions();
|
||||
expect(list).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('creates revision', async () => {
|
||||
const transport = mockTransport();
|
||||
const revisions = mockRevisions();
|
||||
const editor = new r.Editor({
|
||||
collaboration: { transport, revisions, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||
});
|
||||
editor.run();
|
||||
const rev = await editor.createRevision({ author: 'test', summary: 'test rev' });
|
||||
expect(rev).toBeDefined();
|
||||
expect(revisions.store).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('restores revision', async () => {
|
||||
const transport = mockTransport();
|
||||
const revisions = mockRevisions();
|
||||
await revisions.create('old content', { author: 'test' });
|
||||
const editor = new r.Editor({
|
||||
collaboration: { transport, revisions, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||
});
|
||||
editor.run();
|
||||
editor.wysiwyg();
|
||||
await editor.restoreRevision('1');
|
||||
expect(editor.getMarkdown()).toBe('old content');
|
||||
});
|
||||
|
||||
it('fires revisionCreated event', async () => {
|
||||
const transport = mockTransport();
|
||||
const revisions = mockRevisions();
|
||||
let fired = false;
|
||||
const editor = new r.Editor({
|
||||
collaboration: { transport, revisions, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||
on: { revisionCreated: () => { fired = true; } },
|
||||
});
|
||||
editor.run();
|
||||
await editor.createRevision({ author: 'test' });
|
||||
expect(fired).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,12 @@ export function getWindow(): any {
|
||||
(global as any).HTMLElement = _window.HTMLElement;
|
||||
(global as any).Node = _window.Node;
|
||||
(global as any).NodeFilter = _window.NodeFilter;
|
||||
(global as any).TextEncoder = _window.TextEncoder || require('util').TextEncoder;
|
||||
(global as any).TextDecoder = _window.TextDecoder || require('util').TextDecoder;
|
||||
|
||||
const { TextEncoder, TextDecoder } = require('util');
|
||||
_window.TextEncoder = TextEncoder;
|
||||
_window.TextDecoder = TextDecoder;
|
||||
|
||||
const bundle = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'dist', 'ribbit', 'ribbit.js'), 'utf8'
|
||||
|
||||
Reference in New Issue
Block a user