mirror of
https://github.com/jarrodwatts/claude-hud.git
synced 2026-05-10 16:02:38 +00:00
* feat(config): add configurable usage cache TTL settings Add cacheTtlSeconds and failureCacheTtlSeconds to the usage section of config.json, allowing users to control how often the Anthropic usage API is fetched (default 60s) and how quickly failures are retried (default 15s). * docs: document usage cache TTL config options * refactor(usage): bundle cache TTL params into single CacheTtls object Replace cacheTtlMs/failureCacheTtlMs pair with a CacheTtls type wherever they appear together, reducing parameter count in readCacheState, readCache, waitForFreshCache, and UsageApiDeps. * chore(build): drop dist changes from pr --------- Co-authored-by: Jarrod Watts <jarrod@cubelabs.xyz>
170 lines
5.8 KiB
JavaScript
170 lines
5.8 KiB
JavaScript
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 }),
|
|
getGitBranch: async () => null,
|
|
getUsage: async () => null,
|
|
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 }),
|
|
getGitBranch: async () => null,
|
|
getUsage: async () => null,
|
|
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 }),
|
|
getGitBranch: async () => null,
|
|
getUsage: async () => null,
|
|
render: (ctx) => {
|
|
renderedContext = ctx;
|
|
},
|
|
});
|
|
} finally {
|
|
Date.now = originalNow;
|
|
}
|
|
|
|
assert.equal(renderedContext?.sessionDuration, '1m');
|
|
});
|
|
|
|
test('main includes git status in render context', async () => {
|
|
let renderedContext;
|
|
|
|
await main({
|
|
readStdin: async () => ({
|
|
model: { display_name: 'Opus' },
|
|
context_window: { context_window_size: 100, current_usage: { input_tokens: 10 } },
|
|
cwd: '/some/path',
|
|
}),
|
|
parseTranscript: async () => ({ tools: [], agents: [], todos: [] }),
|
|
countConfigs: async () => ({ claudeMdCount: 0, rulesCount: 0, mcpCount: 0, hooksCount: 0 }),
|
|
getGitStatus: async () => ({ branch: 'feature/test', isDirty: false, ahead: 0, behind: 0 }),
|
|
getUsage: async () => null,
|
|
loadConfig: async () => ({
|
|
lineLayout: 'compact',
|
|
showSeparators: false,
|
|
pathLevels: 1,
|
|
gitStatus: { enabled: true, showDirty: true, showAheadBehind: false, showFileStats: false },
|
|
display: { showModel: true, showContextBar: true, contextValue: 'percent', showConfigCounts: true, showDuration: true, showSpeed: false, showTokenBreakdown: true, showUsage: true, showTools: true, showAgents: true, showTodos: true, autocompactBuffer: 'enabled', usageThreshold: 0, sevenDayThreshold: 80, environmentThreshold: 0 },
|
|
usage: { cacheTtlSeconds: 60, failureCacheTtlSeconds: 15 },
|
|
}),
|
|
render: (ctx) => {
|
|
renderedContext = ctx;
|
|
},
|
|
});
|
|
|
|
assert.equal(renderedContext?.gitStatus?.branch, 'feature/test');
|
|
});
|
|
|
|
test('main includes usageData in render context', async () => {
|
|
let renderedContext;
|
|
const mockUsageData = {
|
|
planName: 'Max',
|
|
fiveHour: 50,
|
|
sevenDay: 25,
|
|
fiveHourResetAt: null,
|
|
sevenDayResetAt: null,
|
|
limitReached: false,
|
|
};
|
|
|
|
await main({
|
|
readStdin: async () => ({
|
|
model: { display_name: 'Opus' },
|
|
context_window: { context_window_size: 100, current_usage: { input_tokens: 10 } },
|
|
}),
|
|
parseTranscript: async () => ({ tools: [], agents: [], todos: [] }),
|
|
countConfigs: async () => ({ claudeMdCount: 0, rulesCount: 0, mcpCount: 0, hooksCount: 0 }),
|
|
getGitBranch: async () => null,
|
|
getUsage: async () => mockUsageData,
|
|
render: (ctx) => {
|
|
renderedContext = ctx;
|
|
},
|
|
});
|
|
|
|
assert.deepEqual(renderedContext?.usageData, mockUsageData);
|
|
});
|