import { HocuspocusProvider } from '@hocuspocus/provider'; import { signCollabToken } from '@dorfteich/shared/token-crypto'; import { pino } from 'pino'; import { afterEach, describe, expect, it } from 'vitest'; import * as Y from 'yjs'; import { createCollabServer } from './server.js'; import { freePort } from './testing/free-port.js'; import { InMemoryPagePersistence } from './testing/fake-persistence.js'; const secret = 'persistence-test-secret-32-chars!!'; const logger = pino({ enabled: false }); /** Poll `predicate` until it is true or the timeout elapses. */ async function waitFor(predicate: () => boolean, timeoutMs = 5000): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (predicate()) return; await new Promise((resolve) => setTimeout(resolve, 20)); } throw new Error('timed out waiting for condition'); } describe('collab persistence hooks', () => { const cleanups: Array<() => Promise | void> = []; afterEach(async () => { // Tear down in reverse order (providers before their server). for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); }); async function startServer(persistence: InMemoryPagePersistence): Promise { const server = createCollabServer({ version: 'test', logger, tokenSecret: secret, pingDatabase: async () => ({ ok: true }), persistence, }); const port = await freePort(); await server.listen(port); cleanups.push(() => server.destroy()); return `ws://127.0.0.1:${port}`; } function connect(url: string, pageId: string): { provider: HocuspocusProvider; doc: Y.Doc } { const doc = new Y.Doc(); const token = signCollabToken({ userId: `u-${pageId}`, pageId, mode: 'rw' }, secret, 60); const provider = new HocuspocusProvider({ url, name: pageId, document: doc, token }); cleanups.push(() => provider.destroy()); return { provider, doc }; } it('syncs edits between two read-write clients on the same page', async () => { const persistence = new InMemoryPagePersistence(); const url = await startServer(persistence); const pageId = '11111111-1111-1111-1111-111111111111'; const a = connect(url, pageId); const b = connect(url, pageId); a.doc.getText('t').insert(0, 'hello from A'); await waitFor(() => b.doc.getText('t').toString() === 'hello from A'); expect(b.doc.getText('t').toString()).toBe('hello from A'); }); it('persists edits so they survive a collab-server restart', async () => { const persistence = new InMemoryPagePersistence(); const pageId = '22222222-2222-2222-2222-222222222222'; const url1 = await startServer(persistence); const first = connect(url1, pageId); first.doc.getText('t').insert(0, 'durable content'); // Disconnecting the only client triggers an immediate final store. await waitFor(() => first.provider.isSynced); await first.provider.destroy(); await waitFor(() => persistence.storeCalls > 0); // A brand-new server instance reusing the same persistence must reload it. const url2 = await startServer(persistence); const second = connect(url2, pageId); await waitFor(() => second.doc.getText('t').toString() === 'durable content'); expect(second.doc.getText('t').toString()).toBe('durable content'); }); it('rejects an oversize document and notifies the client via a stateless error', async () => { const persistence = new InMemoryPagePersistence(); persistence.sizeLimit = 10; // any real edit exceeds this const url = await startServer(persistence); const pageId = '33333333-3333-3333-3333-333333333333'; const { provider, doc } = connect(url, pageId); let statelessPayload: string | undefined; provider.on('stateless', ({ payload }: { payload: string }) => { statelessPayload = payload; }); doc.getText('t').insert(0, 'this document is over the ceiling'); await waitFor(() => statelessPayload !== undefined); expect(JSON.parse(statelessPayload!)).toMatchObject({ type: 'error', code: 'page_document_too_large', }); }); });