rspace-online/scripts/test-automerge-roundtrip.ts

259 lines
9.9 KiB
TypeScript

/**
* 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<string, { text: string }> }
const docChanges: string[] = [];
const server = new SyncServer({
participantMode: true,
onDocChange: (docId) => docChanges.push(docId),
});
// Create a doc
let doc = Automerge.init<TestDoc>();
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<TestDoc>('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<TestDoc>('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<TestDoc>('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<NoteDoc>();
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<NoteDoc>(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<NoteDoc>(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<NoteDoc>(new Uint8Array(raw));
server2.setDoc('roundtrip:notes:notebooks:persist-1', loadedDoc);
const fromServer = server2.getDoc<NoteDoc>('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<SimpleDoc>();
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);