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/serve.test.js (18719 bytes)

1 import assert from 'node:assert/strict';
2 import { spawnSync } from 'node:child_process';
3 import fs from 'node:fs';
4 import http from 'node:http';
5 import net from 'node:net';
6 import os from 'node:os';
7 import path from 'node:path';
8 import { afterEach, beforeEach, test } from 'node:test';
9 import { fileURLToPath } from 'node:url';
10 import fc from 'fast-check';
11 import { handlePrompt } from '../src/hook.js';
12 import * as serve from '../src/serve.js';
13 import * as store from '../src/store.js';
14 import { startFakeMemstate } from './fixtures/fake-memstate.js';
15 import { startFakeServer } from './fixtures/fake-server.js';
16 import { startServerProcess } from './fixtures/server-process.js';
17 
18 const BIN = fileURLToPath(new URL('../bin/ideamine.js', import.meta.url));
19 
20 let server; // the server under test
21 let url;
22 let embedServer;
23 let opened; // the windows that /api/go asked for
24 let changes; // how often a command asked for a new upload of the dashboard
25 
26 beforeEach(async () => {
27   process.env.IDEAMINE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-serve-'));
28   // The triage and the questions must never reach the real claude or the projects of this machine.
29   process.env.IDEAMINE_CLAUDE_BIN = fileURLToPath(new URL('./fixtures/fake-claude.js', import.meta.url));
30   process.env.CLAUDE_CONFIG_DIR = process.env.IDEAMINE_HOME;
31   for (const name of ['IDEAMINE_SYNC_URL', 'IDEAMINE_MEMSTATE_URL', 'IDEAMINE_SERVE_HOSTS', 'IDEAMINE_PROMPT_LOG']) delete process.env[name];
32   embedServer = await startFakeServer();
33   process.env.IDEAMINE_EMBED_URL = `${embedServer.url}/v1`;
34   opened = [];
35   changes = 0;
36   ({ server, url } = await serve.start({
37     port: 0,
38     open: async (args, cwd) => opened.push({ args, cwd }),
39     afterChange: () => changes++,
40   }));
41 });
42 
43 afterEach(async () => {
44   await new Promise((resolve) => server.close(resolve));
45   await embedServer.close();
46   delete process.env.IDEAMINE_EMBED_URL;
47 });
48 
49 /** POST to the server under test the way the page does. */
50 async function api(action, body = {}, headers = {}) {
51   const res = await fetch(new URL(`api/${action}`, url), {
52     method: 'POST',
53     headers: { 'content-type': 'application/json', ...headers },
54     body: typeof body === 'string' ? body : JSON.stringify(body),
55   });
56   return { status: res.status, ...(await res.json()) };
57 }
58 
59 /** A raw request, so a test can send any Host header. */
60 function raw(method, pathname, headers) {
61   return new Promise((resolve, reject) => {
62     const req = http.request(new URL(pathname, url), { method, headers }, (res) => {
63       res.resume();
64       res.on('end', () => resolve(res));
65     });
66     req.on('error', reject);
67     req.end();
68   });
69 }
70 
71 test('only this page may use the server: a property over hosts, origins, and content types', () => {
72   const port = 4332;
73   const post = (h) => ({ method: 'POST', type: 'application/json', ...h });
74   fc.assert(
75     fc.property(fc.string(), (host) => {
76       const own = ['127.0.0.1:4332', 'localhost:4332'].includes(host.toLowerCase());
77       assert.equal(serve.allowed({ method: 'GET', host }, port), own);
78       assert.equal(serve.allowed(post({ host }), port), own);
79     }),
80   );
81   const origins = fc.oneof(fc.string(), fc.webUrl(), fc.constantFrom('null', 'http://127.0.0.1:4333', 'https://127.0.0.1:4332', 'http://localhost:4332/'));
82   fc.assert(
83     fc.property(origins, (origin) => {
84       fc.pre(origin !== 'http://127.0.0.1:4332');
85       assert.equal(serve.allowed(post({ host: '127.0.0.1:4332', origin }), port), false);
86     }),
87   );
88   fc.assert(
89     fc.property(fc.string(), (type) => {
90       fc.pre(!/^\s*application\/json\s*(;|$)/i.test(type));
91       assert.equal(serve.allowed(post({ host: '127.0.0.1:4332', type }), port), false);
92     }),
93   );
94   assert.equal(serve.allowed(post({ host: '127.0.0.1:4332', origin: 'http://127.0.0.1:4332', type: 'application/json; charset=utf-8' }), port), true);
95   assert.equal(serve.allowed(post({ host: '127.0.0.1:4332' }), port), true); // no Origin: a program on this PC
96   for (const method of ['PUT', 'DELETE', 'OPTIONS', 'PATCH']) assert.equal(serve.allowed({ method, host: '127.0.0.1:4332' }, port), false);
97   // Behind nginx, the server also answers the names in serve_hosts, and a POST must come from there.
98   const hosts = ['10.66.0.1'];
99   assert.equal(serve.allowed({ method: 'GET', host: '10.66.0.1' }, port), false);
100   assert.equal(serve.allowed({ method: 'GET', host: '10.66.0.1' }, port, hosts), true);
101   assert.equal(serve.allowed(post({ host: '10.66.0.1', origin: 'http://10.66.0.1' }), port, hosts), true);
102   assert.equal(serve.allowed(post({ host: '10.66.0.1', origin: 'http://127.0.0.1:4332' }), port, hosts), false);
103   fc.assert(
104     fc.property(fc.string(), (host) => {
105       assert.equal(serve.allowed({ method: 'GET', host }, port, hosts), ['127.0.0.1:4332', 'localhost:4332', '10.66.0.1'].includes(host.toLowerCase()));
106     }),
107   );
108 });
109 
110 test('requests from other pages change nothing', async () => {
111   const text = await fetch(new URL('api/add', url), { method: 'POST', headers: { 'content-type': 'text/plain' }, body: '{"text":"x"}' });
112   assert.equal(text.status, 403); // a form or a no-cors fetch of another page
113   assert.equal((await api('add', { text: 'x' }, { origin: 'http://evil.example' })).status, 403);
114   assert.equal((await raw('GET', '/data.json', { host: `evil.example:${server.address().port}` })).statusCode, 403); // DNS rebinding
115   const preflight = await raw('OPTIONS', '/api/add', { origin: 'http://evil.example', 'access-control-request-method': 'POST' });
116   assert.equal(preflight.statusCode, 403);
117   assert.equal(preflight.headers['access-control-allow-origin'], undefined);
118   assert.equal(store.load().ideas.length, 0);
119   assert.equal(changes, 0);
120   assert.equal((await api('nope')).status, 404);
121   assert.equal((await api('add', 'not json')).error, 'the request is not valid JSON');
122 });
123 
124 test('the page comes with live data, and no other page can frame it', async () => {
125   store.addIdeas(['alpha']);
126   const page = await fetch(url);
127   assert.equal(page.status, 200);
128   assert.match(page.headers.get('content-security-policy'), /frame-ancestors 'none'/);
129   assert.match(await page.text(), /id="actions"/);
130   const data = await (await fetch(new URL('data.json', url))).json();
131   assert.equal(data.version, 1);
132   assert.equal(data.ideas[0].key, 'IDEA-1');
133   assert.deepEqual(data.live, {
134     note: '',
135     window: process.platform === 'win32',
136     claude: true, // the stand-in claude of the tests starts
137     watch: { on: false, status: data.live.watch.status },
138     sync: null,
139     prompts: false,
140     memory: false,
141   });
142   assert.match(data.live.watch.status, /^ideamine watch: off/);
143 });
144 
145 test('add, update, and rm do what /idea, /ideas-done, /ideas-reopen, and /ideas-rm do', async () => {
146   const added = await api('add', { text: '- alpha #web\n- beta' });
147   assert.match(added.message, /Saved 2 ideas: #1, #2/);
148   const [alpha] = store.load().ideas;
149   assert.deepEqual([alpha.source, alpha.tags, alpha.project], ['web', ['web'], null]);
150 
151   assert.equal((await api('update', { id: 1, status: 'done', note: 'shipped' })).message, '✓ #1 alpha → done · note added');
152   assert.deepEqual(store.findIdea(store.load(), 1).notes.map((n) => n.text), ['shipped']);
153   assert.equal((await api('update', { id: 1, status: 'reopen' })).message, '✓ #1 alpha → inbox');
154   assert.equal((await api('update', { id: 2, status: 'doing' })).message, '✓ #2 beta → doing');
155   assert.ok(store.findIdea(store.load(), 2).started);
156   assert.equal((await api('update', { id: 2, model: 'opus' })).message, '✓ #2 beta → doing · model opus');
157   assert.equal((await api('update', { id: 2, status: 'dropped' })).message, '✓ #2 beta → dropped');
158   assert.equal((await api('update', { id: 2 })).error, 'nothing to change');
159 
160   const missing = await api('rm', { ids: [1, 9] });
161   assert.deepEqual([missing.status, missing.error], [400, 'no idea #9']);
162   assert.equal(store.load().ideas.length, 2); // an unknown id deletes nothing
163   assert.equal((await api('rm', { ids: [1] })).message, 'Removed #1 · alpha');
164   assert.deepEqual(store.load().ideas.map((i) => i.id), [2]);
165   assert.equal(changes, 7); // each command that changed the archive, and no failed one
166 });
167 
168 test('sort triages the inbox with one headless call, like /ideas-sort', async () => {
169   await api('add', { text: '- alpha\n- beta' });
170   const out = await api('sort');
171   assert.match(out.message, /^Saved 2 verdicts: 2 do · 0 maybe · 0 skip/);
172   assert.ok(store.load().ideas.every((i) => i.status === 'triaged'));
173   assert.equal((await api('sort')).message, 'Nothing to triage: the inbox is empty.');
174 });
175 
176 test('go triages a new idea, then opens a window that runs `ideamine go N` in the project', async () => {
177   const project = fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-project-'));
178   store.addIdeas(['alpha'], { project });
179   store.addIdeas(['beta'], { project: path.join(project, 'gone') });
180   const out = await api('go', { id: 1 });
181   assert.match(out.message, /^Saved 1 verdict: 1 do[\s\S]*\n\nOpened Claude Code \(sonnet\) in .* for #1 · Idea 1$/);
182   assert.deepEqual(opened, [{ args: [BIN, 'go', '1'], cwd: project }]);
183   assert.equal(store.findIdea(store.load(), 1).status, 'doing');
184 
185   // A folder that is gone: the build starts in the home folder. Without an id: the first in the queue.
186   await api('go');
187   assert.deepEqual(opened[1], { args: [BIN, 'go', '2'], cwd: os.homedir() });
188   const none = await api('go');
189   assert.deepEqual([none.status, none.error], [400, 'Nothing is ready to build. Triage the inbox first.']);
190 });
191 
192 test('a window that cannot open changes nothing', async () => {
193   await new Promise((resolve) => server.close(resolve));
194   ({ server, url } = await serve.start({ port: 0, open: async () => { throw new Error('no terminal'); }, afterChange: () => changes++ }));
195   store.addIdeas(['alpha']);
196   store.applyTriage([{ id: 1, verdict: 'do', impact: 3, size: 's', model: 'haiku', brief: 'b' }]);
197   const out = await api('go', { id: 1 });
198   assert.deepEqual([out.status, out.error], [400, 'no terminal']);
199   assert.equal(store.findIdea(store.load(), 1).status, 'triaged');
200 });
201 
202 test('ask answers a question about the ideas with one headless call, like /ideas <question>', async () => {
203   await api('add', { text: 'alpha' });
204   const before = changes;
205   assert.equal((await api('ask', { question: 'which first?' })).message, '#1 fits "which first?".');
206   assert.equal((await api('ask', { question: ' ' })).error, 'ask a question');
207   assert.equal(changes, before); // a question changes nothing
208 });
209 
210 test('watch turns the watcher on and off, like /ideas-watch', async () => {
211   assert.match((await api('watch', { on: true })).message, /^ideamine watch: on since /);
212   assert.equal(JSON.parse(fs.readFileSync(path.join(store.home(), 'watch.json'), 'utf8')).on, true);
213   assert.match((await api('watch', { on: false })).message, /^ideamine watch: off/);
214 });
215 
216 test('search by meaning on the page goes through the server to the embedding server', async () => {
217   const res = await fetch(new URL('v1/embeddings', url), {
218     method: 'POST',
219     headers: { 'content-type': 'application/json' },
220     body: JSON.stringify({ model: 'nomic-embed-text', input: ['search_query: subtitles'] }),
221   });
222   assert.equal(res.status, 200);
223   assert.equal((await res.json()).data[0].embedding.length, 64);
224   assert.deepEqual(embedServer.inputs, ['search_query: subtitles']);
225 });
226 
227 test('the server role keeps the prompts of every machine, each once, and gives them by days', async () => {
228   const recent = new Date(Date.now() - 3600000).toISOString();
229   const prompts = [
230     { id: 'a', at: recent, host: 'pc', session: 's1', cwd: 'C:\\work\\app', prompt: 'fix the tests' },
231     { id: 'b', at: '2026-01-01T00:00:00.000Z', host: 'mac', session: 's2', cwd: '/Users/me/app', prompt: 'an old one' },
232     { id: 'bad', prompt: 'no time' },
233   ];
234   const first = await api('prompts', { prompts });
235   assert.deepEqual([first.added, first.skipped], [2, 1]);
236   assert.equal((await api('prompts', { prompts })).added, 0); // the same prompts again
237   const get = async (route) => (await (await fetch(new URL(route, url))).json()).prompts;
238   assert.deepEqual((await get('api/prompts')).map((p) => p.id), ['b', 'a']); // oldest first
239   assert.deepEqual((await get('api/prompts?days=1')).map((p) => p.prompt), ['fix the tests']);
240   assert.equal((await (await fetch(new URL('data.json', url))).json()).live.prompts, true);
241 });
242 
243 test('the Memory tab reads memstated, and never writes there', async () => {
244   const memstate = await startFakeMemstate();
245   process.env.IDEAMINE_MEMSTATE_URL = memstate.url;
246   const get = async (route) => (await fetch(new URL(route, url))).json();
247   try {
248     const overview = await get('api/memory/overview');
249     assert.deepEqual(overview.projects.map((p) => p.id), ['ideamine']);
250     assert.deepEqual(overview.memories.map((m) => [m.keypath, m.category, m.version]), [['task.summary.2026_09_21', 'status', 2], ['decisions.sync', 'decision', 1]]);
251     assert.equal(overview.memories[0].content, undefined); // the overview carries no texts
252     assert.equal((await get('api/memory/project?id=ideamine')).memories[0].content, 'Built the dashboard.');
253     const history = await get('api/memory/history?project=ideamine&keypath=task.summary.2026_09_21');
254     assert.deepEqual(history.versions.map((v) => v.content), ['Started the dashboard.', 'Built the dashboard.']);
255     assert.equal((await get('data.json')).live.memory, true);
256     const reads = /^(GET \/api\/v1\/projects|POST \/api\/v1\/keypaths|POST \/api\/v1\/memories\/history)$/;
257     assert.ok(memstate.requests.every((r) => reads.test(r)), memstate.requests.join(', '));
258     await memstate.close();
259     assert.match((await get('api/memory/overview')).error, /cannot reach memstated/);
260   } finally {
261     await memstate.close().catch(() => {});
262     delete process.env.IDEAMINE_MEMSTATE_URL;
263   }
264   assert.match((await get('api/memory/overview')).error, /this server shows no memories/);
265 });
266 
267 test('behind nginx, the server answers the names in serve_hosts', async () => {
268   const ask = async (host) => (await raw('GET', '/api/ping', { host })).statusCode;
269   assert.equal(await ask('10.66.0.1'), 403);
270   process.env.IDEAMINE_SERVE_HOSTS = '10.66.0.1, ideas.lan';
271   try {
272     assert.deepEqual([await ask('10.66.0.1'), await ask('IDEAS.lan'), await ask('evil.example')], [200, 200, 403]);
273   } finally {
274     delete process.env.IDEAMINE_SERVE_HOSTS;
275   }
276 });
277 
278 test('on a PC with sync on, the page reads the server, and its buttons change the archive there', async () => {
279   const memstate = await startFakeMemstate();
280   const remote = await startServerProcess({ IDEAMINE_MEMSTATE_URL: memstate.url });
281   process.env.IDEAMINE_SYNC_URL = remote.url;
282   try {
283     assert.match((await api('add', { text: 'from the page of the PC' })).message, /Saved #1 · from the page of the PC/);
284     const onServer = (await (await fetch(`${remote.url}api/db`)).json()).db;
285     assert.deepEqual(onServer.ideas.map((i) => i.text), ['from the page of the PC']);
286     const hello = { id: 'p1', at: new Date().toISOString(), prompt: 'hello' };
287     await fetch(`${remote.url}api/prompts`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ prompts: [hello] }) });
288     assert.deepEqual((await (await fetch(new URL('api/prompts', url))).json()).prompts.map((p) => p.prompt), ['hello']);
289     assert.deepEqual((await (await fetch(new URL('api/memory/overview', url))).json()).projects.map((p) => p.id), ['ideamine']);
290     const { live } = await (await fetch(new URL('data.json', url))).json();
291     assert.deepEqual([live.sync.url, live.sync.offline, live.prompts, live.memory], [remote.url, null, true, true]);
292     assert.equal((await api('ops', { ops: [] })).status, 404); // a PC is no server for other machines
293   } finally {
294     delete process.env.IDEAMINE_SYNC_URL;
295     await remote.stop();
296     await memstate.close();
297   }
298 });
299 
300 test('serve_port must be a port number', () => {
301   process.env.IDEAMINE_SERVE_PORT = 'abc';
302   try {
303     assert.throws(() => serve.port(), /serve_port must be a whole number from 1 to 65535/);
304   } finally {
305     delete process.env.IDEAMINE_SERVE_PORT;
306   }
307   assert.equal(serve.port(), 4332);
308 });
309 
310 /** A port that nothing listens on. */
311 async function freePort() {
312   const s = net.createServer();
313   await new Promise((resolve) => s.listen(0, '127.0.0.1', resolve));
314   const { port } = s.address();
315   await new Promise((resolve) => s.close(resolve));
316   return port;
317 }
318 
319 test('/ideas-web starts the server in the background, and /ideas-web off stops it', async () => {
320   process.env.IDEAMINE_SERVE_PORT = String(await freePort());
321   const address = `http://127.0.0.1:${process.env.IDEAMINE_SERVE_PORT}/`;
322   try {
323     // Several rounds, because a stopped server once went on to answer on a kept-alive connection:
324     // the next /ideas-web then said "running" and started nothing.
325     for (let round = 0; round < 3; round++) {
326       assert.equal(await handlePrompt('/ideas-web'), `ideamine web: ${address} (started)`);
327       assert.equal(await handlePrompt('/ideamine:ideas-web'), `ideamine web: ${address}`);
328       assert.equal((await fetch(new URL('data.json', address))).status, 200);
329       assert.equal(await handlePrompt('/ideas-web off'), 'ideamine web: stopped.');
330       assert.equal(await handlePrompt('/ideas-web off'), 'ideamine web: not running.');
331     }
332     assert.match(fs.readFileSync(path.join(store.home(), 'serve.log'), 'utf8'), /the dashboard with buttons runs at /);
333     assert.equal(await handlePrompt('/ideas-web something else'), null); // not a command: the skill answers
334   } finally {
335     await serve.stopRunning(); // never leave a server behind
336     delete process.env.IDEAMINE_SERVE_PORT;
337   }
338 });
339 
340 test('/ideas-web says why the server did not start', async () => {
341   process.env.IDEAMINE_SERVE_PORT = String(await freePort());
342   const node = process.execPath;
343   process.execPath = path.join(store.home(), 'no-such-node');
344   try {
345     assert.match(await serve.ensureRunning({ waitMs: 300 }), /^ideamine web did not start at .*\. The reason is in .*serve\.log\.$/);
346   } finally {
347     process.execPath = node;
348     delete process.env.IDEAMINE_SERVE_PORT;
349   }
350   assert.match(fs.readFileSync(path.join(store.home(), 'serve.log'), 'utf8'), /cannot start: .*ENOENT/);
351 });
352 
353 test('a window that failed waits for a key only when a person can press one', () => {
354   // IDEAMINE_WINDOW comes from the window of the page. Without a terminal, the error must not wait.
355   const r = spawnSync(process.execPath, [BIN, 'cat', '9'], { encoding: 'utf8', env: { ...process.env, IDEAMINE_WINDOW: '1' }, timeout: 10000 });
356   assert.equal(r.status, 1);
357   assert.match(r.stderr, /no idea #9/);
358 });