Recently Written · git

ideamine

An idea inbox for Claude Code: /idea saves ideas at zero tokens; Claude triages them and routes each to the cheapest model that can build it.

git clone https://github.com/equwal/ideamine

Log | Files | Refs


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 });