Real-time collaboration through consumer-provided transport and presence interfaces. Also includes a sample backend app.
274 lines
11 KiB
TypeScript
274 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|