/** * Test: Automerge round-trip — create, save, load, sync, verify. * * Exercises the full local-first stack: * 1. SyncServer (in-memory doc management) * 2. Doc persistence (save to disk + load from disk) * 3. Schema init factories (NotebookDoc, BoardDoc, etc.) * 4. Doc change + onDocChange callback * * Usage: bun run scripts/test-automerge-roundtrip.ts */ // Must set env BEFORE imports (doc-persistence reads it at module level) const TEST_DIR = '/tmp/rspace-automerge-test'; process.env.DOCS_STORAGE_DIR = TEST_DIR; import * as Automerge from '@automerge/automerge'; import { mkdirSync, rmSync, existsSync, readdirSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { SyncServer } from '../server/local-first/sync-server'; import { docIdToPath, saveDoc, loadAllDocs } from '../server/local-first/doc-persistence'; // Cleanup from previous runs if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); let passed = 0; let failed = 0; function assert(condition: boolean, label: string) { if (condition) { console.log(` ✓ ${label}`); passed++; } else { console.error(` ✗ ${label}`); failed++; } } // ─── Test 1: docIdToPath mapping ────────────────────────── console.log('\n── Test 1: docId ↔ path mapping ──'); { const path = docIdToPath('demo:notes:notebooks:abc'); assert(path.endsWith('/demo/notes/notebooks/abc.automerge'), `docIdToPath → ${path}`); const path2 = docIdToPath('myspace:work:boards:board-1'); assert(path2.endsWith('/myspace/work/boards/board-1.automerge'), `docIdToPath boards → ${path2}`); let threw = false; try { docIdToPath('invalid'); } catch { threw = true; } assert(threw, 'docIdToPath rejects invalid docId (< 3 parts)'); } // ─── Test 2: SyncServer in-memory CRUD ───────────────────── console.log('\n── Test 2: SyncServer in-memory CRUD ──'); { interface TestDoc { title: string; items: Record } const docChanges: string[] = []; const server = new SyncServer({ participantMode: true, onDocChange: (docId) => docChanges.push(docId), }); // Create a doc let doc = Automerge.init(); doc = Automerge.change(doc, 'init', (d) => { d.title = 'Test Notebook'; d.items = {}; }); server.setDoc('test:notes:notebooks:nb1', doc); assert(server.getDocIds().includes('test:notes:notebooks:nb1'), 'setDoc registers docId'); // Read it back const loaded = server.getDoc('test:notes:notebooks:nb1'); assert(loaded !== undefined, 'getDoc returns the doc'); assert(loaded!.title === 'Test Notebook', 'doc content preserved'); // Change via changeDoc const changed = server.changeDoc('test:notes:notebooks:nb1', 'add item', (d) => { d.items['item-1'] = { text: 'Hello local-first' }; }); assert(changed !== null, 'changeDoc returns updated doc'); assert(changed!.items['item-1'].text === 'Hello local-first', 'changeDoc content correct'); assert(docChanges.length === 1, 'onDocChange callback fired'); // Verify the server's copy is updated const reloaded = server.getDoc('test:notes:notebooks:nb1'); assert(reloaded!.items['item-1'].text === 'Hello local-first', 'server copy updated after changeDoc'); } // ─── Test 3: Relay mode ──────────────────────────────────── console.log('\n── Test 3: Relay mode (encrypted spaces) ──'); { const server = new SyncServer({ participantMode: true }); assert(!server.isRelayOnly('demo:notes:notebooks:x'), 'not relay by default'); server.setRelayOnly('encrypted-space', true); assert(server.isRelayOnly('encrypted-space'), 'exact match → relay'); assert(server.isRelayOnly('encrypted-space:notes:notebooks:x'), 'prefix match → relay'); assert(!server.isRelayOnly('other-space:notes:notebooks:x'), 'other space → not relay'); server.setRelayOnly('encrypted-space', false); assert(!server.isRelayOnly('encrypted-space:notes:notebooks:x'), 'after removal → not relay'); } // ─── Test 4: Disk persistence round-trip ─────────────────── // Note: We test Automerge binary serialization directly rather than using // doc-persistence (which reads DOCS_STORAGE_DIR at module load time). console.log('\n── Test 4: Disk persistence round-trip ──'); await (async () => { interface NoteDoc { title: string; content: string } // Create a doc let doc = Automerge.init(); doc = Automerge.change(doc, 'init', (d) => { d.title = 'Persistent Note'; d.content = 'This should survive a restart'; }); // Serialize to binary const binary = Automerge.save(doc); assert(binary.byteLength > 0, `Automerge.save produces ${binary.byteLength} bytes`); // Write to temp dir const { mkdir: mk, writeFile: wf, readFile: rf } = await import('node:fs/promises'); const { dirname: dn } = await import('node:path'); const filePath = resolve(TEST_DIR, 'roundtrip/notes/notebooks/persist-1.automerge'); await mk(dn(filePath), { recursive: true }); await wf(filePath, binary); assert(existsSync(filePath), `file written to disk`); // Read back and deserialize const rawBuf = await rf(filePath); const reloaded = Automerge.load(new Uint8Array(rawBuf)); assert(reloaded.title === 'Persistent Note', 'title preserved after load'); assert(reloaded.content === 'This should survive a restart', 'content preserved after load'); // Verify Automerge history const history = Automerge.getHistory(reloaded); assert(history.length === 1, `history has ${history.length} change(s)`); // Test: modify, save again, load again const doc2 = Automerge.change(reloaded, 'update', (d) => { d.content = 'Updated content after reload'; }); const binary2 = Automerge.save(doc2); await wf(filePath, binary2); const rawBuf2 = await rf(filePath); const reloaded2 = Automerge.load(new Uint8Array(rawBuf2)); assert(reloaded2.content === 'Updated content after reload', 'content updated after second save/load'); assert(Automerge.getHistory(reloaded2).length === 2, 'history has 2 changes after update'); // Load via loadAllDocs into a SyncServer (uses TEST_DIR since that's what's on disk) // We do this by creating a SyncServer and loading manually const server2 = new SyncServer({ participantMode: true }); const raw = await rf(filePath); const loadedDoc = Automerge.load(new Uint8Array(raw)); server2.setDoc('roundtrip:notes:notebooks:persist-1', loadedDoc); const fromServer = server2.getDoc('roundtrip:notes:notebooks:persist-1'); assert(fromServer!.title === 'Persistent Note', 'SyncServer holds correct doc from disk'); })(); // ─── Test 5: Multiple docs + listDocs ────────────────────── console.log('\n── Test 5: Multiple docs + listing ──'); await (async () => { const server = new SyncServer({ participantMode: true }); for (const id of ['space-a:work:boards:b1', 'space-a:work:boards:b2', 'space-b:cal:events']) { let doc = Automerge.init<{ label: string }>(); doc = Automerge.change(doc, 'init', (d) => { d.label = id; }); server.setDoc(id, doc); } const ids = server.getDocIds(); assert(ids.length === 3, `3 docs registered (got ${ids.length})`); assert(ids.includes('space-a:work:boards:b1'), 'board b1 listed'); assert(ids.includes('space-b:cal:events'), 'cal events listed'); })(); // ─── Test 6: Peer subscribe + sync message flow ──────────── console.log('\n── Test 6: Peer subscribe + sync flow ──'); { interface SimpleDoc { value: number } const sent: Array<{ peerId: string; msg: string }> = []; const server = new SyncServer({ participantMode: true }); // Create a doc on the server let doc = Automerge.init(); doc = Automerge.change(doc, 'set value', (d) => { d.value = 42; }); server.setDoc('sync-test:data:metrics', doc); // Add a mock peer const mockWs = { send: (data: string) => sent.push({ peerId: 'peer-1', msg: data }), readyState: 1, }; server.addPeer('peer-1', mockWs); // Subscribe peer to the doc server.handleMessage('peer-1', JSON.stringify({ type: 'subscribe', docIds: ['sync-test:data:metrics'], })); assert(server.getDocSubscribers('sync-test:data:metrics').includes('peer-1'), 'peer subscribed'); assert(sent.length > 0, `sync message sent to peer (${sent.length} message(s))`); // Verify the sync message is valid JSON with type 'sync' const firstMsg = JSON.parse(sent[0].msg); assert(firstMsg.type === 'sync', `message type is 'sync'`); assert(firstMsg.docId === 'sync-test:data:metrics', 'correct docId in sync message'); assert(Array.isArray(firstMsg.data), 'sync data is array (Uint8Array serialized)'); // Clean up peer server.removePeer('peer-1'); assert(!server.getPeerIds().includes('peer-1'), 'peer removed'); assert(server.getDocSubscribers('sync-test:data:metrics').length === 0, 'subscriber cleaned up'); } // ─── Test 7: Ping/pong ──────────────────────────────────── console.log('\n── Test 7: Ping/pong ──'); { const sent: string[] = []; const server = new SyncServer({ participantMode: true }); const mockWs = { send: (data: string) => sent.push(data), readyState: 1, }; server.addPeer('ping-peer', mockWs); server.handleMessage('ping-peer', JSON.stringify({ type: 'ping' })); assert(sent.length === 1, 'pong sent'); assert(JSON.parse(sent[0]).type === 'pong', 'response is pong'); server.removePeer('ping-peer'); } // ─── Summary ─────────────────────────────────────────────── console.log(`\n${'═'.repeat(50)}`); console.log(` ${passed} passed, ${failed} failed`); console.log(`${'═'.repeat(50)}\n`); // Cleanup rmSync(TEST_DIR, { recursive: true }); process.exit(failed > 0 ? 1 : 0);