tests/hook.test.js (11530 bytes)
1 import assert from 'node:assert/strict'; 2 import { spawnSync } from 'node:child_process'; 3 import fs from 'node:fs'; 4 import os from 'node:os'; 5 import path from 'node:path'; 6 import { beforeEach, test } from 'node:test'; 7 import { fileURLToPath } from 'node:url'; 8 import { handlePrompt } from '../src/hook.js'; 9 import * as store from '../src/store.js'; 10 import { startFakeServer } from './fixtures/fake-server.js'; 11 import { freePort, startServerProcess } from './fixtures/server-process.js'; 12 13 const BIN = fileURLToPath(new URL('../bin/ideamine.js', import.meta.url)); 14 15 beforeEach(() => { 16 process.env.IDEAMINE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-hook-')); 17 // A pass of the watcher must never reach the real claude or the projects of this machine. 18 process.env.IDEAMINE_CLAUDE_BIN = fileURLToPath(new URL('./fixtures/fake-claude.js', import.meta.url)); 19 process.env.CLAUDE_CONFIG_DIR = process.env.IDEAMINE_HOME; 20 for (const name of ['IDEAMINE_SYNC_URL', 'IDEAMINE_PROMPT_LOG']) delete process.env[name]; 21 }); 22 23 /** Run the real hook process the way Claude Code does: JSON on stdin, decision on stdout. */ 24 function runHook(prompt, cwd = '/work/app') { 25 const input = JSON.stringify({ session_id: 's1', hook_event_name: 'UserPromptSubmit', cwd, prompt }); 26 const r = spawnSync(process.execPath, [BIN, 'hook'], { input, encoding: 'utf8', env: process.env }); 27 assert.equal(r.status, 0, r.stderr); 28 return r.stdout ? JSON.parse(r.stdout) : null; 29 } 30 31 test('/idea saves the idea and blocks the prompt so no model call happens', () => { 32 const out = runHook('/idea add a read-aloud mode #a11y'); 33 assert.equal(out.decision, 'block'); 34 assert.match(out.reason, /Saved #1 · add a read-aloud mode/); 35 assert.deepEqual(out.hookSpecificOutput, { hookEventName: 'UserPromptSubmit', suppressOriginalPrompt: true }); 36 const [idea] = store.load().ideas; 37 assert.equal(idea.source, 'hook'); 38 assert.equal(idea.session, 's1'); 39 assert.deepEqual(idea.tags, ['a11y']); 40 assert.equal(idea.project, path.resolve('/work/app')); 41 }); 42 43 test('plugin-qualified and multi-line forms work', () => { 44 assert.match(runHook('/ideamine:idea first').reason, /Saved #1/); 45 assert.match(runHook('/idea - two\n- three').reason, /Saved 2 ideas: #2, #3/); 46 }); 47 48 test('ordinary prompts, model commands, and questions pass straight through', () => { 49 const prompts = [ 50 'fix the tests', 51 '/ideasx', 52 '/idea-go 3', // a name from before 0.3.0: no command now 53 'an /idea in the middle', 54 '/ideas-go', 55 '/ideamine:ideas-go 3', 56 '/ideas-all', 57 '/ideas-sort', 58 '/ideas-cat', 59 '/ideas-ls ideas about scrolling', // not lanes: the skill searches with these words 60 '/ideas-rm the vague one', 61 '/ideas all', // with a space it is a question for the model, never a command 62 '/ideas what should I build today?', 63 ]; 64 for (const p of prompts) assert.equal(runHook(p), null, p); 65 assert.equal(store.load().ideas.length, 0); 66 }); 67 68 test('/ideas and the dashed commands list, show, and edit ideas', () => { 69 store.addIdeas(['alpha', 'beta'], { project: '/work/app' }); 70 assert.match(runHook('/ideas').reason, /2 open[\s\S]*#2 {3}beta[\s\S]*#1 {3}alpha/); 71 assert.equal(runHook('/ideas-ls').reason, runHook('/ideas').reason); 72 assert.match(runHook('/ideas-cat #1').reason, /^#1 alpha/); 73 assert.match(runHook('/ideas-cat 1 2').reason, /^#1 alpha\n[\s\S]*\n\n#2 beta\n/); 74 assert.match(runHook('/ideas-done 1 shipped it').reason, /#1 alpha → done \(note added\)/); 75 assert.match(runHook('/ideas-ls done').reason, /DONE[\s\S]*#1/); 76 assert.match(runHook('/ideas-ls -a').reason, /\[showing: all\][\s\S]*#2[\s\S]*#1/); 77 assert.match(runHook('/ideamine:ideas-reopen 1').reason, /#1 alpha → inbox/); 78 assert.equal(runHook('/ideas-cat 9').reason, 'No idea #9.'); 79 assert.equal(runHook('/ideas-cat 1 8 9').reason, 'No idea #8, #9.'); 80 assert.equal(runHook('/ideas-done 9').reason, 'No idea #9.'); 81 assert.match(runHook('/idea').reason, /^Usage[\s\S]*\/ideas-rm N/); 82 }); 83 84 test('/ideas-rm deletes ideas for good, without a model call', () => { 85 store.addIdeas(['alpha', 'beta', 'gamma', 'delta']); 86 const out = runHook('/ideas-rm 1 #3'); 87 assert.equal(out.decision, 'block'); 88 assert.match(out.reason, /Removed #1 · alpha\n.*Removed #3 · gamma$/); 89 assert.equal(runHook('/ideas-rm 2 9').reason, 'No idea #9.'); // an unknown id deletes nothing 90 assert.match(runHook('/ideamine:ideas-rm 4').reason, /Removed #4 · delta/); 91 assert.deepEqual(store.load().ideas.map((i) => i.text), ['beta']); 92 }); 93 94 test('the board names the dashed commands, and does not send you to a triage', () => { 95 store.addIdeas(['alpha']); 96 const board = runHook('/ideas').reason; 97 for (const tip of [/\/ideas-go/, /\/ideas-all/, /\/ideas-rm N/]) assert.match(board, tip); 98 assert.doesNotMatch(board, /\/idea-triage|\/ideas-sort/); 99 }); 100 101 test('/ideas-watch turns the watcher on and off, with no model call', () => { 102 const on = runHook('/ideas-watch'); 103 assert.equal(on.decision, 'block'); 104 assert.match(on.reason, /^ideamine watch: on since /); 105 assert.match(runHook('/ideas-watch').reason, /^ideamine watch: on since /); 106 assert.match(runHook('/ideamine:ideas-watch off').reason, /^ideamine watch: off/); 107 assert.equal(JSON.parse(fs.readFileSync(path.join(store.home(), 'watch.json'), 'utf8')).on, false); 108 }); 109 110 test('while the watcher is on, a saved idea is triaged in the background', async () => { 111 runHook('/ideas-watch'); 112 runHook('/idea make the scroller faster'); 113 // The hook returns at once. A separate process triages the idea with the stand-in claude. 114 const deadline = Date.now() + 20000; 115 while (store.load().ideas[0].status === 'inbox' && Date.now() < deadline) await new Promise((r) => setTimeout(r, 200)); 116 assert.equal(store.findIdea(store.load(), 1).triage?.by, 'haiku (headless)'); 117 }); 118 119 test('handlePrompt filters by project with "here"', async () => { 120 store.addIdeas(['mine'], { project: '/a' }); 121 store.addIdeas(['theirs'], { project: '/b' }); 122 const board = await handlePrompt('/ideas-ls here', { cwd: '/a' }); 123 assert.match(board, /mine/); 124 assert.doesNotMatch(board, /theirs/); 125 }); 126 127 test('when the archive is unreadable the prompt goes through instead of being lost', () => { 128 fs.mkdirSync(store.home(), { recursive: true }); 129 fs.writeFileSync(store.dbPath(), 'not json'); 130 const r = spawnSync(process.execPath, [BIN, 'hook'], { 131 input: JSON.stringify({ prompt: '/idea keep me', cwd: '/x' }), 132 encoding: 'utf8', 133 env: process.env, 134 }); 135 assert.equal(r.status, 0); 136 assert.equal(r.stdout, ''); 137 assert.match(r.stderr, /not valid JSON/); 138 }); 139 140 test('/ideas-find searches by meaning and /ideas-groups groups by meaning, with no model call', async () => { 141 const server = await startFakeServer(); 142 process.env.IDEAMINE_EMBED_URL = `${server.url}/v1`; 143 process.env.IDEAMINE_GROUP_THRESHOLD = '0.5'; 144 try { 145 store.addIdeas(['subtitles for audiobooks', 'subtitles for audiobooks on android', 'a tor exit relay']); 146 const found = await handlePrompt('/ideas-find audiobooks with subtitles'); 147 assert.match(found, /^ideas like "audiobooks with subtitles", by meaning\n.*#1 .*\n.*#2 /); 148 assert.doesNotMatch(found, /tor exit/); 149 const groups = await handlePrompt('/ideas-groups'); 150 assert.match(groups, /^ideamine groups: 1 group of open ideas/); 151 assert.match(groups, /IN NO GROUP\n.*#3 /); 152 assert.equal(await handlePrompt('/ideas-groups what is this'), null); // a question: the skill answers 153 } finally { 154 await server.close(); 155 delete process.env.IDEAMINE_EMBED_URL; 156 delete process.env.IDEAMINE_GROUP_THRESHOLD; 157 } 158 }); 159 160 const serverIdeas = async (url) => (await (await fetch(`${url}api/db`)).json()).db.ideas.map((i) => i.text); 161 162 test('with sync on, /idea saves on the server, /ideas shows every machine, and an idea waits when the server is away', async () => { 163 const remote = await startServerProcess(); 164 try { 165 process.env.IDEAMINE_SYNC_URL = remote.url; 166 assert.match(runHook('/idea shared by every machine').reason, /Saved #1 · shared by every machine/); 167 assert.deepEqual(await serverIdeas(remote.url), ['shared by every machine']); 168 // Another machine adds an idea. The next /ideas here shows it. 169 const laptop = { ops: [{ op: 'add', texts: ['from the laptop'], oid: 'laptop-1' }] }; 170 await fetch(`${remote.url}api/ops`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(laptop) }); 171 assert.match(runHook('/ideas').reason, /#2 {3}from the laptop[\s\S]*#1 {3}shared by every machine/); 172 173 process.env.IDEAMINE_SYNC_URL = `http://127.0.0.1:${await freePort()}/`; // the tunnel is down 174 assert.match(runHook('/idea written offline').reason, /^💡 Saved here: written offline\. The ideamine server does not answer, so it gets the idea later \(1 waiting\)\.$/); 175 assert.match(runHook('/ideas').reason, /^\(The ideamine server does not answer\. This is the copy from \d{4}-/); 176 177 process.env.IDEAMINE_SYNC_URL = remote.url; // the tunnel is back 178 assert.match(runHook('/ideas').reason, /written offline/); 179 assert.deepEqual((await serverIdeas(remote.url)).sort(), ['from the laptop', 'shared by every machine', 'written offline']); 180 } finally { 181 await remote.stop(); 182 } 183 }); 184 185 test('/ideas-sync turns sync on, keeps the archive that was here, and shows the state', async () => { 186 const remote = await startServerProcess(); 187 try { 188 store.addIdeas(['only here']); 189 const on = runHook(`/ideas-sync ${remote.url}`).reason; 190 assert.match(on, /^ideamine sync: http:\/\/127\.0\.0\.1:\d+\/\nlast sync \d{4}-\d{2}-\d{2} \d{2}:\d{2}\.\n/); // a new archive has no revision yet 191 assert.match(on, /The archive that was here is in .*ideas\.before-sync-\d{4}-\d{2}-\d{2}\.json\.$/); 192 assert.match(runHook('/ideamine:ideas-sync').reason, /^ideamine sync: http/); 193 assert.match(runHook('/ideas-sync 10.66.0.1').reason, /^ideamine sync: "10\.66\.0\.1" is not an http:\/\/ or https:\/\/ address\. Usage/); 194 assert.match(runHook('/ideas-sync off').reason, /^ideamine sync: off/); 195 } finally { 196 await remote.stop(); 197 } 198 }); 199 200 test('with the prompt log on, each prompt goes to the server in the background', async () => { 201 const remote = await startServerProcess(); 202 try { 203 process.env.IDEAMINE_SYNC_URL = remote.url; 204 process.env.IDEAMINE_PROMPT_LOG = 'on'; 205 assert.equal(runHook('please fix the flaky test'), null); // an ordinary prompt still goes to the model 206 let prompts = []; 207 for (const deadline = Date.now() + 20000; !prompts.length && Date.now() < deadline; await new Promise((r) => setTimeout(r, 200))) { 208 prompts = (await (await fetch(`${remote.url}api/prompts`)).json()).prompts; 209 } 210 assert.deepEqual(prompts.map((p) => [p.prompt, p.session, p.cwd]), [['please fix the flaky test', 's1', '/work/app']]); 211 } finally { 212 await remote.stop(); 213 } 214 }); 215 216 test('/ideas-find falls back to words when the embedding server is away', async () => { 217 const closed = await startFakeServer(); 218 await closed.close(); // nothing listens on its port now 219 process.env.IDEAMINE_EMBED_URL = `${closed.url}/v1`; 220 try { 221 store.addIdeas(['subtitles for audiobooks', 'dark mode']); 222 const out = runHook('/ideas-find subtitles'); 223 assert.equal(out.decision, 'block'); 224 assert.match(out.reason, /by words, because search by meaning is not available \(cannot reach/); 225 assert.match(out.reason, /#1 /); 226 assert.doesNotMatch(out.reason, /#2 /); 227 } finally { 228 delete process.env.IDEAMINE_EMBED_URL; 229 } 230 });