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