ribbit/test/collaboration.test.ts

492 lines
15 KiB
TypeScript

import { ribbit, resetDOM } from './setup';
const lib = 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((rev: any) => rev.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 lib.Editor({});
editor.run();
expect(editor.collaboration).toBeUndefined();
});
it('creates manager with settings', () => {
const transport = mockTransport();
const editor = new lib.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 lib.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 lib.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 lib.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 lib.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 lib.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 lib.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 lib.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 lib.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 lib.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 lib.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 lib.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 lib.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 lib.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 lib.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 lib.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 lib.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 lib.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);
});
});
});