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); }); }); });