tests/mcp.test.js (8231 bytes)
1 import assert from 'node:assert/strict'; 2 import { spawn } from 'node:child_process'; 3 import fs from 'node:fs'; 4 import os from 'node:os'; 5 import path from 'node:path'; 6 import readline from 'node:readline'; 7 import { after, before, test } from 'node:test'; 8 import { fileURLToPath } from 'node:url'; 9 10 const BIN = fileURLToPath(new URL('../bin/ideamine.js', import.meta.url)); 11 const project = fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-proj-')); 12 let server; 13 let nextId = 1; 14 const pending = new Map(); 15 16 function request(method, params = {}) { 17 const id = nextId++; 18 server.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n'); 19 return new Promise((resolve) => pending.set(id, resolve)); 20 } 21 22 async function call(name, args = {}) { 23 const res = await request('tools/call', { name, arguments: args }); 24 return { text: res.result.content[0].text, isError: !!res.result.isError }; 25 } 26 27 before(async () => { 28 const env = { 29 ...process.env, 30 IDEAMINE_HOME: fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-mcp-')), 31 IDEAMINE_CLAUDE_BIN: fileURLToPath(new URL('./fixtures/fake-claude.js', import.meta.url)), 32 }; 33 env.CLAUDE_CONFIG_DIR = env.IDEAMINE_HOME; // no .claude.json: the projects of this machine stay out of the tests 34 server = spawn(process.execPath, [BIN, 'mcp'], { cwd: project, env, stdio: ['pipe', 'pipe', 'inherit'] }); 35 readline.createInterface({ input: server.stdout }).on('line', (line) => { 36 const msg = JSON.parse(line); // stdout must carry nothing but JSON-RPC 37 pending.get(msg.id)?.(msg); 38 }); 39 const init = await request('initialize', { 40 protocolVersion: '2025-06-18', 41 capabilities: {}, 42 clientInfo: { name: 'test', version: '0' }, 43 }); 44 assert.equal(init.result.protocolVersion, '2025-06-18'); 45 assert.equal(init.result.serverInfo.name, 'ideamine'); 46 assert.ok(init.result.capabilities.tools); 47 assert.ok(init.result.capabilities.prompts); 48 server.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n'); 49 }); 50 51 after(() => server.kill()); 52 53 test('lists the six tools with schemas', async () => { 54 const res = await request('tools/list'); 55 assert.deepEqual(res.result.tools.map((t) => t.name), ['idea_add', 'idea_list', 'idea_triage', 'idea_update', 'idea_next', 'idea_remove']); 56 for (const t of res.result.tools) assert.equal(t.inputSchema.type, 'object'); 57 }); 58 59 test('full loop: add, triage, next, update', async () => { 60 const added = await call('idea_add', { text: '- cache the API responses\n- rename helpers.js to utils.js' }); 61 assert.match(added.text, /Saved 2 ideas: #1, #2/); 62 63 const work = await call('idea_triage'); 64 assert.match(work.text, /Ideas to triage \(2\)/); 65 assert.match(work.text, /haiku/); 66 assert.match(work.text, new RegExp(`project: ${path.basename(project)}`)); 67 68 const saved = await call('idea_triage', { 69 by: 'test-model', 70 verdicts: [ 71 { id: 1, verdict: 'do', impact: 4, size: 'm', model: 'sonnet', title: 'Cache API responses', why: 'slow pages', brief: 'Add an LRU cache.' }, 72 { id: 2, verdict: 'do', impact: 2, size: 'xs', model: 'haiku', title: 'Rename helpers.js', why: 'trivial', brief: 'Rename and fix imports.' }, 73 ], 74 }); 75 assert.match(saved.text, /Saved 2 verdicts: 2 do · 0 maybe · 0 skip/); 76 77 const next = await call('idea_next'); 78 assert.match(next.text, /^#2 Rename helpers\.js/); // 2/1 value per effort beats 4/3 79 assert.match(next.text, /recommended model: haiku/); 80 81 const upd = await call('idea_update', { id: 2, status: 'done', note: 'renamed' }); 82 assert.match(upd.text, /Updated #2 Rename helpers\.js · done · note added/); 83 84 const board = await call('idea_list'); 85 assert.match(board.text, /1 open \(1 do\) · 1 done/); 86 const one = await call('idea_list', { id: 1 }); 87 assert.match(one.text, /brief {4}Add an LRU cache\./); 88 assert.match(one.text, /triaged .* by test-model/); 89 }); 90 91 test('headless triage through the tool, while other requests stay responsive', async () => { 92 await call('idea_add', { text: '- idea x\n- idea y' }); 93 const [triaged, pong] = await Promise.all([call('idea_triage', { headless: true }), request('ping')]); 94 assert.deepEqual(pong.result, {}); 95 assert.match(triaged.text, /Saved 2 verdicts: 2 do · 0 maybe · 0 skip \(sonnet, 321 in \/ 45 out tokens\)/); 96 assert.match((await call('idea_triage', { headless: true })).text, /inbox is empty/); 97 }); 98 99 test('tool errors come back as isError results, not protocol errors', async () => { 100 const res = await call('idea_update', { id: 999, status: 'done' }); 101 assert.equal(res.isError, true); 102 assert.match(res.text, /no idea #999/); 103 const unknown = await request('tools/call', { name: 'nope', arguments: {} }); 104 assert.equal(unknown.error.code, -32602); 105 const method = await request('does/not/exist'); 106 assert.equal(method.error.code, -32601); 107 }); 108 109 test('prompts mirror the skills', async () => { 110 const list = await request('prompts/list'); 111 const names = list.result.prompts.map((p) => p.name); 112 assert.deepEqual(names, ['idea', 'ideas', 'ideas-ls', 'ideas-cat', 'ideas-rm', 'ideas-find', 'ideas-groups', 'ideas-done', 'ideas-reopen', 'ideas-go', 'ideas-all', 'ideas-sort', 'ideas-watch', 'ideas-web', 'ideas-sync']); 113 // One prompt for each skill, so that other MCP clients get the same commands as the plugin. 114 assert.deepEqual([...names].sort(), fs.readdirSync(new URL('../skills', import.meta.url)).sort()); 115 const go = await request('prompts/get', { name: 'ideas-go', arguments: { id: '12' } }); 116 assert.match(go.result.messages[0].content.text, /Requested idea: 12\b[\s\S]*idea_next[\s\S]*run_in_background/); 117 const got = await request('prompts/get', { name: 'idea', arguments: { text: 'teleport the cat' } }); 118 const text = got.result.messages[0].content.text; 119 assert.match(text, /teleport the cat/); 120 assert.doesNotMatch(text, /^---/); 121 assert.match(text, /idea_add/); 122 }); 123 124 // The tests below continue from the archive that the tests above left: #1, #3, #4 do; #2 done. 125 126 test('idea_list full shows the queue in full, so /ideas all can judge each idea; idea_remove deletes ideas', async () => { 127 assert.match((await call('idea_list', { full: true })).text, /^#3 Idea 3\n[^]*\n\n#4 Idea 4\n[^]*\n\n#1 Cache API responses\n/); 128 const none = await call('idea_remove', { ids: [] }); 129 assert.equal(none.isError, true); 130 assert.match((await call('idea_remove', { ids: [1, 3, 4] })).text, /^Removed 3 ideas\.\n\n#1 Cache API responses\n[^]*\n\n#3 Idea 3\n/); 131 assert.match((await call('idea_list')).text, /^ideamine: 0 open · 1 done/); 132 assert.equal((await call('idea_list', { full: true })).text, 'No ideas match.'); 133 }); 134 135 test('idea_next with triage: true triages first, so /ideas go never has to stop', async () => { 136 await call('idea_add', { text: '- first\n- second\n- third' }); // #5 #6 #7 137 assert.match((await call('idea_next')).text, /^No idea is ready: 3 untriaged in the inbox\. Pass triage: true/); 138 const next = await call('idea_next', { triage: true }); 139 assert.match(next.text, /^Saved 3 verdicts: 2 do · 0 maybe · 1 skip/); 140 assert.match(next.text, /\n\n#5 Idea 5\n[^]*recommended model: sonnet\nproject dir: [^\n]*ideamine-proj-\w+ \(exists\)\n/); 141 assert.match(next.text, /\n\nThe queue:\nideamine: 2 open \(2 do\)[^]*#5[^]*#6/); // to choose an idea that fits the chat 142 143 // An explicit id triages only that idea. A saved folder that is gone is marked. 144 await call('idea_add', { text: '- eighth\n- ninth', project: path.join(project, 'gone') }); // #8 #9 145 const nine = (await call('idea_next', { id: 9, triage: true })).text; 146 assert.match(nine, /^Saved 1 verdict: 1 do[^]*\n\n#9 Idea 9\n/); 147 assert.match(nine, /project dir: [^\n]*gone \(does not exist\)$/); 148 assert.match((await call('idea_list', { filter: 'inbox' })).text, /#8 +eighth/); 149 }); 150 151 test('a manual triage shows the whole queue afterwards', async () => { 152 const out = await call('idea_triage', { headless: true }); // #8, the last one in the inbox 153 assert.match(out.text, /^Saved 1 verdict: 1 do[^]*\n\nideamine: 4 open \(4 do\) · 1 done\n\nDO \(best first\)\n[^]*#8 +sonnet/); 154 assert.match((await call('idea_triage', { headless: true })).text, /^Nothing to triage: the inbox is empty\.\n\nideamine: 4 open/); 155 });