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 (25628 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', 'IDEAMINE_COOP_URL', 'IDEAMINE_COOP_TOKEN']) 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:4333', 'http://localhost:4332/'));
82   fc.assert(
83     fc.property(origins, (origin) => {
84       fc.pre(origin !== 'http://127.0.0.1:4332' && origin !== 'https://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   // nginx serves the page over HTTPS too (for the phone). Its buttons and drags must work there.
104   assert.equal(serve.allowed(post({ host: 'mem.equwal.com', origin: 'https://mem.equwal.com' }), port, ['mem.equwal.com']), true);
105   assert.equal(serve.allowed(post({ host: 'mem.equwal.com', origin: 'https://evil.example' }), port, ['mem.equwal.com']), false);
106   fc.assert(
107     fc.property(fc.string(), (host) => {
108       assert.equal(serve.allowed({ method: 'GET', host }, port, hosts), ['127.0.0.1:4332', 'localhost:4332', '10.66.0.1'].includes(host.toLowerCase()));
109     }),
110   );
111 });
112 
113 test('requests from other pages change nothing', async () => {
114   const text = await fetch(new URL('api/add', url), { method: 'POST', headers: { 'content-type': 'text/plain' }, body: '{"text":"x"}' });
115   assert.equal(text.status, 403); // a form or a no-cors fetch of another page
116   assert.equal((await api('add', { text: 'x' }, { origin: 'http://evil.example' })).status, 403);
117   assert.equal((await raw('GET', '/data.json', { host: `evil.example:${server.address().port}` })).statusCode, 403); // DNS rebinding
118   const preflight = await raw('OPTIONS', '/api/add', { origin: 'http://evil.example', 'access-control-request-method': 'POST' });
119   assert.equal(preflight.statusCode, 403);
120   assert.equal(preflight.headers['access-control-allow-origin'], undefined);
121   assert.equal(store.load().ideas.length, 0);
122   assert.equal(changes, 0);
123   assert.equal((await api('nope')).status, 404);
124   assert.equal((await api('add', 'not json')).error, 'the request is not valid JSON');
125 });
126 
127 test('the page comes with live data, and no other page can frame it', async () => {
128   store.addIdeas(['alpha']);
129   const page = await fetch(url);
130   assert.equal(page.status, 200);
131   assert.match(page.headers.get('content-security-policy'), /frame-ancestors 'none'/);
132   assert.match(await page.text(), /id="actions"/);
133   const data = await (await fetch(new URL('data.json', url))).json();
134   assert.equal(data.version, 1);
135   assert.equal(data.ideas[0].key, 'IDEA-1');
136   assert.deepEqual(data.live, {
137     note: '',
138     window: process.platform === 'win32',
139     claude: true, // the stand-in claude of the tests starts
140     watch: { on: false, status: data.live.watch.status },
141     sync: null,
142     prompts: false,
143     memory: false,
144     usage: false, // no transcripts of Claude Code in the test home
145     coop: false, // no coop hub: a drop in Doing only moves the card
146   });
147   assert.match(data.live.watch.status, /^ideamine watch: off/);
148 });
149 
150 test('add, update, and rm do what /idea, /ideas-done, /ideas-reopen, and /ideas-rm do', async () => {
151   const added = await api('add', { text: '- alpha #web\n- beta' });
152   assert.match(added.message, /Saved 2 ideas: #1, #2/);
153   const [alpha] = store.load().ideas;
154   assert.deepEqual([alpha.source, alpha.tags, alpha.project], ['web', ['web'], null]);
155 
156   assert.equal((await api('update', { id: 1, status: 'done', note: 'shipped' })).message, '✓ #1 alpha → done · note added');
157   assert.deepEqual(store.findIdea(store.load(), 1).notes.map((n) => n.text), ['shipped']);
158   assert.equal((await api('update', { id: 1, status: 'reopen' })).message, '✓ #1 alpha → inbox');
159   assert.equal((await api('update', { id: 2, status: 'doing' })).message, '✓ #2 beta → doing');
160   assert.ok(store.findIdea(store.load(), 2).started);
161   assert.equal((await api('update', { id: 2, model: 'opus' })).message, '✓ #2 beta → doing · model opus');
162   assert.equal((await api('update', { id: 2, status: 'dropped' })).message, '✓ #2 beta → dropped');
163   assert.equal((await api('update', { id: 2 })).error, 'nothing to change');
164 
165   const missing = await api('rm', { ids: [1, 9] });
166   assert.deepEqual([missing.status, missing.error], [400, 'no idea #9']);
167   assert.equal(store.load().ideas.length, 2); // an unknown id deletes nothing
168   assert.equal((await api('rm', { ids: [1] })).message, 'Removed #1 · alpha');
169   assert.deepEqual(store.load().ideas.map((i) => i.id), [2]);
170   assert.equal(changes, 7); // each command that changed the archive, and no failed one
171 });
172 
173 test('sort triages the inbox with one headless call, like /ideas-sort', async () => {
174   await api('add', { text: '- alpha\n- beta' });
175   const out = await api('sort');
176   assert.match(out.message, /^Saved 2 verdicts: 2 do · 0 maybe · 0 skip/);
177   assert.ok(store.load().ideas.every((i) => i.status === 'triaged'));
178   assert.equal((await api('sort')).message, 'Nothing to triage: the inbox is empty.');
179 });
180 
181 test('go triages a new idea, then opens a window that runs `ideamine go N` in the project', async () => {
182   const project = fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-project-'));
183   store.addIdeas(['alpha'], { project });
184   store.addIdeas(['beta'], { project: path.join(project, 'gone') });
185   const out = await api('go', { id: 1 });
186   assert.match(out.message, /^Saved 1 verdict: 1 do[\s\S]*\n\nOpened Claude Code \(sonnet\) in .* for #1 · Idea 1$/);
187   assert.deepEqual(opened, [{ args: [BIN, 'go', '1'], cwd: project }]);
188   assert.equal(store.findIdea(store.load(), 1).status, 'doing');
189 
190   // A folder that is gone: the build starts in the home folder. Without an id: the first in the queue.
191   await api('go');
192   assert.deepEqual(opened[1], { args: [BIN, 'go', '2'], cwd: os.homedir() });
193   const none = await api('go');
194   assert.deepEqual([none.status, none.error], [400, 'Nothing is ready to build. Triage the inbox first.']);
195 });
196 
197 test('a window that cannot open changes nothing', async () => {
198   await new Promise((resolve) => server.close(resolve));
199   ({ server, url } = await serve.start({ port: 0, open: async () => { throw new Error('no terminal'); }, afterChange: () => changes++ }));
200   store.addIdeas(['alpha']);
201   store.applyTriage([{ id: 1, verdict: 'do', impact: 3, size: 's', model: 'haiku', brief: 'b' }]);
202   const out = await api('go', { id: 1 });
203   assert.deepEqual([out.status, out.error], [400, 'no terminal']);
204   assert.equal(store.findIdea(store.load(), 1).status, 'triaged');
205 });
206 
207 test('ask answers a question about the ideas with one headless call, like /ideas <question>', async () => {
208   await api('add', { text: 'alpha' });
209   const before = changes;
210   assert.equal((await api('ask', { question: 'which first?' })).message, '#1 fits "which first?".');
211   assert.equal((await api('ask', { question: ' ' })).error, 'ask a question');
212   assert.equal(changes, before); // a question changes nothing
213 });
214 
215 test('watch turns the watcher on and off, like /ideas-watch', async () => {
216   assert.match((await api('watch', { on: true })).message, /^ideamine watch: on since /);
217   assert.equal(JSON.parse(fs.readFileSync(path.join(store.home(), 'watch.json'), 'utf8')).on, true);
218   assert.match((await api('watch', { on: false })).message, /^ideamine watch: off/);
219 });
220 
221 test('search by meaning on the page goes through the server to the embedding server', async () => {
222   const res = await fetch(new URL('v1/embeddings', url), {
223     method: 'POST',
224     headers: { 'content-type': 'application/json' },
225     body: JSON.stringify({ model: 'nomic-embed-text', input: ['search_query: subtitles'] }),
226   });
227   assert.equal(res.status, 200);
228   assert.equal((await res.json()).data[0].embedding.length, 64);
229   assert.deepEqual(embedServer.inputs, ['search_query: subtitles']);
230 });
231 
232 test('the page comes with what a phone needs: a manifest, icons, and a service worker', async () => {
233   const get = (route) => fetch(new URL(route, url));
234   const manifest = await get('manifest.webmanifest');
235   assert.equal(manifest.headers.get('content-type'), 'application/manifest+json; charset=utf-8');
236   const app = await manifest.json();
237   assert.equal(app.display, 'standalone');
238   assert.deepEqual(app.icons.map((i) => i.sizes), ['192x192', '512x512', '512x512']);
239   assert.equal(app.share_target.params.text, 'text'); // a phone can share a text into the composer
240   const worker = await get('sw.js');
241   assert.match(worker.headers.get('content-type'), /javascript/);
242   assert.equal(worker.headers.get('cache-control'), 'no-store');
243   assert.match(await worker.text(), /addEventListener\('fetch'/);
244   for (const [name, type] of [['icon-192.png', 'image/png'], ['icon-512.png', 'image/png'], ['apple-touch-icon.png', 'image/png']]) {
245     const res = await get(name);
246     assert.equal(res.headers.get('content-type'), type);
247     const bytes = Buffer.from(await res.arrayBuffer());
248     assert.deepEqual([...bytes.subarray(1, 4)], [0x50, 0x4e, 0x47], `${name} is a PNG`);
249   }
250   const page = await (await get('index.html')).text();
251   assert.match(page, /<link rel="manifest" href="manifest\.webmanifest">/);
252   assert.match(page, /apple-mobile-web-app-capable/);
253 });
254 
255 test('the server role keeps the prompts of every machine, each once, and gives them by days', async () => {
256   const recent = new Date(Date.now() - 3600000).toISOString();
257   const prompts = [
258     { id: 'a', at: recent, host: 'pc', session: 's1', cwd: 'C:\\work\\app', prompt: 'fix the tests' },
259     { id: 'b', at: '2026-01-01T00:00:00.000Z', host: 'mac', session: 's2', cwd: '/Users/me/app', prompt: 'an old one' },
260     { id: 'bad', prompt: 'no time' },
261   ];
262   const first = await api('prompts', { prompts });
263   assert.deepEqual([first.added, first.skipped], [2, 1]);
264   assert.equal((await api('prompts', { prompts })).added, 0); // the same prompts again
265   const get = async (route) => (await (await fetch(new URL(route, url))).json()).prompts;
266   assert.deepEqual((await get('api/prompts')).map((p) => p.id), ['b', 'a']); // oldest first
267   assert.deepEqual((await get('api/prompts?days=1')).map((p) => p.prompt), ['fix the tests']);
268   assert.equal((await (await fetch(new URL('data.json', url))).json()).live.prompts, true);
269 });
270 
271 test('the Memory tab reads memstated, and never writes there', async () => {
272   const memstate = await startFakeMemstate();
273   process.env.IDEAMINE_MEMSTATE_URL = memstate.url;
274   const get = async (route) => (await fetch(new URL(route, url))).json();
275   try {
276     const overview = await get('api/memory/overview');
277     assert.deepEqual(overview.projects.map((p) => p.id), ['ideamine']);
278     assert.deepEqual(overview.memories.map((m) => [m.keypath, m.category, m.version]), [['task.summary.2026_09_21', 'status', 2], ['decisions.sync', 'decision', 1]]);
279     assert.equal(overview.memories[0].content, undefined); // the overview carries no texts
280     assert.equal((await get('api/memory/project?id=ideamine')).memories[0].content, 'Built the dashboard.');
281     const history = await get('api/memory/history?project=ideamine&keypath=task.summary.2026_09_21');
282     assert.deepEqual(history.versions.map((v) => v.content), ['Started the dashboard.', 'Built the dashboard.']);
283     assert.equal((await get('data.json')).live.memory, true);
284     const reads = /^(GET \/api\/v1\/projects|POST \/api\/v1\/keypaths|POST \/api\/v1\/memories\/history)$/;
285     assert.ok(memstate.requests.every((r) => reads.test(r)), memstate.requests.join(', '));
286     await memstate.close();
287     assert.match((await get('api/memory/overview')).error, /cannot reach memstated/);
288   } finally {
289     await memstate.close().catch(() => {});
290     delete process.env.IDEAMINE_MEMSTATE_URL;
291   }
292   assert.match((await get('api/memory/overview')).error, /this server shows no memories/);
293 });
294 
295 test('behind nginx, the server answers the names in serve_hosts', async () => {
296   const ask = async (host) => (await raw('GET', '/api/ping', { host })).statusCode;
297   assert.equal(await ask('10.66.0.1'), 403);
298   process.env.IDEAMINE_SERVE_HOSTS = '10.66.0.1, ideas.lan';
299   try {
300     assert.deepEqual([await ask('10.66.0.1'), await ask('IDEAS.lan'), await ask('evil.example')], [200, 200, 403]);
301   } finally {
302     delete process.env.IDEAMINE_SERVE_HOSTS;
303   }
304 });
305 
306 test('on a PC with sync on, the page reads the server, and its buttons change the archive there', async () => {
307   const memstate = await startFakeMemstate();
308   const remote = await startServerProcess({ IDEAMINE_MEMSTATE_URL: memstate.url });
309   process.env.IDEAMINE_SYNC_URL = remote.url;
310   try {
311     assert.match((await api('add', { text: 'from the page of the PC' })).message, /Saved #1 · from the page of the PC/);
312     const onServer = (await (await fetch(`${remote.url}api/db`)).json()).db;
313     assert.deepEqual(onServer.ideas.map((i) => i.text), ['from the page of the PC']);
314     const hello = { id: 'p1', at: new Date().toISOString(), prompt: 'hello' };
315     await fetch(`${remote.url}api/prompts`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ prompts: [hello] }) });
316     assert.deepEqual((await (await fetch(new URL('api/prompts', url))).json()).prompts.map((p) => p.prompt), ['hello']);
317     assert.deepEqual((await (await fetch(new URL('api/memory/overview', url))).json()).projects.map((p) => p.id), ['ideamine']);
318     const { live } = await (await fetch(new URL('data.json', url))).json();
319     assert.deepEqual([live.sync.url, live.sync.offline, live.prompts, live.memory], [remote.url, null, true, true]);
320     assert.equal((await api('ops', { ops: [] })).status, 404); // a PC is no server for other machines
321   } finally {
322     delete process.env.IDEAMINE_SYNC_URL;
323     await remote.stop();
324     await memstate.close();
325   }
326 });
327 
328 test('serve_port must be a port number', () => {
329   process.env.IDEAMINE_SERVE_PORT = 'abc';
330   try {
331     assert.throws(() => serve.port(), /serve_port must be a whole number from 1 to 65535/);
332   } finally {
333     delete process.env.IDEAMINE_SERVE_PORT;
334   }
335   assert.equal(serve.port(), 4332);
336 });
337 
338 /** A port that nothing listens on. */
339 async function freePort() {
340   const s = net.createServer();
341   await new Promise((resolve) => s.listen(0, '127.0.0.1', resolve));
342   const { port } = s.address();
343   await new Promise((resolve) => s.close(resolve));
344   return port;
345 }
346 
347 test('/ideas-web starts the server in the background, and /ideas-web off stops it', async () => {
348   process.env.IDEAMINE_SERVE_PORT = String(await freePort());
349   const address = `http://127.0.0.1:${process.env.IDEAMINE_SERVE_PORT}/`;
350   try {
351     // Several rounds, because a stopped server once went on to answer on a kept-alive connection:
352     // the next /ideas-web then said "running" and started nothing.
353     for (let round = 0; round < 3; round++) {
354       assert.equal(await handlePrompt('/ideas-web'), `ideamine web: ${address} (started)`);
355       assert.equal(await handlePrompt('/ideamine:ideas-web'), `ideamine web: ${address}`);
356       assert.equal((await fetch(new URL('data.json', address))).status, 200);
357       assert.equal(await handlePrompt('/ideas-web off'), 'ideamine web: stopped.');
358       assert.equal(await handlePrompt('/ideas-web off'), 'ideamine web: not running.');
359     }
360     assert.match(fs.readFileSync(path.join(store.home(), 'serve.log'), 'utf8'), /the dashboard with buttons runs at /);
361     assert.equal(await handlePrompt('/ideas-web something else'), null); // not a command: the skill answers
362   } finally {
363     await serve.stopRunning(); // never leave a server behind
364     delete process.env.IDEAMINE_SERVE_PORT;
365   }
366 });
367 
368 test('/ideas-web says why the server did not start', async () => {
369   process.env.IDEAMINE_SERVE_PORT = String(await freePort());
370   const node = process.execPath;
371   process.execPath = path.join(store.home(), 'no-such-node');
372   try {
373     assert.match(await serve.ensureRunning({ waitMs: 300 }), /^ideamine web did not start at .*\. The reason is in .*serve\.log\.$/);
374   } finally {
375     process.execPath = node;
376     delete process.env.IDEAMINE_SERVE_PORT;
377   }
378   assert.match(fs.readFileSync(path.join(store.home(), 'serve.log'), 'utf8'), /cannot start: .*ENOENT/);
379 });
380 
381 test('a window that failed waits for a key only when a person can press one', () => {
382   // IDEAMINE_WINDOW comes from the window of the page. Without a terminal, the error must not wait.
383   const r = spawnSync(process.execPath, [BIN, 'cat', '9'], { encoding: 'utf8', env: { ...process.env, IDEAMINE_WINDOW: '1' }, timeout: 10000 });
384   assert.equal(r.status, 1);
385   assert.match(r.stderr, /no idea #9/);
386 });
387 
388 test('a drag to the Do, Maybe, or Skip lane changes only the verdict', async () => {
389   await api('add', { text: '- alpha' });
390   store.applyTriage([{ id: 1, verdict: 'do', impact: 5, size: 'l', model: 'opus', why: 'because', brief: 'plan' }]);
391 
392   assert.equal((await api('verdict', { id: 1, verdict: 'maybe' })).message, '✓ #1 alpha → maybe');
393   const idea = store.findIdea(store.load(), 1);
394   assert.equal(store.lane(idea), 'maybe');
395   // The triage of Claude stays. Only the lane of the card moves.
396   assert.deepEqual(
397     [idea.triage.impact, idea.triage.size, idea.triage.model, idea.triage.why, idea.triage.brief, idea.triage.by],
398     [5, 'l', 'opus', 'because', 'plan', 'web'],
399   );
400 
401   assert.equal((await api('verdict', { id: 1, verdict: 'nope' })).error, 'verdict must be one of do/maybe/skip');
402   assert.equal((await api('verdict', { id: 9, verdict: 'do' })).error, 'no idea #9');
403 });
404 
405 // dropCommand of the page, run against a page state that has no Claude Code (the server role).
406 const pageSource = fs.readFileSync(new URL('../dashboard/index.html', import.meta.url), 'utf8');
407 const dropCommand = new Function('state', `${/function dropCommand[\s\S]*?\n}\n/.exec(pageSource)[0]}; return dropCommand;`)({ data: { live: {} } });
408 
409 /** Put idea `id` in `lane` the way the CLI and Claude do it. `triaged`: it got a verdict first. */
410 function seed(id, lane, triaged) {
411   if (['do', 'maybe', 'skip'].includes(lane)) return store.applyTriage([{ id, verdict: lane }]);
412   if (triaged) store.applyTriage([{ id, verdict: 'do' }]);
413   store.updateIdea(id, { status: lane });
414 }
415 
416 /** A POST from the page at https://mem.equwal.com, the way nginx passes it on. */
417 function httpsPagePost(action, body) {
418   return new Promise((resolve, reject) => {
419     const host = 'mem.equwal.com';
420     const req = http.request(new URL(`api/${action}`, url), { method: 'POST', headers: { host, origin: `https://${host}`, 'content-type': 'application/json' } }, (res) => {
421       const chunks = [];
422       res.on('data', (c) => chunks.push(c));
423       res.on('end', () => resolve({ status: res.statusCode, ...JSON.parse(Buffer.concat(chunks).toString()) }));
424     });
425     req.on('error', reject);
426     req.end(JSON.stringify(body));
427   });
428 }
429 
430 /** Drop idea `id` in `lane` on the board. Resolves to the lane that the idea is in afterwards. */
431 async function drop(id, lane, post = api) {
432   const idea = { ...store.findIdea(store.load(), id) };
433   idea.lane = store.lane(idea);
434   const command = dropCommand(idea, lane);
435   const out = await post(command.action, command.body);
436   assert.equal(out.ok, true, out.error);
437   return store.lane(store.findIdea(store.load(), id));
438 }
439 
440 test('on the HTTPS page, a drag from Do to Doing starts the idea, and a drag back to Do queues it again', async () => {
441   process.env.IDEAMINE_SERVE_HOSTS = 'mem.equwal.com';
442   try {
443     await api('add', { text: '- alpha' });
444     seed(1, 'do', true);
445     assert.equal(await drop(1, 'doing', httpsPagePost), 'doing');
446     assert.equal(await drop(1, 'do', httpsPagePost), 'do');
447     assert.equal(await drop(1, 'done', httpsPagePost), 'done');
448     assert.equal(await drop(1, 'maybe', httpsPagePost), 'maybe');
449   } finally {
450     delete process.env.IDEAMINE_SERVE_HOSTS;
451   }
452 });
453 
454 test('a drag moves a card from any lane to any other lane: a property over every pair', async () => {
455   await fc.assert(
456     fc.asyncProperty(fc.constantFrom(...store.LANES), fc.constantFrom(...store.LANES), fc.boolean(), async (from, to, triaged) => {
457       fc.pre(from !== to);
458       const [{ idea }] = store.addIdeas('an idea');
459       seed(idea.id, from, triaged);
460       assert.equal(store.lane(store.findIdea(store.load(), idea.id)), from);
461       assert.equal(await drop(idea.id, to), to, `${from} → ${to}`);
462     }),
463     { numRuns: 200 },
464   );
465 });
466 
467 test('the server keeps the token use of each machine and puts a price on it', async () => {
468   const row = { day: '2026-09-22', project: 'C:\work', model: 'claude-opus-5', messages: 1, input: 0, output: 1000000, read: 0, write5m: 0, write1h: 0 };
469   assert.equal((await api('usage', { machine: 'm1', host: 'pc1', rows: [row] })).rows, 1);
470   const first = await (await fetch(new URL('api/usage', url))).json();
471   assert.equal(first.rows.length, 1);
472   assert.equal(first.rows[0].cost, 25); // 1M output tokens on Opus 5
473   assert.equal(first.rows[0].host, 'pc1');
474 
475   // The same machine sends again: the new rows take the place of the rows from before.
476   assert.equal((await api('usage', { machine: 'm1', host: 'pc1', rows: [row, { ...row, day: '2026-09-21' }] })).rows, 2);
477   assert.equal((await api('usage', { machine: 'm2', host: 'pc2', rows: [row] })).machines, 2);
478   assert.equal((await (await fetch(new URL('api/usage', url))).json()).rows.length, 3);
479 
480   assert.equal((await api('usage', { rows: [] })).error, 'a machine id and its rows must come together');
481   assert.equal((await api('usage', { machine: 'm3', rows: [{ day: 'not a day' }] })).rows, 0); // a bad row is left out
482 });