From fcaa8da6d61869a58cda69fde8d93b8a14521093 Mon Sep 17 00:00:00 2001 From: Jarrod Watts Date: Sat, 3 Jan 2026 18:52:12 +1100 Subject: [PATCH] test coverage --- .github/workflows/ci.yml | 21 +++ TESTING.md | 14 ++ package.json | 7 +- src/index.ts | 44 +++-- src/render/tools-line.ts | 2 +- src/transcript.ts | 3 +- tests/core.test.js | 213 ++++++++++++++++++++++++- tests/fixtures/transcript-basic.jsonl | 2 +- tests/fixtures/transcript-render.jsonl | 2 +- tests/index.test.js | 106 ++++++++++++ tests/integration.test.js | 18 ++- tests/render.test.js | 209 +++++++++++++++++++++++- tests/stdin.test.js | 32 ++++ 13 files changed, 651 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/index.test.js create mode 100644 tests/stdin.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6c69da0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [18.x, 20.x] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + - run: npm ci + - run: npm run test:coverage diff --git a/TESTING.md b/TESTING.md index 3de94df..14f9c9c 100644 --- a/TESTING.md +++ b/TESTING.md @@ -44,12 +44,26 @@ npm test This runs `npm run build` and then executes Node's built-in test runner. +To generate coverage: + +```bash +npm run test:coverage +``` + +To update snapshots: + +```bash +npm run test:update-snapshots +``` + ## CI Gate (recommended) - `npm ci` - `npm run build` - `npm test` +The provided GitHub Actions workflow runs `npm run test:coverage` on Node 18 and 20. + These steps should be required in PR checks to ensure new changes do not regress existing behavior. ## Contributing Expectations diff --git a/package.json b/package.json index 0669e36..f5aa544 100644 --- a/package.json +++ b/package.json @@ -8,13 +8,16 @@ "build": "tsc", "dev": "tsc --watch", "test": "npm run build && node --test", + "test:coverage": "npm run build && c8 --reporter=text --reporter=lcov node --test", + "test:update-snapshots": "UPDATE_SNAPSHOTS=1 npm test", "test:stdin": "echo '{\"model\":{\"display_name\":\"Opus\"},\"context_window\":{\"current_usage\":{\"input_tokens\":45000},\"context_window_size\":200000},\"transcript_path\":\"/tmp/test.jsonl\"}' | node dist/index.js" }, "keywords": ["claude-code", "statusline", "hud"], "author": "Jarrod Watts", "license": "MIT", "devDependencies": { - "typescript": "^5.0.0", - "@types/node": "^20.0.0" + "@types/node": "^20.0.0", + "c8": "^9.1.0", + "typescript": "^5.0.0" } } diff --git a/src/index.ts b/src/index.ts index 54b0abc..c84808a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,22 +3,42 @@ import { parseTranscript } from './transcript.js'; import { render } from './render/index.js'; import { countConfigs } from './config-reader.js'; import type { RenderContext } from './types.js'; +import { fileURLToPath } from 'node:url'; + +export type MainDeps = { + readStdin: typeof readStdin; + parseTranscript: typeof parseTranscript; + countConfigs: typeof countConfigs; + render: typeof render; + now: () => number; + log: (...args: unknown[]) => void; +}; + +export async function main(overrides: Partial = {}): Promise { + const deps: MainDeps = { + readStdin, + parseTranscript, + countConfigs, + render, + now: () => Date.now(), + log: console.log, + ...overrides, + }; -async function main(): Promise { try { - const stdin = await readStdin(); + const stdin = await deps.readStdin(); if (!stdin) { - console.log('[claude-hud] Initializing...'); + deps.log('[claude-hud] Initializing...'); return; } const transcriptPath = stdin.transcript_path ?? ''; - const transcript = await parseTranscript(transcriptPath); + const transcript = await deps.parseTranscript(transcriptPath); - const { claudeMdCount, rulesCount, mcpCount, hooksCount } = await countConfigs(stdin.cwd); + const { claudeMdCount, rulesCount, mcpCount, hooksCount } = await deps.countConfigs(stdin.cwd); - const sessionDuration = formatSessionDuration(transcript.sessionStart); + const sessionDuration = formatSessionDuration(transcript.sessionStart, deps.now); const ctx: RenderContext = { stdin, @@ -30,18 +50,18 @@ async function main(): Promise { sessionDuration, }; - render(ctx); + deps.render(ctx); } catch (error) { - console.log('[claude-hud] Error:', error instanceof Error ? error.message : 'Unknown error'); + deps.log('[claude-hud] Error:', error instanceof Error ? error.message : 'Unknown error'); } } -function formatSessionDuration(sessionStart?: Date): string { +export function formatSessionDuration(sessionStart?: Date, now: () => number = () => Date.now()): string { if (!sessionStart) { return ''; } - const ms = Date.now() - sessionStart.getTime(); + const ms = now() - sessionStart.getTime(); const mins = Math.floor(ms / 60000); if (mins < 1) return '<1m'; @@ -52,4 +72,6 @@ function formatSessionDuration(sessionStart?: Date): string { return `${hours}h ${remainingMins}m`; } -main(); +if (process.argv[1] === fileURLToPath(import.meta.url)) { + void main(); +} diff --git a/src/render/tools-line.ts b/src/render/tools-line.ts index 7b2e9f7..9df3d0a 100644 --- a/src/render/tools-line.ts +++ b/src/render/tools-line.ts @@ -43,7 +43,7 @@ function truncatePath(path: string, maxLen: number = 20): string { if (path.length <= maxLen) return path; const parts = path.split('/'); - const filename = parts.pop() ?? path; + const filename = parts.pop() || path; if (filename.length >= maxLen) { return filename.slice(0, maxLen - 3) + '...'; diff --git a/src/transcript.ts b/src/transcript.ts index 1f0b1f8..0947c6e 100644 --- a/src/transcript.ts +++ b/src/transcript.ts @@ -140,7 +140,6 @@ function extractTarget(toolName: string, input?: Record): strin case 'Bash': const cmd = input.command as string; return cmd?.slice(0, 30) + (cmd?.length > 30 ? '...' : ''); - default: - return undefined; } + return undefined; } diff --git a/tests/core.test.js b/tests/core.test.js index aa68829..db1fa2a 100644 --- a/tests/core.test.js +++ b/tests/core.test.js @@ -1,11 +1,13 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseTranscript } from '../dist/transcript.js'; -import { getContextPercent } from '../dist/stdin.js'; +import { countConfigs } from '../dist/config-reader.js'; +import { getContextPercent, getModelName } from '../dist/stdin.js'; +import * as fs from 'node:fs'; test('getContextPercent returns 0 when data is missing', () => { assert.equal(getContextPercent({}), 0); @@ -27,6 +29,26 @@ test('getContextPercent includes cache tokens', () => { assert.equal(percent, 50); }); +test('getContextPercent handles missing input tokens', () => { + const percent = getContextPercent({ + context_window: { + context_window_size: 200, + current_usage: { + cache_creation_input_tokens: 40, + cache_read_input_tokens: 10, + }, + }, + }); + + assert.equal(percent, 25); +}); + +test('getModelName prefers display name, then id, then fallback', () => { + assert.equal(getModelName({ model: { display_name: 'Opus', id: 'opus-123' } }), 'Opus'); + assert.equal(getModelName({ model: { id: 'sonnet-456' } }), 'sonnet-456'); + assert.equal(getModelName({}), 'Unknown'); +}); + test('parseTranscript aggregates tools, agents, and todos', async () => { const fixturePath = fileURLToPath(new URL('./fixtures/transcript-basic.jsonl', import.meta.url)); const result = await parseTranscript(fixturePath); @@ -40,6 +62,13 @@ test('parseTranscript aggregates tools, agents, and todos', async () => { assert.equal(result.sessionStart?.toISOString(), '2024-01-01T00:00:00.000Z'); }); +test('parseTranscript returns empty result when file is missing', async () => { + const result = await parseTranscript('/tmp/does-not-exist.jsonl'); + assert.equal(result.tools.length, 0); + assert.equal(result.agents.length, 0); + assert.equal(result.todos.length, 0); +}); + test('parseTranscript tolerates malformed lines', async () => { const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-')); const filePath = path.join(dir, 'malformed.jsonl'); @@ -60,3 +89,183 @@ test('parseTranscript tolerates malformed lines', async () => { await rm(dir, { recursive: true, force: true }); } }); + +test('parseTranscript extracts tool targets for common tools', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-')); + const filePath = path.join(dir, 'targets.jsonl'); + const lines = [ + JSON.stringify({ + message: { + content: [ + { type: 'tool_use', id: 'tool-1', name: 'Bash', input: { command: 'echo hello world' } }, + { type: 'tool_use', id: 'tool-2', name: 'Glob', input: { pattern: '**/*.ts' } }, + { type: 'tool_use', id: 'tool-3', name: 'Grep', input: { pattern: 'render' } }, + ], + }, + }), + ]; + + await writeFile(filePath, lines.join('\n'), 'utf8'); + + try { + const result = await parseTranscript(filePath); + const targets = new Map(result.tools.map((tool) => [tool.name, tool.target])); + assert.equal(targets.get('Bash'), 'echo hello world'); + assert.equal(targets.get('Glob'), '**/*.ts'); + assert.equal(targets.get('Grep'), 'render'); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('parseTranscript truncates long bash commands in targets', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-')); + const filePath = path.join(dir, 'bash.jsonl'); + const longCommand = 'echo ' + 'x'.repeat(50); + const lines = [ + JSON.stringify({ + message: { + content: [{ type: 'tool_use', id: 'tool-1', name: 'Bash', input: { command: longCommand } }], + }, + }), + ]; + + await writeFile(filePath, lines.join('\n'), 'utf8'); + + try { + const result = await parseTranscript(filePath); + assert.equal(result.tools.length, 1); + assert.ok(result.tools[0].target?.endsWith('...')); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('parseTranscript handles edge-case lines and error statuses', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-')); + const filePath = path.join(dir, 'edge-cases.jsonl'); + const lines = [ + ' ', + JSON.stringify({ message: { content: 'not-an-array' } }), + JSON.stringify({ + message: { + content: [ + { type: 'tool_use', id: 'agent-1', name: 'Task', input: {} }, + { type: 'tool_use', id: 'tool-error', name: 'Read', input: { path: '/tmp/fallback.txt' } }, + { type: 'tool_result', tool_use_id: 'tool-error', is_error: true }, + { type: 'tool_result', tool_use_id: 'missing-tool' }, + ], + }, + }), + ]; + + await writeFile(filePath, lines.join('\n'), 'utf8'); + + try { + const result = await parseTranscript(filePath); + const errorTool = result.tools.find((tool) => tool.id === 'tool-error'); + assert.equal(errorTool?.status, 'error'); + assert.equal(errorTool?.target, '/tmp/fallback.txt'); + assert.equal(result.agents[0]?.type, 'unknown'); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('parseTranscript returns undefined targets for unknown tools', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-')); + const filePath = path.join(dir, 'unknown-tools.jsonl'); + const lines = [ + JSON.stringify({ + message: { + content: [{ type: 'tool_use', id: 'tool-1', name: 'UnknownTool', input: { foo: 'bar' } }], + }, + }), + ]; + + await writeFile(filePath, lines.join('\n'), 'utf8'); + + try { + const result = await parseTranscript(filePath); + assert.equal(result.tools.length, 1); + assert.equal(result.tools[0].target, undefined); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('parseTranscript returns partial results when stream creation fails', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-')); + const transcriptDir = path.join(dir, 'transcript-dir'); + await mkdir(transcriptDir); + + try { + const result = await parseTranscript(transcriptDir); + assert.equal(result.tools.length, 0); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('countConfigs honors project and global config locations', async () => { + const homeDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-home-')); + const projectDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-project-')); + const originalHome = process.env.HOME; + process.env.HOME = homeDir; + + try { + await mkdir(path.join(homeDir, '.claude', 'rules', 'nested'), { recursive: true }); + await writeFile(path.join(homeDir, '.claude', 'CLAUDE.md'), 'global', 'utf8'); + await writeFile(path.join(homeDir, '.claude', 'rules', 'rule.md'), '# rule', 'utf8'); + await writeFile(path.join(homeDir, '.claude', 'rules', 'nested', 'rule-nested.md'), '# rule nested', 'utf8'); + await writeFile( + path.join(homeDir, '.claude', 'settings.json'), + JSON.stringify({ mcpServers: { one: {} }, hooks: { onStart: {} } }), + 'utf8' + ); + await writeFile(path.join(homeDir, '.claude.json'), '{bad json', 'utf8'); + + await mkdir(path.join(projectDir, '.claude', 'rules'), { recursive: true }); + await writeFile(path.join(projectDir, 'CLAUDE.md'), 'project', 'utf8'); + await writeFile(path.join(projectDir, 'CLAUDE.local.md'), 'project-local', 'utf8'); + await writeFile(path.join(projectDir, '.claude', 'CLAUDE.md'), 'project-alt', 'utf8'); + await writeFile(path.join(projectDir, '.claude', 'CLAUDE.local.md'), 'project-alt-local', 'utf8'); + await writeFile(path.join(projectDir, '.claude', 'rules', 'rule2.md'), '# rule2', 'utf8'); + await writeFile( + path.join(projectDir, '.claude', 'settings.json'), + JSON.stringify({ mcpServers: { two: {}, three: {} }, hooks: { onStop: {} } }), + 'utf8' + ); + await writeFile(path.join(projectDir, '.claude', 'settings.local.json'), '{bad json', 'utf8'); + await writeFile(path.join(projectDir, '.mcp.json'), JSON.stringify({ mcpServers: { four: {} } }), 'utf8'); + + const counts = await countConfigs(projectDir); + assert.equal(counts.claudeMdCount, 5); + assert.equal(counts.rulesCount, 3); + assert.equal(counts.mcpCount, 4); + assert.equal(counts.hooksCount, 2); + } finally { + process.env.HOME = originalHome; + await rm(homeDir, { recursive: true, force: true }); + await rm(projectDir, { recursive: true, force: true }); + } +}); + +test('countConfigs tolerates rule directory read errors', async () => { + const homeDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-home-')); + const originalHome = process.env.HOME; + process.env.HOME = homeDir; + + const rulesDir = path.join(homeDir, '.claude', 'rules'); + await mkdir(rulesDir, { recursive: true }); + fs.chmodSync(rulesDir, 0); + + try { + const counts = await countConfigs(); + assert.equal(counts.rulesCount, 0); + } finally { + fs.chmodSync(rulesDir, 0o755); + process.env.HOME = originalHome; + await rm(homeDir, { recursive: true, force: true }); + } +}); diff --git a/tests/fixtures/transcript-basic.jsonl b/tests/fixtures/transcript-basic.jsonl index 9aefd10..f1bd2d7 100644 --- a/tests/fixtures/transcript-basic.jsonl +++ b/tests/fixtures/transcript-basic.jsonl @@ -2,4 +2,4 @@ {"timestamp":"2024-01-01T00:00:01.000Z","message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","is_error":false}]}} {"timestamp":"2024-01-01T00:00:02.000Z","message":{"content":[{"type":"tool_use","id":"agent-1","name":"Task","input":{"subagent_type":"explore","model":"haiku"}}]}} {"timestamp":"2024-01-01T00:00:03.000Z","message":{"content":[{"type":"tool_result","tool_use_id":"agent-1","is_error":false}]}} -{"timestamp":"2024-01-01T00:00:04.000Z","message":{"content":[{"type":"tool_use","id":"todo-1","name":"TodoWrite","input":{"todos":[{"content":"First task","status":"completed"},{"content":"Second task","status":"in_progress"}]}}}]}} +{"timestamp":"2024-01-01T00:00:04.000Z","message":{"content":[{"type":"tool_use","id":"todo-1","name":"TodoWrite","input":{"todos":[{"content":"First task","status":"completed"},{"content":"Second task","status":"in_progress"}]}}]}} diff --git a/tests/fixtures/transcript-render.jsonl b/tests/fixtures/transcript-render.jsonl index c4d766e..a54e724 100644 --- a/tests/fixtures/transcript-render.jsonl +++ b/tests/fixtures/transcript-render.jsonl @@ -3,4 +3,4 @@ {"message":{"content":[{"type":"tool_use","id":"tool-2","name":"Edit","input":{"file_path":"/tmp/very/long/path/to/authentication.ts"}}]}} {"message":{"content":[{"type":"tool_use","id":"agent-1","name":"Task","input":{"subagent_type":"explore","model":"haiku","description":"Finding auth code"}}]}} {"message":{"content":[{"type":"tool_result","tool_use_id":"agent-1","is_error":false}]}} -{"message":{"content":[{"type":"tool_use","id":"todo-1","name":"TodoWrite","input":{"todos":[{"content":"Fix auth bug","status":"completed"},{"content":"Add tests","status":"in_progress"}]}}}]} +{"message":{"content":[{"type":"tool_use","id":"todo-1","name":"TodoWrite","input":{"todos":[{"content":"Fix auth bug","status":"completed"},{"content":"Add tests","status":"in_progress"}]}}]}} diff --git a/tests/index.test.js b/tests/index.test.js new file mode 100644 index 0000000..466ff6d --- /dev/null +++ b/tests/index.test.js @@ -0,0 +1,106 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { formatSessionDuration, main } from '../dist/index.js'; + +test('formatSessionDuration returns empty string without session start', () => { + assert.equal(formatSessionDuration(undefined, () => 0), ''); +}); + +test('formatSessionDuration formats sub-minute and minute durations', () => { + const start = new Date(0); + assert.equal(formatSessionDuration(start, () => 30 * 1000), '<1m'); + assert.equal(formatSessionDuration(start, () => 5 * 60 * 1000), '5m'); +}); + +test('formatSessionDuration formats hour durations', () => { + const start = new Date(0); + assert.equal(formatSessionDuration(start, () => 2 * 60 * 60 * 1000 + 5 * 60 * 1000), '2h 5m'); +}); + +test('formatSessionDuration uses Date.now by default', () => { + const originalNow = Date.now; + Date.now = () => 60000; + try { + const result = formatSessionDuration(new Date(0)); + assert.equal(result, '1m'); + } finally { + Date.now = originalNow; + } +}); + +test('main logs an error when dependencies throw', async () => { + const logs = []; + await main({ + readStdin: async () => { + throw new Error('boom'); + }, + parseTranscript: async () => ({ tools: [], agents: [], todos: [] }), + countConfigs: async () => ({ claudeMdCount: 0, rulesCount: 0, mcpCount: 0, hooksCount: 0 }), + render: () => {}, + now: () => Date.now(), + log: (...args) => logs.push(args.join(' ')), + }); + + assert.ok(logs.some((line) => line.includes('[claude-hud] Error:'))); +}); + +test('main logs unknown error for non-Error throws', async () => { + const logs = []; + await main({ + readStdin: async () => { + throw 'boom'; + }, + parseTranscript: async () => ({ tools: [], agents: [], todos: [] }), + countConfigs: async () => ({ claudeMdCount: 0, rulesCount: 0, mcpCount: 0, hooksCount: 0 }), + render: () => {}, + now: () => Date.now(), + log: (...args) => logs.push(args.join(' ')), + }); + + assert.ok(logs.some((line) => line.includes('Unknown error'))); +}); + +test('index entrypoint runs when executed directly', async () => { + const originalArgv = [...process.argv]; + const originalIsTTY = process.stdin.isTTY; + const originalLog = console.log; + const logs = []; + + try { + const moduleUrl = new URL('../dist/index.js', import.meta.url); + process.argv[1] = new URL(moduleUrl).pathname; + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + console.log = (...args) => logs.push(args.join(' ')); + await import(`${moduleUrl}?entry=${Date.now()}`); + } finally { + console.log = originalLog; + process.argv = originalArgv; + Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true }); + } + + assert.ok(logs.some((line) => line.includes('[claude-hud] Initializing...'))); +}); + +test('main executes the happy path with default dependencies', async () => { + const originalNow = Date.now; + Date.now = () => 60000; + let renderedContext; + + try { + await main({ + readStdin: async () => ({ + model: { display_name: 'Opus' }, + context_window: { context_window_size: 100, current_usage: { input_tokens: 90 } }, + }), + parseTranscript: async () => ({ tools: [], agents: [], todos: [], sessionStart: new Date(0) }), + countConfigs: async () => ({ claudeMdCount: 0, rulesCount: 0, mcpCount: 0, hooksCount: 0 }), + render: (ctx) => { + renderedContext = ctx; + }, + }); + } finally { + Date.now = originalNow; + } + + assert.equal(renderedContext?.sessionDuration, '1m'); +}); diff --git a/tests/integration.test.js b/tests/integration.test.js index cade260..b9f8bd4 100644 --- a/tests/integration.test.js +++ b/tests/integration.test.js @@ -2,7 +2,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { readFileSync } from 'node:fs'; @@ -40,8 +40,24 @@ test('CLI renders expected output for a basic transcript', async () => { assert.equal(result.status, 0, result.stderr || 'non-zero exit'); const normalized = stripAnsi(result.stdout).replace(/\u00A0/g, ' ').trimEnd(); + if (process.env.UPDATE_SNAPSHOTS === '1') { + await writeFile(expectedPath, normalized + '\n', 'utf8'); + return; + } assert.equal(normalized, expected); } finally { await rm(homeDir, { recursive: true, force: true }); } }); + +test('CLI prints initializing message on empty stdin', () => { + const result = spawnSync('node', ['dist/index.js'], { + cwd: path.resolve(process.cwd()), + input: '', + encoding: 'utf8', + }); + + assert.equal(result.status, 0, result.stderr || 'non-zero exit'); + const normalized = stripAnsi(result.stdout).replace(/\u00A0/g, ' ').trimEnd(); + assert.equal(normalized, '[claude-hud] Initializing...'); +}); diff --git a/tests/render.test.js b/tests/render.test.js index 73e137c..c3eb4cc 100644 --- a/tests/render.test.js +++ b/tests/render.test.js @@ -4,6 +4,7 @@ import { renderSessionLine } from '../dist/render/session-line.js'; import { renderToolsLine } from '../dist/render/tools-line.js'; import { renderAgentsLine } from '../dist/render/agents-line.js'; import { renderTodosLine } from '../dist/render/todos-line.js'; +import { getContextColor } from '../dist/render/colors.js'; function baseContext() { return { @@ -42,6 +43,56 @@ test('renderSessionLine shows compact warning at critical threshold', () => { assert.ok(line.includes('COMPACT')); }); +test('renderSessionLine includes duration and formats large tokens', () => { + const ctx = baseContext(); + ctx.sessionDuration = '1m'; + ctx.stdin.context_window.context_window_size = 1100000; + ctx.stdin.context_window.current_usage.input_tokens = 1000000; + ctx.stdin.context_window.current_usage.cache_read_input_tokens = 1500; + const line = renderSessionLine(ctx); + assert.ok(line.includes('⏱️')); + assert.ok(line.includes('1.0M')); + assert.ok(line.includes('2k')); +}); + +test('renderSessionLine handles missing input tokens and cache creation usage', () => { + const ctx = baseContext(); + ctx.stdin.context_window.context_window_size = 100; + ctx.stdin.context_window.current_usage = { + cache_creation_input_tokens: 90, + }; + const line = renderSessionLine(ctx); + assert.ok(line.includes('90%')); + assert.ok(line.includes('in: 0')); +}); + +test('renderSessionLine handles missing cache token fields', () => { + const ctx = baseContext(); + ctx.stdin.context_window.context_window_size = 100; + ctx.stdin.context_window.current_usage = { + input_tokens: 90, + }; + const line = renderSessionLine(ctx); + assert.ok(line.includes('cache: 0')); +}); + +test('getContextColor returns yellow for warning threshold', () => { + assert.equal(getContextColor(70), '\x1b[33m'); +}); + +test('renderSessionLine includes config counts when present', () => { + const ctx = baseContext(); + ctx.claudeMdCount = 1; + ctx.rulesCount = 2; + ctx.mcpCount = 3; + ctx.hooksCount = 4; + const line = renderSessionLine(ctx); + assert.ok(line.includes('CLAUDE.md')); + assert.ok(line.includes('rules')); + assert.ok(line.includes('MCPs')); + assert.ok(line.includes('hooks')); +}); + test('renderToolsLine renders running and completed tools', () => { const ctx = baseContext(); ctx.transcript.tools = [ @@ -56,7 +107,7 @@ test('renderToolsLine renders running and completed tools', () => { { id: 'tool-2', name: 'Edit', - target: '/tmp/example.txt', + target: '/tmp/very/long/path/to/authentication.ts', status: 'running', startTime: new Date(0), }, @@ -65,6 +116,82 @@ test('renderToolsLine renders running and completed tools', () => { const line = renderToolsLine(ctx); assert.ok(line?.includes('Read')); assert.ok(line?.includes('Edit')); + assert.ok(line?.includes('.../authentication.ts')); +}); + +test('renderToolsLine truncates long filenames', () => { + const ctx = baseContext(); + ctx.transcript.tools = [ + { + id: 'tool-1', + name: 'Edit', + target: '/tmp/this-is-a-very-very-long-filename.ts', + status: 'running', + startTime: new Date(0), + }, + ]; + + const line = renderToolsLine(ctx); + assert.ok(line?.includes('...')); + assert.ok(!line?.includes('/tmp/')); +}); + +test('renderToolsLine handles trailing slash paths', () => { + const ctx = baseContext(); + ctx.transcript.tools = [ + { + id: 'tool-1', + name: 'Read', + target: '/tmp/very/long/path/with/trailing/', + status: 'running', + startTime: new Date(0), + }, + ]; + + const line = renderToolsLine(ctx); + assert.ok(line?.includes('...')); +}); + +test('renderToolsLine preserves short targets and handles missing targets', () => { + const ctx = baseContext(); + ctx.transcript.tools = [ + { + id: 'tool-1', + name: 'Read', + target: 'short.txt', + status: 'running', + startTime: new Date(0), + }, + { + id: 'tool-2', + name: 'Write', + status: 'running', + startTime: new Date(0), + }, + ]; + + const line = renderToolsLine(ctx); + assert.ok(line?.includes('short.txt')); + assert.ok(line?.includes('Write')); +}); + +test('renderToolsLine returns null when tools are unrecognized', () => { + const ctx = baseContext(); + ctx.transcript.tools = [ + { + id: 'tool-1', + name: 'WeirdTool', + status: 'unknown', + startTime: new Date(0), + }, + ]; + + assert.equal(renderToolsLine(ctx), null); +}); + +test('renderAgentsLine returns null when no agents exist', () => { + const ctx = baseContext(); + assert.equal(renderAgentsLine(ctx), null); }); test('renderAgentsLine renders completed agents', () => { @@ -87,6 +214,55 @@ test('renderAgentsLine renders completed agents', () => { assert.ok(line?.includes('haiku')); }); +test('renderAgentsLine truncates long descriptions and formats elapsed time', () => { + const ctx = baseContext(); + ctx.transcript.agents = [ + { + id: 'agent-1', + type: 'explore', + model: 'haiku', + description: 'A very long description that should be truncated in the HUD output', + status: 'completed', + startTime: new Date(0), + endTime: new Date(1500), + }, + { + id: 'agent-2', + type: 'analyze', + status: 'completed', + startTime: new Date(0), + endTime: new Date(65000), + }, + ]; + + const line = renderAgentsLine(ctx); + assert.ok(line?.includes('...')); + assert.ok(line?.includes('2s')); + assert.ok(line?.includes('1m')); +}); + +test('renderAgentsLine renders running agents with live elapsed time', () => { + const ctx = baseContext(); + const originalNow = Date.now; + Date.now = () => 2000; + + try { + ctx.transcript.agents = [ + { + id: 'agent-1', + type: 'plan', + status: 'running', + startTime: new Date(0), + }, + ]; + + const line = renderAgentsLine(ctx); + assert.ok(line?.includes('◐')); + assert.ok(line?.includes('2s')); + } finally { + Date.now = originalNow; + } +}); test('renderTodosLine handles in-progress and completed-only cases', () => { const ctx = baseContext(); ctx.transcript.todos = [ @@ -98,3 +274,34 @@ test('renderTodosLine handles in-progress and completed-only cases', () => { ctx.transcript.todos = [{ content: 'First task', status: 'completed' }]; assert.ok(renderTodosLine(ctx)?.includes('All todos complete')); }); + +test('renderTodosLine returns null when no todos are in progress', () => { + const ctx = baseContext(); + ctx.transcript.todos = [ + { content: 'First task', status: 'completed' }, + { content: 'Second task', status: 'pending' }, + ]; + assert.equal(renderTodosLine(ctx), null); +}); + +test('renderTodosLine truncates long todo content', () => { + const ctx = baseContext(); + ctx.transcript.todos = [ + { + content: 'This is a very long todo content that should be truncated for display', + status: 'in_progress', + }, + ]; + const line = renderTodosLine(ctx); + assert.ok(line?.includes('...')); +}); + +test('renderTodosLine returns null when no todos exist', () => { + const ctx = baseContext(); + assert.equal(renderTodosLine(ctx), null); +}); + +test('renderToolsLine returns null when no tools exist', () => { + const ctx = baseContext(); + assert.equal(renderToolsLine(ctx), null); +}); diff --git a/tests/stdin.test.js b/tests/stdin.test.js new file mode 100644 index 0000000..900ec4e --- /dev/null +++ b/tests/stdin.test.js @@ -0,0 +1,32 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readStdin } from '../dist/stdin.js'; + +test('readStdin returns null for TTY input', async () => { + const originalIsTTY = process.stdin.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + + try { + const result = await readStdin(); + assert.equal(result, null); + } finally { + Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true }); + } +}); + +test('readStdin returns null on stream errors', async () => { + const originalIsTTY = process.stdin.isTTY; + const originalSetEncoding = process.stdin.setEncoding; + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + process.stdin.setEncoding = () => { + throw new Error('boom'); + }; + + try { + const result = await readStdin(); + assert.equal(result, null); + } finally { + process.stdin.setEncoding = originalSetEncoding; + Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true }); + } +});