tests/sync.test.js (12034 bytes)
1 import assert from 'node:assert/strict'; 2 import fs from 'node:fs'; 3 import os from 'node:os'; 4 import path from 'node:path'; 5 import { after, before, beforeEach, test } from 'node:test'; 6 import fc from 'fast-check'; 7 import * as archive from '../src/archive.js'; 8 import * as store from '../src/store.js'; 9 import * as sync from '../src/sync.js'; 10 import { freePort, startServerProcess } from './fixtures/server-process.js'; 11 12 let server; 13 let deadUrl; // an address where nothing answers: the tunnel of a machine is down 14 15 before(async () => { 16 server = await startServerProcess(); 17 deadUrl = `http://127.0.0.1:${await freePort()}/`; 18 }); 19 20 after(() => server.stop()); 21 22 /** An empty archive on the server: its archive and its record of applied changes go. */ 23 function resetServer() { 24 for (const name of ['ideas.json', 'ideas.json.bak', 'applied.json', 'prompts.jsonl']) fs.rmSync(path.join(server.home, name), { force: true }); 25 } 26 27 const newHome = (name) => fs.mkdtempSync(path.join(os.tmpdir(), `ideamine-${name}-`)); 28 29 /** Act as one machine: its own archive copy and outbox, and the server at `url`. */ 30 function machine(home, url = server.url) { 31 process.env.IDEAMINE_HOME = home; 32 process.env.IDEAMINE_SYNC_URL = url; 33 } 34 35 beforeEach(() => { 36 resetServer(); 37 delete process.env.IDEAMINE_PROMPT_LOG; 38 machine(newHome('pc')); 39 }); 40 41 const serverDb = async () => (await (await fetch(`${server.url}api/db`)).json()).db; 42 43 test('a change goes to the server, and the copy here follows the server', async () => { 44 const [{ idea }] = await archive.add(['alpha #web'], { source: 'test', project: '/work/app' }); 45 assert.equal(idea.id, 1); 46 assert.equal(idea.host, os.hostname()); 47 const onServer = await serverDb(); 48 assert.deepEqual(onServer.ideas.map((i) => [i.id, i.title, i.tags[0]]), [[1, 'alpha', 'web']]); 49 assert.deepEqual(store.load().ideas, onServer.ideas); 50 assert.equal(sync.waiting(), 0); 51 const done = await archive.update(1, { status: 'done', note: 'shipped' }); 52 assert.deepEqual([done.status, done.notes.map((n) => n.text)], ['done', ['shipped']]); 53 assert.deepEqual((await archive.remove([1])).map((i) => i.id), [1]); 54 assert.deepEqual((await serverDb()).ideas, []); 55 }); 56 57 test('when the server does not answer, a change waits in the outbox and goes with the next sync', async () => { 58 const home = store.home(); 59 machine(home, deadUrl); 60 const out = await archive.add(['written on a train']); 61 assert.equal(out.queued, true); 62 assert.match(out.reason, /cannot reach/); 63 assert.equal(sync.waiting(), 1); 64 assert.equal((await serverDb()).ideas.length, 0); 65 assert.match(sync.status(), /1 change waits for the server[\s\S]*The last sync failed/); 66 67 machine(home); // the tunnel is back 68 await sync.pull(); 69 assert.equal(sync.waiting(), 0); 70 assert.deepEqual((await serverDb()).ideas.map((i) => i.text), ['written on a train']); 71 assert.deepEqual(store.load().ideas.map((i) => i.text), ['written on a train']); 72 }); 73 74 test('the server applies a change only once, also when it comes twice', async () => { 75 const op = { op: 'add', texts: ['once'], oid: 'same-oid' }; 76 const post = async () => (await fetch(`${server.url}api/ops`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ops: [op, { op: 'update', id: 1, patch: { note: 'n' }, oid: 'note-oid' }] }) })).json(); 77 const first = await post(); 78 const second = await post(); // the answer to the first got lost, and the machine sent it again 79 assert.deepEqual(second.results, first.results); 80 assert.equal(second.db.ideas.length, 1); 81 assert.deepEqual(second.db.ideas[0].notes.map((n) => n.text), ['n']); 82 const bad = await (await fetch(`${server.url}api/ops`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ops: [{ op: 'nope', oid: 'x' }, { op: 'remove', ids: [9], oid: 'y' }] }) })).json(); 83 assert.deepEqual(bad.results, [{ ok: false, error: 'unknown change "nope"' }, { ok: false, error: 'no idea #9' }]); 84 }); 85 86 test('a slow answer with an older archive does not undo a newer copy', async () => { 87 await archive.add(['one']); 88 await archive.add(['two']); 89 const newer = store.load(); 90 const stateNow = sync.readState(); 91 // The answer to an earlier request arrives last: revision 1 of the same archive. 92 const old = { ...newer, rev: 1, ideas: newer.ideas.slice(0, 1) }; 93 const original = globalThis.fetch; 94 globalThis.fetch = async () => new Response(JSON.stringify({ ok: true, db: old }), { status: 200 }); 95 try { 96 await sync.pull(); 97 } finally { 98 globalThis.fetch = original; 99 } 100 assert.deepEqual(store.load().ideas.map((i) => i.text), ['one', 'two']); 101 assert.equal(sync.readState().rev, stateNow.rev); 102 }); 103 104 test('kick() starts a background sync when work waits or the copy is old, and waits after a failure', async () => { 105 let started = 0; 106 const kick = (now = Date.now()) => sync.kick({ start: () => started++, now }); 107 assert.equal(kick(), true); // never synced 108 await sync.pull(); 109 assert.equal(kick(), false); // fresh, nothing waits 110 assert.equal(kick(Date.now() + 61000), true); // the copy is old 111 sync.logPrompt({ prompt: 'hello', session: 's1', cwd: '/w' }); 112 assert.equal(kick(), false); // a prompt waits, but the last sync was a moment ago 113 assert.equal(kick(Date.now() + 21000), true); // a prompt waits, and the last sync is 21 s old 114 machine(store.home(), deadUrl); 115 await archive.add(['queued']); 116 assert.equal(kick(), false); // the sync failed a moment ago 117 delete process.env.IDEAMINE_SYNC_URL; 118 assert.equal(kick(Date.now() + 3600000), false); // sync is off 119 assert.equal(started, 3); 120 }); 121 122 test('turning sync on keeps the archive that was here in a backup file', async () => { 123 delete process.env.IDEAMINE_SYNC_URL; 124 store.addIdeas(['local only']); 125 const backup = sync.setServer(server.url); 126 assert.match(path.basename(backup), /^ideas\.before-sync-\d{4}-\d{2}-\d{2}\.json$/); 127 assert.equal(JSON.parse(fs.readFileSync(backup, 'utf8')).ideas[0].text, 'local only'); 128 await archive.fresh(); 129 assert.deepEqual(store.load().ideas, []); // the server's archive, which is empty 130 assert.throws(() => sync.setServer('ftp://x'), /not an http/); 131 sync.setServer(''); 132 }); 133 134 test('the prompt log sends prompts once, cuts a huge paste, and imports older transcripts', async () => { 135 process.env.IDEAMINE_PROMPT_LOG = 'on'; 136 const transcripts = path.join(store.home(), 'claude', 'projects', 'C--work-app'); 137 fs.mkdirSync(transcripts, { recursive: true }); 138 process.env.CLAUDE_CONFIG_DIR = path.join(store.home(), 'claude'); 139 const line = (o) => JSON.stringify({ sessionId: 's0', cwd: 'C:\\work\\app', ...o }); 140 fs.writeFileSync(path.join(transcripts, 's0.jsonl'), [ 141 line({ type: 'user', timestamp: '2026-01-05T10:00:00.000Z', message: { role: 'user', content: '<system-reminder>\nNotes of the app.\n</system-reminder>\nfix the tests' } }), 142 line({ type: 'user', timestamp: '2026-01-05T09:59:00.000Z', message: { role: 'user', content: [{ type: 'text', text: '<system-reminder>only a note</system-reminder>' }] } }), 143 line({ type: 'user', timestamp: '2026-01-05T10:01:00.000Z', message: { role: 'user', content: '<command-name>/ideas</command-name>\n<command-args>go 3</command-args>' } }), 144 line({ type: 'user', timestamp: '2026-01-05T10:02:00.000Z', message: { role: 'user', content: [{ type: 'tool_result', content: 'ok' }] }, toolUseResult: {} }), 145 line({ type: 'user', timestamp: '2026-01-05T10:03:00.000Z', isMeta: true, message: { role: 'user', content: 'Caveat: a note of Claude Code' } }), 146 line({ type: 'user', timestamp: '2026-01-05T10:04:00.000Z', isSidechain: true, message: { role: 'user', content: 'a prompt of a subagent' } }), 147 line({ type: 'user', timestamp: '2026-01-05T10:05:00.000Z', message: { role: 'user', content: '[Request interrupted by user]' } }), 148 line({ type: 'assistant', timestamp: '2026-01-05T10:06:00.000Z', message: { role: 'assistant', content: 'hi' } }), 149 line({ type: 'user', timestamp: '2026-02-01T10:00:00.000Z', message: { role: 'user', content: 'after the live log started' } }), 150 'not json', 151 ].join('\n')); 152 sync.logPrompt({ prompt: 'x'.repeat(150000), session: 's1', cwd: '/w', at: '2026-01-10T00:00:00.000Z' }); 153 assert.equal(sync.importPrompts(), 2); // older than the first live prompt only 154 assert.equal(sync.importPrompts(), 2); // the same ids again: the server skips them 155 await sync.pull({ prompts: true }); 156 const { prompts } = await (await fetch(`${server.url}api/prompts`)).json(); 157 assert.deepEqual(prompts.map((p) => p.prompt.slice(0, 20)), ['fix the tests', '/ideas go 3', 'x'.repeat(20)]); 158 assert.match(prompts[2].prompt, /… \(cut: 50000 more characters\)$/); 159 assert.equal(prompts[0].cwd, 'C:\\work\\app'); 160 assert.deepEqual((await (await fetch(`${server.url}api/prompts?days=1`)).json()).prompts, []); // all older than a day 161 delete process.env.CLAUDE_CONFIG_DIR; 162 }); 163 164 // A model of two machines that share the server, with outages. Each idea text is unique, so the 165 // test can follow an idea without its id. 166 const command = fc.oneof( 167 fc.record({ c: fc.constant('add'), who: fc.constantFrom('A', 'B') }), 168 fc.record({ c: fc.constantFrom('done', 'note', 'rm'), who: fc.constantFrom('A', 'B'), pick: fc.nat(20) }), 169 fc.record({ c: fc.constantFrom('down', 'up', 'pull'), who: fc.constantFrom('A', 'B') }), 170 ); 171 172 test('two machines with outages end with one archive: no idea lost, none twice, no note twice', async () => { 173 await fc.assert( 174 fc.asyncProperty(fc.array(command, { maxLength: 18 }), async (commands) => { 175 resetServer(); 176 const homes = { A: newHome('a'), B: newHome('b') }; 177 const up = { A: true, B: true }; 178 const added = []; 179 const removed = new Set(); 180 let n = 0; 181 const use = (who) => machine(homes[who], up[who] ? server.url : deadUrl); 182 for (const cmd of commands) { 183 if (cmd.c === 'down' || cmd.c === 'up') { 184 up[cmd.who] = cmd.c === 'up'; 185 continue; 186 } 187 use(cmd.who); 188 if (cmd.c === 'pull') { 189 await sync.pull().catch((e) => assert.ok(e instanceof sync.SyncError, e)); 190 continue; 191 } 192 if (cmd.c === 'add') { 193 const text = `idea ${++n}`; 194 added.push(text); 195 await archive.add([text]); 196 continue; 197 } 198 const ideas = store.load().ideas; 199 if (!ideas.length) continue; 200 const idea = ideas[cmd.pick % ideas.length]; 201 if (cmd.c === 'rm') removed.add(idea.text); 202 const run = cmd.c === 'rm' ? archive.remove([idea.id]) : archive.update(idea.id, cmd.c === 'done' ? { status: 'done' } : { note: `note ${++n}` }); 203 // The other machine may have deleted the idea already: then the server refuses the change. 204 await run.catch((e) => assert.match(e.message, /^no idea #\d+$/)); 205 } 206 for (const who of ['A', 'B', 'A']) { 207 machine(homes[who]); 208 await sync.pull(); 209 } 210 const onServer = await serverDb(); 211 const texts = onServer.ideas.map((i) => i.text).sort(); 212 assert.deepEqual(texts, added.filter((t) => !removed.has(t)).sort()); 213 for (const idea of onServer.ideas) { 214 const notes = idea.notes.map((x) => x.text); 215 assert.equal(new Set(notes).size, notes.length, `a note came twice on #${idea.id}`); 216 } 217 for (const who of ['A', 'B']) { 218 machine(homes[who]); 219 assert.deepEqual(store.load().ideas, onServer.ideas); 220 assert.equal(sync.waiting(), 0); 221 } 222 }), 223 { numRuns: 40 }, 224 ); 225 }); 226 227 test('a Windows path from a machine stays as it is on a POSIX server, and names show on any machine', () => { 228 assert.equal(store.absolute('C:\\Users\\me\\app', 'linux'), 'C:\\Users\\me\\app'); 229 assert.equal(store.absolute('\\\\nas\\share\\app', 'linux'), '\\\\nas\\share\\app'); 230 assert.equal(store.absolute('/home/me/app', 'linux'), path.resolve('/home/me/app')); 231 assert.equal(store.baseName('C:\\Users\\me\\app\\'), 'app'); 232 assert.equal(store.baseName('/home/me/app'), 'app'); 233 });