tests/triage.test.js (6150 bytes)
1 import assert from 'node:assert/strict'; 2 import fs from 'node:fs'; 3 import os from 'node:os'; 4 import path from 'node:path'; 5 import { beforeEach, test } from 'node:test'; 6 import { fileURLToPath } from 'node:url'; 7 import { headlessTriage } from '../src/claude.js'; 8 import * as store from '../src/store.js'; 9 10 const FAKE = fileURLToPath(new URL('./fixtures/fake-claude.js', import.meta.url)); 11 let log; 12 13 beforeEach(() => { 14 const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-triage-')); 15 process.env.IDEAMINE_HOME = dir; 16 process.env.IDEAMINE_CLAUDE_BIN = FAKE; 17 process.env.CLAUDE_CONFIG_DIR = dir; // no .claude.json: the projects of this machine stay out of the tests 18 process.env.FAKE_CLAUDE_LOG = log = path.join(dir, 'call.json'); 19 }); 20 21 test('the triage pairs each idea with the project that it is about', async () => { 22 const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-pair-')); 23 const alpha = path.join(root, 'alpha'); 24 const beta = path.join(root, 'beta'); 25 const chat = path.join(root, 'scratch-workspaces', 'chat1'); // where the user saved the ideas 26 for (const d of [alpha, beta, chat]) fs.mkdirSync(d, { recursive: true }); 27 fs.writeFileSync(path.join(alpha, 'README.md'), '# Alpha: a scroller extension\n'); 28 fs.writeFileSync(path.join(root, '.claude.json'), JSON.stringify({ projects: { [alpha]: {}, [beta]: {} } })); 29 process.env.CLAUDE_CONFIG_DIR = root; 30 31 store.addIdeas(['make alpha scroll faster', 'beta needs a logo', 'a brand new app'], { project: chat }); 32 await headlessTriage({ model: 'haiku' }); 33 const db = store.load(); 34 assert.equal(store.findIdea(db, 1).project, alpha); 35 assert.equal(store.findIdea(db, 2).project, beta); 36 assert.equal(store.findIdea(db, 3).project, chat); // no project fits: the idea stays where it was saved 37 assert.match(store.findIdea(db, 1).notes[0].text, /^paired with .*alpha \(was .*chat1\)$/); 38 assert.ok(db.ideas.every((i) => i.triage.paired)); 39 const { input } = JSON.parse(fs.readFileSync(log, 'utf8')); 40 assert.match(input, /- project: /); 41 assert.ok(input.includes(`Projects on this machine:\nalpha: ${alpha} — Alpha: a scroller extension\nbeta: ${beta}\n\n`)); 42 }); 43 44 test('regression: the triage answers with the name of the folder, and the idea is still paired', async () => { 45 // Haiku answered "simple-autoscroll-free", not the path, and ideamine 0.4.0 before this fix did not pair. 46 const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-name-')); 47 const extension = path.join(root, 'simple-autoscroll-free'); 48 fs.mkdirSync(extension); 49 fs.writeFileSync(path.join(root, '.claude.json'), JSON.stringify({ projects: { [extension]: {} } })); 50 process.env.CLAUDE_CONFIG_DIR = root; 51 process.env.FAKE_CLAUDE_ANSWER = 'name'; 52 store.addIdeas(['add a speed slider to simple-autoscroll-free']); 53 await headlessTriage({ model: 'haiku' }); 54 delete process.env.FAKE_CLAUDE_ANSWER; 55 assert.equal(store.findIdea(store.load(), 1).project, extension); 56 }); 57 58 test('headless triage runs one minimal claude -p call and saves every verdict', async () => { 59 store.addIdeas(['cache responses', 'rename helpers', 'nft thing'], { project: '/p' }); 60 // Pretend we are inside a desktop-app session at max effort. 61 process.env.CLAUDECODE = '1'; 62 process.env.CLAUDE_EFFORT = 'max'; 63 process.env.CLAUDE_CODE_MESSAGING_SOCKET = '\\\\.\\pipe\\x'; 64 const out = await headlessTriage(); 65 delete process.env.CLAUDECODE; 66 delete process.env.CLAUDE_EFFORT; 67 delete process.env.CLAUDE_CODE_MESSAGING_SOCKET; 68 69 assert.equal(out.results.length, 3); 70 assert.deepEqual(out.tokens, { input: 321, output: 45 }); 71 const db = store.load(); 72 assert.deepEqual(db.ideas.map((i) => store.lane(i)), ['do', 'do', 'skip']); 73 assert.equal(store.findIdea(db, 2).triage.model, 'haiku'); 74 assert.equal(store.findIdea(db, 1).triage.by, 'sonnet (headless)'); 75 76 const call = JSON.parse(fs.readFileSync(log, 'utf8')); 77 const flag = (name) => call.args[call.args.indexOf(name) + 1]; 78 assert.equal(call.args[0], '-p'); 79 assert.equal(flag('--model'), 'sonnet'); 80 assert.equal(flag('--tools'), ''); 81 assert.equal(flag('--setting-sources'), ''); 82 assert.equal(flag('--effort'), 'low'); 83 assert.ok(call.args.includes('--strict-mcp-config')); 84 assert.ok(call.args.includes('--no-session-persistence')); 85 assert.ok(JSON.parse(flag('--json-schema')).properties.verdicts); 86 // The child is an independent session: no nesting marker, no inherited effort or host socket. 87 assert.deepEqual(call.env, { 88 CLAUDECODE: null, 89 CLAUDE_EFFORT: null, 90 CLAUDE_CODE_MESSAGING_SOCKET: null, 91 ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL ?? null, 92 MAX_THINKING_TOKENS: process.env.MAX_THINKING_TOKENS ?? null, // low effort already limits Sonnet 93 }); 94 assert.match(call.input, /Ideas to triage \(3\)/); 95 }); 96 97 test('haiku gets no effort flag, and an empty inbox makes no call', async () => { 98 assert.equal((await headlessTriage()).message, 'Nothing to triage: the inbox is empty.'); 99 assert.ok(!fs.existsSync(log)); 100 store.addIdeas(['one']); 101 await headlessTriage({ model: 'claude-haiku-4-5' }); 102 const call = JSON.parse(fs.readFileSync(log, 'utf8')); 103 assert.equal(call.args[call.args.indexOf('--model') + 1], 'haiku'); 104 assert.ok(!call.args.includes('--effort')); 105 // Haiku has no effort setting. Its thinking was 70% of the output tokens and did not change the verdicts. 106 assert.equal(call.env.MAX_THINKING_TOKENS, '0'); 107 }); 108 109 test('headless triage of given ids judges only those ideas', async () => { 110 store.addIdeas(['one', 'two', 'three']); 111 const out = await headlessTriage({ ids: [3] }); 112 assert.deepEqual(out.results.map((r) => r.id), [3]); 113 assert.deepEqual(store.load().ideas.map((i) => i.status), ['inbox', 'inbox', 'triaged']); 114 assert.match(JSON.parse(fs.readFileSync(log, 'utf8')).input, /Ideas to triage \(1\):\n#3: three\n/); 115 }); 116 117 test('a missing CLI gives a clear error', async () => { 118 store.addIdeas(['one']); 119 process.env.IDEAMINE_CLAUDE_BIN = path.join(os.tmpdir(), 'definitely-not-claude-xyz'); 120 await assert.rejects(headlessTriage(), /Claude Code CLI not found/); 121 assert.equal(store.load().ideas[0].status, 'inbox'); 122 });