diff --git a/.gitignore b/.gitignore index ab0ee0c..94a8f98 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ context.md **/.DS_Store .claude/settings.local.json + diff --git a/README.md b/README.md index 0ba48ea..d1dec2d 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,23 @@ Mimir auto-detected `src/auth.ts` and `src/middleware/auth.ts` from your repo --- +## Cost Estimation + +Example output: + +```text +Dollar cost: ~$0.0330 (sonnet) | ~$0.0088 (haiku) | ~$0.0007 (gemini) | ~$0.0225 (gpt-4o) +``` + +Cost is computed from estimated input tokens, assuming output tokens = 2x input tokens, using these per-million-token rates: + +- Claude Sonnet: $3 input / $15 output +- Claude Haiku: $0.80 input / $4 output +- Gemini Flash: $0.075 input / $0.30 output +- GPT-4o: $2.50 input / $10 output + +--- + ## Commands | Command | Description | @@ -345,3 +362,5 @@ Designed and built using **[Claude Code](https://claude.ai/code)** — Anthropic ## License MIT © 2026 [Marco Barlera](https://github.com/marcobarlera) + + diff --git a/scripts/config-show.js b/scripts/config-show.js index 026dad3..2dfd77c 100644 --- a/scripts/config-show.js +++ b/scripts/config-show.js @@ -23,4 +23,8 @@ function main() { process.stdout.write(`${LINE}\n\n`); } -main(); +if (require.main === module) { + main(); +} + +module.exports = { main }; diff --git a/scripts/estimate.js b/scripts/estimate.js index 4d4d241..0fd87f0 100644 --- a/scripts/estimate.js +++ b/scripts/estimate.js @@ -9,6 +9,7 @@ const { appendHistory } = require('./lib/history' const { estimateContextOverhead, autoDetectFiles } = require('./lib/context'); const { estimateCacheSavings } = require('./lib/cache'); const { estimateTaskTurns, projectAtTurn } = require('./lib/turns'); +const { estimateAllModelCosts } = require('./lib/cost'); const LINE = '━'.repeat(35); const SEP = '─'.repeat(35); @@ -40,7 +41,7 @@ function parseArgs(argv) { const clean = []; let i = 0; while (i < argv.length) { - if (argv[i] === '--git-diff' || argv[i] === '--no-auto') { i++; continue; } + if (argv[i] === '--git-diff' || argv[i] === '--no-auto') { i++; continue; } if (argv[i] === '--turns') { i += 2; continue; } if (argv[i] === '--output') { i += 2; continue; } clean.push(argv[i]); @@ -101,7 +102,6 @@ async function main() { const method = taskResult.method; const ctx = estimateContextOverhead(cfg, { turns, cwd }); - // Explicit files const fileResults = []; let fileTotal = 0; for (const fp of filePaths) { @@ -115,7 +115,6 @@ async function main() { } } - // Auto-detected files (only when no explicit --files and not --no-auto) let autoFiles = []; let autoTotal = 0; if (!noAuto) { @@ -123,7 +122,6 @@ async function main() { autoTotal = autoFiles.reduce((s, f) => s + f.tokens, 0); } - // Git diff let diffTokens = 0; if (useGitDiff) { const diff = getGitDiff(); @@ -140,6 +138,7 @@ async function main() { const turnEst = estimateTaskTurns(task); const projectedTokens = projectAtTurn(totalTokens, ctx.tokensPerTurn, turnEst.turns); const projectedRisk = classifyRisk(projectedTokens, cfg, task); + const costInfo = estimateAllModelCosts(totalTokens); if (outputJson) { const allFiles = [ @@ -155,6 +154,7 @@ async function main() { suggestedModel: modelLine, headroomPct: headroom, method, + costInfo, files: allFiles, cache: cacheInfo, projection: { turns: turnEst.turns, category: turnEst.category, tokens: projectedTokens, risk: projectedRisk.level }, @@ -166,7 +166,6 @@ async function main() { process.stdout.write(`\n⚡ MIMIR PREFLIGHT\n`); process.stdout.write(`${LINE}\n`); - // — Baseline section — process.stdout.write(` Baseline:\n`); row(' System overhead:', `~${ctx.systemOverhead.toLocaleString()} (prompt + Claude UI)`); for (const h of ctx.hookScripts) { @@ -186,7 +185,6 @@ async function main() { row(` Conversation (${turns} turns):`, `~${ctx.conversationTokens.toLocaleString()} (${tptLabel})`); } - // — Task section — process.stdout.write(`\n This task:\n`); row(` Task (${method}):`, fmt(method, taskResult.tokens)); @@ -208,7 +206,6 @@ async function main() { if (useGitDiff) row(' Git diff:', `~${diffTokens.toLocaleString()}`); - // — Totals — process.stdout.write(`\n ${SEP}\n`); row('Task tokens:', `~${taskSpecific.toLocaleString()}`); row('Total context:', `~${totalTokens.toLocaleString()}`); @@ -216,6 +213,12 @@ async function main() { process.stdout.write(`\n`); process.stdout.write(` Risk: ${risk.level} ${risk.emoji}\n`); process.stdout.write(` Suggested model: ${modelLine}\n`); + if (costInfo) { + const costLine = costInfo + .map((c) => `~$${c.totalCost.toFixed(4)} (${c.label})`) + .join(' | '); + process.stdout.write(` Dollar cost: ${costLine}\n`); + } process.stdout.write(` Context headroom: ${headroom}%\n`); if (cacheInfo) { process.stdout.write(` Prompt cache: ~${cacheInfo.cacheableTokens.toLocaleString()} tok stable (${cacheInfo.cacheablePct}%) → ~${cacheInfo.costReductionPct}% cost/turn if cached\n`); @@ -238,4 +241,8 @@ async function main() { } } -main().catch(err => { process.stderr.write(`Error: ${err.message}\n`); process.exit(1); }); +if (require.main === module) { + main().catch(err => { process.stderr.write(`Error: ${err.message}\n`); process.exit(1); }); +} + +module.exports = { parseArgs, main }; diff --git a/scripts/history-show.js b/scripts/history-show.js index c00192c..1ad8615 100644 --- a/scripts/history-show.js +++ b/scripts/history-show.js @@ -91,4 +91,8 @@ function main() { process.stdout.write(`${LINE}\n\n`); } -main(); +if (require.main === module) { + main(); +} + +module.exports = { main, printStats, escapeCsv }; diff --git a/scripts/lib/cost.js b/scripts/lib/cost.js new file mode 100644 index 0000000..c8eb205 --- /dev/null +++ b/scripts/lib/cost.js @@ -0,0 +1,63 @@ +const MODEL_PRICING = { + 'sonnet-4-6': { input: 3.00, output: 15.00 }, + 'haiku-4-5': { input: 0.80, output: 4.00 }, + 'gemini-flash': { input: 0.075, output: 0.30 }, + 'gpt-4o': { input: 2.50, output: 10.00 }, +}; + +const MODEL_LABELS = { + 'sonnet-4-6': 'sonnet', + 'haiku-4-5': 'haiku', + 'gemini-flash': 'gemini', + 'gpt-4o': 'gpt-4o', +}; + +function normalizeModelId(model) { + if (!model) return null; + const raw = String(model).toLowerCase(); + if (raw.includes('sonnet-4-6') || raw.includes('sonnet 4.6')) return 'sonnet-4-6'; + if (raw.includes('haiku-4-5') || raw.includes('haiku 4.5')) return 'haiku-4-5'; + if (raw.includes('gemini-flash') || raw.includes('gemini flash')) return 'gemini-flash'; + if (raw.includes('gpt-4o') || raw.includes('gpt 4o')) return 'gpt-4o'; + return null; +} + +function estimateModelCost(model, inputTokens) { + const modelId = normalizeModelId(model); + const pricing = modelId ? MODEL_PRICING[modelId] : null; + if (!pricing || !Number.isFinite(inputTokens) || inputTokens < 0) return null; + + const outputTokens = inputTokens * 2; + const inputCost = (inputTokens / 1_000_000) * pricing.input; + const outputCost = (outputTokens / 1_000_000) * pricing.output; + return { + modelId, + inputTokens, + outputTokens, + inputCost, + outputCost, + totalCost: inputCost + outputCost, + }; +} + +function estimateAllModelCosts(inputTokens) { + if (!Number.isFinite(inputTokens) || inputTokens < 0) return null; + return Object.keys(MODEL_PRICING).map((modelId) => { + const pricing = MODEL_PRICING[modelId]; + const outputTokens = inputTokens * 2; + const inputCost = (inputTokens / 1_000_000) * pricing.input; + const outputCost = (outputTokens / 1_000_000) * pricing.output; + return { + modelId, + label: MODEL_LABELS[modelId] || modelId, + inputTokens, + outputTokens, + inputCost, + outputCost, + totalCost: inputCost + outputCost, + }; + }); +} + +module.exports = { MODEL_PRICING, MODEL_LABELS, normalizeModelId, estimateModelCost, estimateAllModelCosts }; + diff --git a/scripts/split.js b/scripts/split.js index c14a203..7ab2c1f 100755 --- a/scripts/split.js +++ b/scripts/split.js @@ -159,7 +159,11 @@ async function main() { process.stdout.write(`${LINE}\n\n`); } -main().catch((err) => { - process.stderr.write(`Error: ${err.message}\n`); - process.exit(1); -}); +if (require.main === module) { + main().catch((err) => { + process.stderr.write(`Error: ${err.message}\n`); + process.exit(1); + }); +} + +module.exports = { parseArgs, detectSplitPoints, main }; diff --git a/tests/config-show.test.js b/tests/config-show.test.js index 2d29d15..c002416 100644 --- a/tests/config-show.test.js +++ b/tests/config-show.test.js @@ -1,13 +1,32 @@ const assert = require('assert'); const path = require('path'); -const { execSync } = require('child_process'); -const script = path.resolve(__dirname, '..', 'scripts', 'config-show.js'); +async function runConfigShow(cwd) { + const originalArgv = process.argv; + const originalCwd = process.cwd(); + const originalWrite = process.stdout.write; + let stdout = ''; + process.argv = [process.execPath, 'scripts/config-show.js']; + process.stdout.write = (chunk) => { stdout += chunk; return true; }; + process.chdir(cwd); + + try { + delete require.cache[require.resolve('../scripts/config-show.js')]; + require('../scripts/config-show.js').main(); + } finally { + process.argv = originalArgv; + process.stdout.write = originalWrite; + process.chdir(originalCwd); + delete require.cache[require.resolve('../scripts/config-show.js')]; + } + + return stdout; +} + +void (async () => { // Default config (no .mimir.json in /tmp) -const out1 = execSync(`node "${script}"`, { - cwd: '/tmp', -}).toString(); +const out1 = await runConfigShow('/tmp'); assert.match(out1, /MIMIR CONFIG/, 'missing header'); assert.match(out1, /Source/, 'missing source line'); @@ -19,3 +38,4 @@ assert.match(out1, /Default model/, 'missing default model'); assert.match(out1, /200[,.]000/, 'should show 200k default context window'); console.log('✅ config-show.test.js passed'); +})().catch(err => { throw err; }); diff --git a/tests/cost.test.js b/tests/cost.test.js new file mode 100644 index 0000000..78d2d8a --- /dev/null +++ b/tests/cost.test.js @@ -0,0 +1,29 @@ +const assert = require('assert'); +const { MODEL_PRICING, normalizeModelId, estimateModelCost, estimateAllModelCosts } = require('../scripts/lib/cost'); + +assert.strictEqual(MODEL_PRICING['sonnet-4-6'].input, 3); +assert.strictEqual(MODEL_PRICING['haiku-4-5'].output, 4); +assert.strictEqual(MODEL_PRICING['gemini-flash'].input, 0.075); +assert.strictEqual(MODEL_PRICING['gpt-4o'].output, 10); + +assert.strictEqual(normalizeModelId('Sonnet 4.6'), 'sonnet-4-6'); +assert.strictEqual(normalizeModelId('claude-sonnet-4-6'), 'sonnet-4-6'); +assert.strictEqual(normalizeModelId('Haiku 4.5'), 'haiku-4-5'); +assert.strictEqual(normalizeModelId('gemini flash'), 'gemini-flash'); +assert.strictEqual(normalizeModelId('gpt-4o'), 'gpt-4o'); + +const cost = estimateModelCost('Sonnet 4.6', 1000); +assert.ok(cost, 'expected a cost result'); +assert.strictEqual(cost.modelId, 'sonnet-4-6'); +assert.strictEqual(cost.outputTokens, 2000); +assert.strictEqual(cost.totalCost, 0.033); + +assert.strictEqual(estimateModelCost('unknown', 1000), null); + +const all = estimateAllModelCosts(1000); +assert.ok(Array.isArray(all), 'expected all model costs array'); +assert.strictEqual(all.length, 4); +assert.strictEqual(all[0].label, 'sonnet'); +assert.strictEqual(all[1].label, 'haiku'); + +console.log('✅ cost.test.js passed'); diff --git a/tests/estimate.test.js b/tests/estimate.test.js index 12bd248..f729a91 100644 --- a/tests/estimate.test.js +++ b/tests/estimate.test.js @@ -1,13 +1,42 @@ const assert = require('assert'); -const { execSync } = require('child_process'); const NO_KEY = { ...process.env, ANTHROPIC_API_KEY: '', MIMIR_NO_HISTORY: '1' }; +async function runEstimate(args) { + const originalArgv = process.argv; + const originalEnv = { ...process.env }; + const originalWrite = process.stdout.write; + const originalExit = process.exit; + let stdout = ''; + let exitCode = null; + + process.argv = [process.execPath, 'scripts/estimate.js', ...args]; + process.env.ANTHROPIC_API_KEY = ''; + process.env.MIMIR_NO_HISTORY = '1'; + process.stdout.write = (chunk) => { stdout += chunk; return true; }; + process.exit = (code) => { exitCode = code ?? 0; throw new Error('__EXIT__'); }; + + try { + delete require.cache[require.resolve('../scripts/estimate.js')]; + const mod = require('../scripts/estimate.js'); + await mod.main(); + } catch (err) { + if (err.message !== '__EXIT__') throw err; + } finally { + process.argv = originalArgv; + process.env = originalEnv; + process.stdout.write = originalWrite; + process.exit = originalExit; + delete require.cache[require.resolve('../scripts/estimate.js')]; + } + + assert.strictEqual(exitCode ?? 0, 0, `estimate.js exited with ${exitCode}`); + return stdout; +} + +void (async () => { // Normal usage — new layout -const out = execSync( - 'node scripts/estimate.js "analyze all TypeScript files in src directory"', - { env: NO_KEY } -).toString(); +let out = await runEstimate(['analyze all TypeScript files in src directory']); assert.match(out, /MIMIR PREFLIGHT/, 'missing header'); assert.match(out, /Baseline/, 'missing baseline section'); @@ -19,6 +48,7 @@ assert.match(out, /Risk:\s+(LOW|MEDIUM|HIGH|CRITICAL)/, 'missing risk line'); assert.match(out, /Context headroom:\s+\d+%/, 'missing headroom line'); assert.match(out, /Action:/, 'missing action line'); assert.match(out, /Suggested model:/, 'missing model line'); +assert.match(out, /Dollar cost:/, 'missing dollar cost line'); // Task tokens < Total context (baseline always adds overhead) const taskTokMatch = out.match(/Task tokens:\s+~?([\d,]+)/); @@ -28,60 +58,43 @@ const totalTok = totalTokMatch ? parseInt(totalTokMatch[1].replace(/,/g, ' assert.ok(totalTok > taskTok, `total (${totalTok}) should exceed task tokens (${taskTok})`); // --files flag — file rows shown in task section -const outFiles = execSync( - 'node scripts/estimate.js "refactor auth module" --files package.json', - { env: NO_KEY } -).toString(); +const outFiles = await runEstimate(['refactor auth module', '--files', 'package.json']); assert.match(outFiles, /MIMIR PREFLIGHT/, 'missing header with files'); assert.match(outFiles, /package\.json/, 'missing file name'); assert.match(outFiles, /Task tokens/, 'missing task tokens'); +assert.match(outFiles, /Dollar cost:/, 'missing dollar cost with files'); // --files suppresses auto-detect assert.doesNotMatch(outFiles, /\(auto\)/, '--files should suppress auto-detect'); // --no-auto suppresses auto-detect -const outNoAuto = execSync( - 'node scripts/estimate.js "refactor risk logic" --no-auto', - { env: NO_KEY } -).toString(); +const outNoAuto = await runEstimate(['refactor risk logic', '--no-auto']); assert.doesNotMatch(outNoAuto, /\(auto\)/, '--no-auto should suppress auto-detect'); // --turns flag — conversation row shown -const outTurns = execSync( - 'node scripts/estimate.js "add feature" --turns 3', - { env: NO_KEY } -).toString(); +const outTurns = await runEstimate(['add feature', '--turns', '3']); assert.match(outTurns, /Conversation \(3 turns\)/, 'missing conversation row'); // No arguments → exit 0 + help text -const outNoArgs = execSync('node scripts/estimate.js', { env: NO_KEY }).toString(); +const outNoArgs = await runEstimate([]); assert.match(outNoArgs, /MIMIR/, 'missing mimir in help'); assert.match(outNoArgs, /Usage/, 'missing Usage in help'); assert.match(outNoArgs, /--turns/, 'missing --turns in help'); assert.match(outNoArgs, /--no-auto/, 'missing --no-auto in help'); // --files with missing file — shows warning -const outBadFile = execSync( - 'node scripts/estimate.js "some task" --files nonexistent.ts', - { env: NO_KEY } -).toString(); +const outBadFile = await runEstimate(['some task', '--files', 'nonexistent.ts']); assert.match(outBadFile, /MIMIR PREFLIGHT/); assert.match(outBadFile, /⚠/); // HIGH/CRITICAL risk → auto-split appended -const outHigh = execSync( - 'node scripts/estimate.js "analyze every file in the entire codebase and refactor all modules and update all tests and generate full documentation"', - { env: NO_KEY } -).toString(); +const outHigh = await runEstimate(['analyze every file in the entire codebase and refactor all modules and update all tests and generate full documentation']); if (/Risk:\s+(HIGH|CRITICAL)/.test(outHigh)) { assert.match(outHigh, /MIMIR SPLIT/, 'HIGH/CRITICAL should include auto-split output'); } // --output json emits valid JSON with expected fields -const jsonOut = execSync( - 'node scripts/estimate.js "add auth middleware" --output json --no-auto', - { env: NO_KEY } -).toString(); +const jsonOut = await runEstimate(['add auth middleware', '--output', 'json', '--no-auto']); let parsed; assert.doesNotThrow(() => { parsed = JSON.parse(jsonOut); }, '--output json must emit valid JSON'); assert.ok(typeof parsed.totalTokens === 'number', 'totalTokens must be number'); @@ -96,3 +109,6 @@ assert.ok(parsed.totalTokens === parsed.taskTokens + parsed.contextTokens, 'tota assert.doesNotMatch(jsonOut, /MIMIR PREFLIGHT/, '--output json should not include formatted header'); console.log('✅ estimate.test.js passed'); +})().catch(err => { throw err; }); + + diff --git a/tests/history-show.test.js b/tests/history-show.test.js index 55178d6..7b1f570 100644 --- a/tests/history-show.test.js +++ b/tests/history-show.test.js @@ -2,8 +2,32 @@ const assert = require('assert'); const fs = require('fs'); const os = require('os'); const path = require('path'); -const { execSync } = require('child_process'); +async function runHistoryShow(args, env) { + const originalArgv = process.argv; + const originalEnv = { ...process.env }; + const originalWrite = process.stdout.write; + let stdout = ''; + + process.argv = [process.execPath, 'scripts/history-show.js', ...args]; + process.stdout.write = (chunk) => { stdout += chunk; return true; }; + Object.assign(process.env, env); + + try { + delete require.cache[require.resolve('../scripts/lib/history.js')]; + delete require.cache[require.resolve('../scripts/history-show.js')]; + require('../scripts/history-show.js').main(); + } finally { + process.argv = originalArgv; + process.stdout.write = originalWrite; + process.env = originalEnv; + delete require.cache[require.resolve('../scripts/history-show.js')]; + } + + return stdout; +} + +void (async () => { const tmpFile = path.join(os.tmpdir(), `mimir-history-show-test-${Date.now()}.json`); const entries = [ { task: 'refactor auth module', tokens: 5200, risk: 'LOW', model: 'Sonnet 4.6', timestamp: '2026-04-24T10:00:00.000Z' }, @@ -12,10 +36,9 @@ const entries = [ fs.writeFileSync(tmpFile, JSON.stringify(entries)); const env = { ...process.env, MIMIR_HISTORY_FILE: tmpFile, MIMIR_NO_HISTORY: '1' }; -const bin = path.resolve(__dirname, '..', 'scripts', 'history-show.js'); // Normal output — shows entries -const outNormal = execSync(`node "${bin}"`, { env }).toString(); +const outNormal = await runHistoryShow([], env); assert.match(outNormal, /MIMIR HISTORY/, 'missing header'); assert.match(outNormal, /refactor auth/, 'missing entry 1'); assert.match(outNormal, /analyze entire/, 'missing entry 2'); @@ -23,12 +46,12 @@ assert.match(outNormal, /LOW/, 'missing risk LOW'); assert.match(outNormal, /HIGH/, 'missing risk HIGH'); // N arg limits output -const outOne = execSync(`node "${bin}" 1`, { env }).toString(); +const outOne = await runHistoryShow(['1'], env); assert.match(outOne, /analyze entire/, 'N=1 should show most recent'); assert.doesNotMatch(outOne, /refactor auth/, 'N=1 should not show older entry'); // --csv output -const outCsv = execSync(`node "${bin}" --csv`, { env }).toString(); +const outCsv = await runHistoryShow(['--csv'], env); assert.match(outCsv, /timestamp,task,tokens,risk,model/, 'missing CSV header'); assert.match(outCsv, /refactor auth module/, 'missing CSV entry 1'); assert.match(outCsv, /analyze entire codebase/, 'missing CSV entry 2'); @@ -37,7 +60,7 @@ assert.match(outCsv, /85000/, 'missing tokens in CSV' assert.doesNotMatch(outCsv, /MIMIR HISTORY/, 'CSV should not have header block'); // --csv with N arg -const outCsvOne = execSync(`node "${bin}" 1 --csv`, { env }).toString(); +const outCsvOne = await runHistoryShow(['1', '--csv'], env); const csvLines = outCsvOne.trim().split('\n'); assert.strictEqual(csvLines.length, 2, 'N=1 --csv should have header + 1 data row'); @@ -45,7 +68,7 @@ assert.strictEqual(csvLines.length, 2, 'N=1 --csv should have header + 1 data ro const emptyFile = path.join(os.tmpdir(), `mimir-history-empty-${Date.now()}.json`); fs.writeFileSync(emptyFile, '[]'); const envEmpty = { ...process.env, MIMIR_HISTORY_FILE: emptyFile }; -const outEmpty = execSync(`node "${bin}"`, { env: envEmpty }).toString(); +const outEmpty = await runHistoryShow([], envEmpty); assert.match(outEmpty, /No Mimir history/, 'missing empty state message'); // --stats output @@ -59,7 +82,7 @@ const statsFile = path.join(os.tmpdir(), `mimir-stats-test-${Date.now()}.json`); fs.writeFileSync(statsFile, JSON.stringify(statsEntries)); const envStats = { ...process.env, MIMIR_HISTORY_FILE: statsFile }; -const outStats = execSync(`node "${bin}" --stats`, { env: envStats }).toString(); +const outStats = await runHistoryShow(['--stats'], envStats); assert.match(outStats, /MIMIR STATS/, 'missing STATS header'); assert.match(outStats, /4/, 'should show total count'); assert.match(outStats, /Average/, 'missing Average line'); @@ -69,7 +92,7 @@ assert.match(outStats, /HIGH/, 'missing HIGH in breakdown'); assert.doesNotMatch(outStats, /MIMIR HISTORY/, '--stats should not show history header'); // --stats on empty history -const outStatsEmpty = execSync(`node "${bin}" --stats`, { env: envEmpty }).toString(); +const outStatsEmpty = await runHistoryShow(['--stats'], envEmpty); assert.match(outStatsEmpty, /No Mimir history/, 'empty stats should show no history message'); // Cleanup @@ -78,3 +101,4 @@ fs.unlinkSync(emptyFile); fs.unlinkSync(statsFile); console.log('✅ history-show.test.js passed'); +})().catch(err => { throw err; }); diff --git a/tests/history.test.js b/tests/history.test.js index 7cb8938..294fa5b 100644 --- a/tests/history.test.js +++ b/tests/history.test.js @@ -3,32 +3,15 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); -// Override HISTORY_FILE for test isolation const testHistoryFile = path.join(os.tmpdir(), `mimir-history-test-${Date.now()}.json`); +const originalEnv = { ...process.env }; +process.env.MIMIR_HISTORY_FILE = testHistoryFile; -// Patch require cache so history.js uses our test file const historyPath = path.resolve(__dirname, '..', 'scripts', 'lib', 'history.js'); delete require.cache[require.resolve(historyPath)]; +const { appendHistory, loadHistory } = require(historyPath); -const origWrite = fs.writeFileSync.bind(fs); -const origRead = fs.readFileSync.bind(fs); - -// Monkey-patch to redirect history file ops -const { appendHistory, loadHistory } = (() => { - const mod = require(historyPath); - return mod; -})(); - -// Since we can't easily redirect the internal HISTORY_FILE constant, -// test via the actual file path but clean up after -const HISTORY_FILE = path.join(os.homedir(), '.mimir-history.json'); - -// Backup existing history -let backup = null; -try { backup = fs.readFileSync(HISTORY_FILE, 'utf8'); } catch {} - -// Clean slate -try { fs.unlinkSync(HISTORY_FILE); } catch {} +try { fs.unlinkSync(testHistoryFile); } catch {} // Test: loadHistory returns [] when file missing const empty = loadHistory(); @@ -47,10 +30,7 @@ appendHistory({ task: 'second task', tokens: 200, risk: 'MEDIUM', model: 'Sonnet const h2 = loadHistory(); assert.strictEqual(h2.length, 2, 'should have 2 entries'); -// Restore backup -try { fs.unlinkSync(HISTORY_FILE); } catch {} -if (backup !== null) { - try { fs.writeFileSync(HISTORY_FILE, backup); } catch {} -} +try { fs.unlinkSync(testHistoryFile); } catch {} +process.env = originalEnv; console.log('✅ history.test.js passed'); diff --git a/tests/run-all.js b/tests/run-all.js index bdad924..f3ce4ca 100644 --- a/tests/run-all.js +++ b/tests/run-all.js @@ -8,6 +8,7 @@ const tests = [ 'tests/tokenizer.test.js', 'tests/config.test.js', 'tests/estimate.test.js', + 'tests/cost.test.js', 'tests/split.test.js', 'tests/config-show.test.js', 'tests/history.test.js', diff --git a/tests/split.test.js b/tests/split.test.js index 1f7a0ef..a769982 100644 --- a/tests/split.test.js +++ b/tests/split.test.js @@ -1,13 +1,44 @@ const assert = require('assert'); -const { execSync } = require('child_process'); const NO_KEY = { ...process.env, ANTHROPIC_API_KEY: '' }; +async function runSplit(args) { + const originalArgv = process.argv; + const originalEnv = { ...process.env }; + const originalWrite = process.stdout.write; + const originalErrWrite = process.stderr.write; + const originalExit = process.exit; + let stdout = ''; + let stderr = ''; + let exitCode = null; + + process.argv = [process.execPath, 'scripts/split.js', ...args]; + process.env.ANTHROPIC_API_KEY = ''; + process.stdout.write = (chunk) => { stdout += chunk; return true; }; + process.stderr.write = (chunk) => { stderr += chunk; return true; }; + process.exit = (code) => { exitCode = code ?? 0; throw new Error('__EXIT__'); }; + + try { + delete require.cache[require.resolve('../scripts/split.js')]; + const mod = require('../scripts/split.js'); + await mod.main(); + } catch (err) { + if (err.message !== '__EXIT__') throw err; + } finally { + process.argv = originalArgv; + process.env = originalEnv; + process.stdout.write = originalWrite; + process.stderr.write = originalErrWrite; + process.exit = originalExit; + delete require.cache[require.resolve('../scripts/split.js')]; + } + + return { stdout, stderr, exitCode }; +} + +void (async () => { // Conjunction split -const out1 = execSync( - 'node scripts/split.js "analyze all TypeScript files and then refactor the auth module"', - { env: NO_KEY } -).toString(); +const out1 = (await runSplit(['analyze all TypeScript files and then refactor the auth module'])).stdout; assert.match(out1, /MIMIR SPLIT/, 'missing header'); assert.match(out1, /Suggested split/, 'missing split section'); @@ -16,55 +47,37 @@ assert.match(out1, /tokens/, 'missing token count'); assert.match(out1, /1\./, 'missing item 1'); // Multiple action verbs -const out2 = execSync( - 'node scripts/split.js "analyze the codebase and then refactor all components"', - { env: NO_KEY } -).toString(); +const out2 = (await runSplit(['analyze the codebase and then refactor all components'])).stdout; assert.match(out2, /1\./); assert.match(out2, /2\./); // --files flag — shows file breakdown -const out3 = execSync( - 'node scripts/split.js "refactor auth module and then update tests" --files package.json', - { env: NO_KEY } -).toString(); +const out3 = (await runSplit(['refactor auth module and then update tests', '--files', 'package.json'])).stdout; assert.match(out3, /Files loaded/, 'missing files loaded line'); assert.match(out3, /package\.json/, 'missing file name'); assert.match(out3, /Suggested split/); // --files missing file — shows warning, does not crash -const out4 = execSync( - 'node scripts/split.js "some task" --files nonexistent.ts', - { env: NO_KEY } -).toString(); +const out4 = (await runSplit(['some task', '--files', 'nonexistent.ts'])).stdout; assert.match(out4, /⚠/); // Numbered list → splits on items -const out5 = execSync( - 'node scripts/split.js "1. analyze TypeScript files 2. refactor auth module 3. update tests"', - { env: NO_KEY } -).toString(); +const out5 = (await runSplit(['1. analyze TypeScript files 2. refactor auth module 3. update tests'])).stdout; assert.match(out5, /1\./); assert.match(out5, /2\./); assert.match(out5, /3\./); // File paths → splits per file -const out6 = execSync( - 'node scripts/split.js "fix bug in src/auth.ts and update src/auth.test.ts"', - { env: NO_KEY } -).toString(); +const out6 = (await runSplit(['fix bug in src/auth.ts and update src/auth.test.ts'])).stdout; assert.match(out6, /src\/auth\.ts/); assert.match(out6, /src\/auth\.test\.ts/); assert.match(out6, /scope boundary/); // No arguments → non-zero exit -try { - execSync('node scripts/split.js 2>&1', { env: NO_KEY }); - assert.fail('Should have thrown'); -} catch (err) { - assert.ok(err.status !== 0); -} +const empty = await runSplit([]); +assert.ok(empty.exitCode !== 0, 'Should have non-zero exit'); console.log('✅ split.test.js passed'); +})().catch(err => { throw err; });