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