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