259 lines
9.9 KiB
TypeScript
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);
|