SuperCharge Claude Code v1.0.0 - Complete Customization Package
Features: - 30+ Custom Skills (cognitive, development, UI/UX, autonomous agents) - RalphLoop autonomous agent integration - Multi-AI consultation (Qwen) - Agent management system with sync capabilities - Custom hooks for session management - MCP servers integration - Plugin marketplace setup - Comprehensive installation script Components: - Skills: always-use-superpowers, ralph, brainstorming, ui-ux-pro-max, etc. - Agents: 100+ agents across engineering, marketing, product, etc. - Hooks: session-start-superpowers, qwen-consult, ralph-auto-trigger - Commands: /brainstorm, /write-plan, /execute-plan - MCP Servers: zai-mcp-server, web-search-prime, web-reader, zread - Binaries: ralphloop wrapper Installation: ./supercharge.sh
This commit is contained in:
43
plugins/claude-hud/tests/config.test.js
Normal file
43
plugins/claude-hud/tests/config.test.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { loadConfig, getConfigPath } from '../dist/config.js';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
|
||||
test('loadConfig returns valid config structure', async () => {
|
||||
const config = await loadConfig();
|
||||
|
||||
// pathLevels must be 1, 2, or 3
|
||||
assert.ok([1, 2, 3].includes(config.pathLevels), 'pathLevels should be 1, 2, or 3');
|
||||
|
||||
// lineLayout must be valid
|
||||
const validLineLayouts = ['compact', 'expanded'];
|
||||
assert.ok(validLineLayouts.includes(config.lineLayout), 'lineLayout should be valid');
|
||||
|
||||
// showSeparators must be boolean
|
||||
assert.equal(typeof config.showSeparators, 'boolean', 'showSeparators should be boolean');
|
||||
|
||||
// gitStatus object with expected properties
|
||||
assert.equal(typeof config.gitStatus, 'object');
|
||||
assert.equal(typeof config.gitStatus.enabled, 'boolean');
|
||||
assert.equal(typeof config.gitStatus.showDirty, 'boolean');
|
||||
assert.equal(typeof config.gitStatus.showAheadBehind, 'boolean');
|
||||
|
||||
// display object with expected properties
|
||||
assert.equal(typeof config.display, 'object');
|
||||
assert.equal(typeof config.display.showModel, 'boolean');
|
||||
assert.equal(typeof config.display.showContextBar, 'boolean');
|
||||
assert.equal(typeof config.display.showConfigCounts, 'boolean');
|
||||
assert.equal(typeof config.display.showDuration, 'boolean');
|
||||
assert.equal(typeof config.display.showTokenBreakdown, 'boolean');
|
||||
assert.equal(typeof config.display.showUsage, 'boolean');
|
||||
assert.equal(typeof config.display.showTools, 'boolean');
|
||||
assert.equal(typeof config.display.showAgents, 'boolean');
|
||||
assert.equal(typeof config.display.showTodos, 'boolean');
|
||||
});
|
||||
|
||||
test('getConfigPath returns correct path', () => {
|
||||
const configPath = getConfigPath();
|
||||
const homeDir = os.homedir();
|
||||
assert.equal(configPath, path.join(homeDir, '.claude', 'plugins', 'claude-hud', 'config.json'));
|
||||
});
|
||||
636
plugins/claude-hud/tests/core.test.js
Normal file
636
plugins/claude-hud/tests/core.test.js
Normal file
@@ -0,0 +1,636 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
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 { countConfigs } from '../dist/config-reader.js';
|
||||
import { getContextPercent, getBufferedPercent, getModelName } from '../dist/stdin.js';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
test('getContextPercent returns 0 when data is missing', () => {
|
||||
assert.equal(getContextPercent({}), 0);
|
||||
assert.equal(getContextPercent({ context_window: { context_window_size: 0 } }), 0);
|
||||
assert.equal(getBufferedPercent({}), 0);
|
||||
assert.equal(getBufferedPercent({ context_window: { context_window_size: 0 } }), 0);
|
||||
});
|
||||
|
||||
test('getContextPercent returns raw percentage without buffer', () => {
|
||||
// 55000 / 200000 = 27.5% → rounds to 28%
|
||||
const percent = getContextPercent({
|
||||
context_window: {
|
||||
context_window_size: 200000,
|
||||
current_usage: {
|
||||
input_tokens: 30000,
|
||||
cache_creation_input_tokens: 12500,
|
||||
cache_read_input_tokens: 12500,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(percent, 28);
|
||||
});
|
||||
|
||||
test('getBufferedPercent includes 22.5% buffer', () => {
|
||||
// 55000 / 200000 = 27.5%, + 22.5% buffer = 50%
|
||||
const percent = getBufferedPercent({
|
||||
context_window: {
|
||||
context_window_size: 200000,
|
||||
current_usage: {
|
||||
input_tokens: 30000,
|
||||
cache_creation_input_tokens: 12500,
|
||||
cache_read_input_tokens: 12500,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(percent, 50);
|
||||
});
|
||||
|
||||
test('getContextPercent handles missing input tokens', () => {
|
||||
// 5000 / 200000 = 2.5% → rounds to 3%
|
||||
const percent = getContextPercent({
|
||||
context_window: {
|
||||
context_window_size: 200000,
|
||||
current_usage: {
|
||||
cache_creation_input_tokens: 3000,
|
||||
cache_read_input_tokens: 2000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(percent, 3);
|
||||
});
|
||||
|
||||
test('getBufferedPercent scales to larger context windows', () => {
|
||||
// Test with 1M context window: 45000 tokens + (1000000 * 0.225) buffer
|
||||
// Raw: 45000 / 1000000 = 4.5% → 5%
|
||||
// Buffered: (45000 + 225000) / 1000000 = 27% → 27%
|
||||
const rawPercent = getContextPercent({
|
||||
context_window: {
|
||||
context_window_size: 1000000,
|
||||
current_usage: { input_tokens: 45000 },
|
||||
},
|
||||
});
|
||||
const bufferedPercent = getBufferedPercent({
|
||||
context_window: {
|
||||
context_window_size: 1000000,
|
||||
current_usage: { input_tokens: 45000 },
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(rawPercent, 5);
|
||||
assert.equal(bufferedPercent, 27);
|
||||
});
|
||||
|
||||
// Native percentage tests (Claude Code v2.1.6+)
|
||||
test('getContextPercent prefers native used_percentage when available', () => {
|
||||
const percent = getContextPercent({
|
||||
context_window: {
|
||||
context_window_size: 200000,
|
||||
current_usage: { input_tokens: 55000 }, // would be 28% raw
|
||||
used_percentage: 47, // native value takes precedence
|
||||
},
|
||||
});
|
||||
assert.equal(percent, 47);
|
||||
});
|
||||
|
||||
test('getBufferedPercent prefers native used_percentage when available', () => {
|
||||
const percent = getBufferedPercent({
|
||||
context_window: {
|
||||
context_window_size: 200000,
|
||||
current_usage: { input_tokens: 55000 }, // would be 50% buffered
|
||||
used_percentage: 47, // native value takes precedence
|
||||
},
|
||||
});
|
||||
assert.equal(percent, 47);
|
||||
});
|
||||
|
||||
test('getContextPercent falls back when native is null', () => {
|
||||
const percent = getContextPercent({
|
||||
context_window: {
|
||||
context_window_size: 200000,
|
||||
current_usage: { input_tokens: 55000 },
|
||||
used_percentage: null,
|
||||
},
|
||||
});
|
||||
assert.equal(percent, 28); // raw calculation
|
||||
});
|
||||
|
||||
test('getBufferedPercent falls back when native is null', () => {
|
||||
const percent = getBufferedPercent({
|
||||
context_window: {
|
||||
context_window_size: 200000,
|
||||
current_usage: { input_tokens: 55000 },
|
||||
used_percentage: null,
|
||||
},
|
||||
});
|
||||
assert.equal(percent, 50); // buffered calculation
|
||||
});
|
||||
|
||||
test('native percentage handles zero correctly', () => {
|
||||
assert.equal(getContextPercent({ context_window: { used_percentage: 0 } }), 0);
|
||||
assert.equal(getBufferedPercent({ context_window: { used_percentage: 0 } }), 0);
|
||||
});
|
||||
|
||||
test('native percentage clamps negative values to 0', () => {
|
||||
assert.equal(getContextPercent({ context_window: { used_percentage: -5 } }), 0);
|
||||
assert.equal(getBufferedPercent({ context_window: { used_percentage: -10 } }), 0);
|
||||
});
|
||||
|
||||
test('native percentage clamps values over 100 to 100', () => {
|
||||
assert.equal(getContextPercent({ context_window: { used_percentage: 150 } }), 100);
|
||||
assert.equal(getBufferedPercent({ context_window: { used_percentage: 200 } }), 100);
|
||||
});
|
||||
|
||||
test('native percentage falls back when NaN', () => {
|
||||
const percent = getContextPercent({
|
||||
context_window: {
|
||||
context_window_size: 200000,
|
||||
current_usage: { input_tokens: 55000 },
|
||||
used_percentage: NaN,
|
||||
},
|
||||
});
|
||||
assert.equal(percent, 28); // falls back to raw calculation
|
||||
});
|
||||
|
||||
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);
|
||||
assert.equal(result.tools.length, 1);
|
||||
assert.equal(result.tools[0].status, 'completed');
|
||||
assert.equal(result.tools[0].target, '/tmp/example.txt');
|
||||
assert.equal(result.agents.length, 1);
|
||||
assert.equal(result.agents[0].status, 'completed');
|
||||
assert.equal(result.todos.length, 2);
|
||||
assert.equal(result.todos[1].status, 'in_progress');
|
||||
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');
|
||||
const lines = [
|
||||
'{"timestamp":"2024-01-01T00:00:00.000Z","message":{"content":[{"type":"tool_use","id":"tool-1","name":"Read"}]}}',
|
||||
'{not-json}',
|
||||
'{"message":{"content":[{"type":"tool_result","tool_use_id":"tool-1"}]}}',
|
||||
'',
|
||||
];
|
||||
|
||||
await writeFile(filePath, lines.join('\n'), 'utf8');
|
||||
|
||||
try {
|
||||
const result = await parseTranscript(filePath);
|
||||
assert.equal(result.tools.length, 1);
|
||||
assert.equal(result.tools[0].status, 'completed');
|
||||
} finally {
|
||||
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 excludes disabled user-scope MCPs', async () => {
|
||||
const homeDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-home-'));
|
||||
const originalHome = process.env.HOME;
|
||||
process.env.HOME = homeDir;
|
||||
|
||||
try {
|
||||
await mkdir(path.join(homeDir, '.claude'), { recursive: true });
|
||||
// 3 MCPs defined in settings.json
|
||||
await writeFile(
|
||||
path.join(homeDir, '.claude', 'settings.json'),
|
||||
JSON.stringify({ mcpServers: { server1: {}, server2: {}, server3: {} } }),
|
||||
'utf8'
|
||||
);
|
||||
// 1 MCP disabled in ~/.claude.json
|
||||
await writeFile(
|
||||
path.join(homeDir, '.claude.json'),
|
||||
JSON.stringify({ disabledMcpServers: ['server2'] }),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const counts = await countConfigs();
|
||||
assert.equal(counts.mcpCount, 2); // 3 - 1 disabled = 2
|
||||
} finally {
|
||||
process.env.HOME = originalHome;
|
||||
await rm(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('countConfigs excludes disabled project .mcp.json servers', 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'), { recursive: true });
|
||||
await mkdir(path.join(projectDir, '.claude'), { recursive: true });
|
||||
|
||||
// 4 MCPs in .mcp.json
|
||||
await writeFile(
|
||||
path.join(projectDir, '.mcp.json'),
|
||||
JSON.stringify({ mcpServers: { mcp1: {}, mcp2: {}, mcp3: {}, mcp4: {} } }),
|
||||
'utf8'
|
||||
);
|
||||
// 2 disabled via disabledMcpjsonServers
|
||||
await writeFile(
|
||||
path.join(projectDir, '.claude', 'settings.local.json'),
|
||||
JSON.stringify({ disabledMcpjsonServers: ['mcp2', 'mcp4'] }),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const counts = await countConfigs(projectDir);
|
||||
assert.equal(counts.mcpCount, 2); // 4 - 2 disabled = 2
|
||||
} finally {
|
||||
process.env.HOME = originalHome;
|
||||
await rm(homeDir, { recursive: true, force: true });
|
||||
await rm(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('countConfigs handles all MCPs disabled', async () => {
|
||||
const homeDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-home-'));
|
||||
const originalHome = process.env.HOME;
|
||||
process.env.HOME = homeDir;
|
||||
|
||||
try {
|
||||
await mkdir(path.join(homeDir, '.claude'), { recursive: true });
|
||||
// 2 MCPs defined
|
||||
await writeFile(
|
||||
path.join(homeDir, '.claude', 'settings.json'),
|
||||
JSON.stringify({ mcpServers: { serverA: {}, serverB: {} } }),
|
||||
'utf8'
|
||||
);
|
||||
// Both disabled
|
||||
await writeFile(
|
||||
path.join(homeDir, '.claude.json'),
|
||||
JSON.stringify({ disabledMcpServers: ['serverA', 'serverB'] }),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const counts = await countConfigs();
|
||||
assert.equal(counts.mcpCount, 0); // All disabled
|
||||
} finally {
|
||||
process.env.HOME = originalHome;
|
||||
await rm(homeDir, { 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 });
|
||||
}
|
||||
});
|
||||
|
||||
test('countConfigs ignores non-string values in disabledMcpServers', async () => {
|
||||
const homeDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-home-'));
|
||||
const originalHome = process.env.HOME;
|
||||
process.env.HOME = homeDir;
|
||||
|
||||
try {
|
||||
await mkdir(path.join(homeDir, '.claude'), { recursive: true });
|
||||
// 3 MCPs defined
|
||||
await writeFile(
|
||||
path.join(homeDir, '.claude', 'settings.json'),
|
||||
JSON.stringify({ mcpServers: { server1: {}, server2: {}, server3: {} } }),
|
||||
'utf8'
|
||||
);
|
||||
// disabledMcpServers contains mixed types - only 'server2' is a valid string
|
||||
await writeFile(
|
||||
path.join(homeDir, '.claude.json'),
|
||||
JSON.stringify({ disabledMcpServers: [123, null, 'server2', { name: 'server3' }, [], true] }),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const counts = await countConfigs();
|
||||
assert.equal(counts.mcpCount, 2); // Only 'server2' disabled, server1 and server3 remain
|
||||
} finally {
|
||||
process.env.HOME = originalHome;
|
||||
await rm(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('countConfigs counts same-named servers in different scopes separately', 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'), { recursive: true });
|
||||
await mkdir(path.join(projectDir, '.claude'), { recursive: true });
|
||||
|
||||
// User scope: server named 'shared-server'
|
||||
await writeFile(
|
||||
path.join(homeDir, '.claude', 'settings.json'),
|
||||
JSON.stringify({ mcpServers: { 'shared-server': {}, 'user-only': {} } }),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
// Project scope: also has 'shared-server' (different config, same name)
|
||||
await writeFile(
|
||||
path.join(projectDir, '.mcp.json'),
|
||||
JSON.stringify({ mcpServers: { 'shared-server': {}, 'project-only': {} } }),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const counts = await countConfigs(projectDir);
|
||||
// 'shared-server' counted in BOTH scopes (user + project) = 4 total
|
||||
assert.equal(counts.mcpCount, 4);
|
||||
} finally {
|
||||
process.env.HOME = originalHome;
|
||||
await rm(homeDir, { recursive: true, force: true });
|
||||
await rm(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('countConfigs uses case-sensitive matching for disabled servers', async () => {
|
||||
const homeDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-home-'));
|
||||
const originalHome = process.env.HOME;
|
||||
process.env.HOME = homeDir;
|
||||
|
||||
try {
|
||||
await mkdir(path.join(homeDir, '.claude'), { recursive: true });
|
||||
// MCP named 'MyServer' (mixed case)
|
||||
await writeFile(
|
||||
path.join(homeDir, '.claude', 'settings.json'),
|
||||
JSON.stringify({ mcpServers: { MyServer: {}, otherServer: {} } }),
|
||||
'utf8'
|
||||
);
|
||||
// Try to disable with wrong case - should NOT work
|
||||
await writeFile(
|
||||
path.join(homeDir, '.claude.json'),
|
||||
JSON.stringify({ disabledMcpServers: ['myserver', 'MYSERVER', 'OTHERSERVER'] }),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const counts = await countConfigs();
|
||||
// Both servers should still be enabled (case mismatch means not disabled)
|
||||
assert.equal(counts.mcpCount, 2);
|
||||
} finally {
|
||||
process.env.HOME = originalHome;
|
||||
await rm(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Regression test for GitHub Issue #3:
|
||||
// "MCP count showing 5 when user has 6, still showing 5 when all disabled"
|
||||
// https://github.com/jarrodwatts/claude-hud/issues/3
|
||||
test('Issue #3: MCP count updates correctly when servers are disabled', async () => {
|
||||
const homeDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-home-'));
|
||||
const originalHome = process.env.HOME;
|
||||
process.env.HOME = homeDir;
|
||||
|
||||
try {
|
||||
await mkdir(path.join(homeDir, '.claude'), { recursive: true });
|
||||
|
||||
// User has 6 MCPs configured (simulating the issue reporter's setup)
|
||||
await writeFile(
|
||||
path.join(homeDir, '.claude.json'),
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
mcp1: { command: 'cmd1' },
|
||||
mcp2: { command: 'cmd2' },
|
||||
mcp3: { command: 'cmd3' },
|
||||
mcp4: { command: 'cmd4' },
|
||||
mcp5: { command: 'cmd5' },
|
||||
mcp6: { command: 'cmd6' },
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
// Scenario 1: No servers disabled - should show 6
|
||||
let counts = await countConfigs();
|
||||
assert.equal(counts.mcpCount, 6, 'Should show all 6 MCPs when none disabled');
|
||||
|
||||
// Scenario 2: 1 server disabled - should show 5 (this was the initial bug report state)
|
||||
await writeFile(
|
||||
path.join(homeDir, '.claude.json'),
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
mcp1: { command: 'cmd1' },
|
||||
mcp2: { command: 'cmd2' },
|
||||
mcp3: { command: 'cmd3' },
|
||||
mcp4: { command: 'cmd4' },
|
||||
mcp5: { command: 'cmd5' },
|
||||
mcp6: { command: 'cmd6' },
|
||||
},
|
||||
disabledMcpServers: ['mcp1'],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
counts = await countConfigs();
|
||||
assert.equal(counts.mcpCount, 5, 'Should show 5 MCPs when 1 is disabled');
|
||||
|
||||
// Scenario 3: ALL servers disabled - should show 0 (this was the main bug)
|
||||
await writeFile(
|
||||
path.join(homeDir, '.claude.json'),
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
mcp1: { command: 'cmd1' },
|
||||
mcp2: { command: 'cmd2' },
|
||||
mcp3: { command: 'cmd3' },
|
||||
mcp4: { command: 'cmd4' },
|
||||
mcp5: { command: 'cmd5' },
|
||||
mcp6: { command: 'cmd6' },
|
||||
},
|
||||
disabledMcpServers: ['mcp1', 'mcp2', 'mcp3', 'mcp4', 'mcp5', 'mcp6'],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
counts = await countConfigs();
|
||||
assert.equal(counts.mcpCount, 0, 'Should show 0 MCPs when all are disabled');
|
||||
} finally {
|
||||
process.env.HOME = originalHome;
|
||||
await rm(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
5
plugins/claude-hud/tests/fixtures/expected/render-basic.txt
vendored
Normal file
5
plugins/claude-hud/tests/fixtures/expected/render-basic.txt
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
[Opus] █████░░░░░ 45%
|
||||
my-project
|
||||
◐ Edit: .../authentication.ts | ✓ Read ×1
|
||||
✓ explore [haiku]: Finding auth code (<1s)
|
||||
▸ Add tests (1/2)
|
||||
206
plugins/claude-hud/tests/git.test.js
Normal file
206
plugins/claude-hud/tests/git.test.js
Normal file
@@ -0,0 +1,206 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { getGitBranch, getGitStatus } from '../dist/git.js';
|
||||
|
||||
test('getGitBranch returns null when cwd is undefined', async () => {
|
||||
const result = await getGitBranch(undefined);
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test('getGitBranch returns null for non-git directory', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-nogit-'));
|
||||
try {
|
||||
const result = await getGitBranch(dir);
|
||||
assert.equal(result, null);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('getGitBranch returns branch name for git directory', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-git-'));
|
||||
try {
|
||||
execFileSync('git', ['init'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: dir, stdio: 'ignore' });
|
||||
|
||||
const result = await getGitBranch(dir);
|
||||
assert.ok(result === 'main' || result === 'master', `Expected main or master, got ${result}`);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('getGitBranch returns custom branch name', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-git-'));
|
||||
try {
|
||||
execFileSync('git', ['init'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['checkout', '-b', 'feature/test-branch'], { cwd: dir, stdio: 'ignore' });
|
||||
|
||||
const result = await getGitBranch(dir);
|
||||
assert.equal(result, 'feature/test-branch');
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// getGitStatus tests
|
||||
test('getGitStatus returns null when cwd is undefined', async () => {
|
||||
const result = await getGitStatus(undefined);
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test('getGitStatus returns null for non-git directory', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-nogit-'));
|
||||
try {
|
||||
const result = await getGitStatus(dir);
|
||||
assert.equal(result, null);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('getGitStatus returns clean state for clean repo', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-git-'));
|
||||
try {
|
||||
execFileSync('git', ['init'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: dir, stdio: 'ignore' });
|
||||
|
||||
const result = await getGitStatus(dir);
|
||||
assert.ok(result?.branch === 'main' || result?.branch === 'master');
|
||||
assert.equal(result?.isDirty, false);
|
||||
assert.equal(result?.ahead, 0);
|
||||
assert.equal(result?.behind, 0);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('getGitStatus detects dirty state', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-git-'));
|
||||
try {
|
||||
execFileSync('git', ['init'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: dir, stdio: 'ignore' });
|
||||
|
||||
// Create uncommitted file
|
||||
await writeFile(path.join(dir, 'dirty.txt'), 'uncommitted change');
|
||||
|
||||
const result = await getGitStatus(dir);
|
||||
assert.equal(result?.isDirty, true);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// fileStats tests
|
||||
test('getGitStatus returns undefined fileStats for clean repo', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-git-'));
|
||||
try {
|
||||
execFileSync('git', ['init'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: dir, stdio: 'ignore' });
|
||||
|
||||
const result = await getGitStatus(dir);
|
||||
assert.equal(result?.fileStats, undefined);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('getGitStatus counts untracked files', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-git-'));
|
||||
try {
|
||||
execFileSync('git', ['init'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: dir, stdio: 'ignore' });
|
||||
|
||||
// Create untracked files
|
||||
await writeFile(path.join(dir, 'untracked1.txt'), 'content');
|
||||
await writeFile(path.join(dir, 'untracked2.txt'), 'content');
|
||||
|
||||
const result = await getGitStatus(dir);
|
||||
assert.equal(result?.fileStats?.untracked, 2);
|
||||
assert.equal(result?.fileStats?.modified, 0);
|
||||
assert.equal(result?.fileStats?.added, 0);
|
||||
assert.equal(result?.fileStats?.deleted, 0);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('getGitStatus counts modified files', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-git-'));
|
||||
try {
|
||||
execFileSync('git', ['init'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'ignore' });
|
||||
|
||||
// Create and commit a file
|
||||
await writeFile(path.join(dir, 'file.txt'), 'original');
|
||||
execFileSync('git', ['add', 'file.txt'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['commit', '-m', 'add file'], { cwd: dir, stdio: 'ignore' });
|
||||
|
||||
// Modify the file
|
||||
await writeFile(path.join(dir, 'file.txt'), 'modified');
|
||||
|
||||
const result = await getGitStatus(dir);
|
||||
assert.equal(result?.fileStats?.modified, 1);
|
||||
assert.equal(result?.fileStats?.untracked, 0);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('getGitStatus counts staged added files', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-git-'));
|
||||
try {
|
||||
execFileSync('git', ['init'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: dir, stdio: 'ignore' });
|
||||
|
||||
// Create and stage a new file
|
||||
await writeFile(path.join(dir, 'newfile.txt'), 'content');
|
||||
execFileSync('git', ['add', 'newfile.txt'], { cwd: dir, stdio: 'ignore' });
|
||||
|
||||
const result = await getGitStatus(dir);
|
||||
assert.equal(result?.fileStats?.added, 1);
|
||||
assert.equal(result?.fileStats?.untracked, 0);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('getGitStatus counts deleted files', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-git-'));
|
||||
try {
|
||||
execFileSync('git', ['init'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'ignore' });
|
||||
|
||||
// Create, commit, then delete a file
|
||||
await writeFile(path.join(dir, 'todelete.txt'), 'content');
|
||||
execFileSync('git', ['add', 'todelete.txt'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['commit', '-m', 'add file'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['rm', 'todelete.txt'], { cwd: dir, stdio: 'ignore' });
|
||||
|
||||
const result = await getGitStatus(dir);
|
||||
assert.equal(result?.fileStats?.deleted, 1);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
168
plugins/claude-hud/tests/index.test.js
Normal file
168
plugins/claude-hud/tests/index.test.js
Normal file
@@ -0,0 +1,168 @@
|
||||
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, showConfigCounts: true, showDuration: true, showTokenBreakdown: true, showUsage: true, showTools: true, showAgents: true, showTodos: true, autocompactBuffer: 'enabled', usageThreshold: 0, environmentThreshold: 0 },
|
||||
}),
|
||||
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);
|
||||
});
|
||||
66
plugins/claude-hud/tests/integration.test.js
Normal file
66
plugins/claude-hud/tests/integration.test.js
Normal file
@@ -0,0 +1,66 @@
|
||||
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, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
function stripAnsi(text) {
|
||||
return text.replace(
|
||||
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nq-uy=><]/g,
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
test('CLI renders expected output for a basic transcript', async () => {
|
||||
const fixturePath = fileURLToPath(new URL('./fixtures/transcript-render.jsonl', import.meta.url));
|
||||
const expectedPath = fileURLToPath(new URL('./fixtures/expected/render-basic.txt', import.meta.url));
|
||||
const expected = readFileSync(expectedPath, 'utf8').trimEnd();
|
||||
|
||||
const homeDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-home-'));
|
||||
// Use a fixed 3-level path for deterministic test output
|
||||
const projectDir = path.join(homeDir, 'dev', 'apps', 'my-project');
|
||||
await import('node:fs/promises').then(fs => fs.mkdir(projectDir, { recursive: true }));
|
||||
try {
|
||||
const stdin = JSON.stringify({
|
||||
model: { display_name: 'Opus' },
|
||||
context_window: {
|
||||
context_window_size: 200000,
|
||||
current_usage: { input_tokens: 45000 },
|
||||
},
|
||||
transcript_path: fixturePath,
|
||||
cwd: projectDir,
|
||||
});
|
||||
|
||||
const result = spawnSync('node', ['dist/index.js'], {
|
||||
cwd: path.resolve(process.cwd()),
|
||||
input: stdin,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, HOME: homeDir },
|
||||
});
|
||||
|
||||
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...');
|
||||
});
|
||||
637
plugins/claude-hud/tests/render.test.js
Normal file
637
plugins/claude-hud/tests/render.test.js
Normal file
@@ -0,0 +1,637 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { render } from '../dist/render/index.js';
|
||||
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 {
|
||||
stdin: {
|
||||
model: { display_name: 'Opus' },
|
||||
context_window: {
|
||||
context_window_size: 200000,
|
||||
current_usage: {
|
||||
input_tokens: 10000,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
transcript: { tools: [], agents: [], todos: [] },
|
||||
claudeMdCount: 0,
|
||||
rulesCount: 0,
|
||||
mcpCount: 0,
|
||||
hooksCount: 0,
|
||||
sessionDuration: '',
|
||||
gitStatus: null,
|
||||
usageData: null,
|
||||
config: {
|
||||
lineLayout: 'compact',
|
||||
showSeparators: false,
|
||||
pathLevels: 1,
|
||||
gitStatus: { enabled: true, showDirty: true, showAheadBehind: false, showFileStats: false },
|
||||
display: { showModel: true, showContextBar: true, showConfigCounts: true, showDuration: true, showTokenBreakdown: true, showUsage: true, showTools: true, showAgents: true, showTodos: true, autocompactBuffer: 'enabled', usageThreshold: 0, environmentThreshold: 0 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('renderSessionLine adds token breakdown when context is high', () => {
|
||||
const ctx = baseContext();
|
||||
// For 90%: (tokens + 45000) / 200000 = 0.9 → tokens = 135000
|
||||
ctx.stdin.context_window.current_usage.input_tokens = 135000;
|
||||
const line = renderSessionLine(ctx);
|
||||
assert.ok(line.includes('in:'), 'expected token breakdown');
|
||||
assert.ok(line.includes('cache:'), 'expected cache breakdown');
|
||||
});
|
||||
|
||||
test('renderSessionLine includes duration and formats large tokens', () => {
|
||||
const ctx = baseContext();
|
||||
ctx.sessionDuration = '1m';
|
||||
// Use 1M context, need 85%+ to show breakdown
|
||||
// For 85%: (tokens + 45000) / 1000000 = 0.85 → tokens = 805000
|
||||
ctx.stdin.context_window.context_window_size = 1000000;
|
||||
ctx.stdin.context_window.current_usage.input_tokens = 805000;
|
||||
ctx.stdin.context_window.current_usage.cache_read_input_tokens = 1500;
|
||||
const line = renderSessionLine(ctx);
|
||||
assert.ok(line.includes('⏱️'));
|
||||
assert.ok(line.includes('805k') || line.includes('805.0k'), 'expected large input token display');
|
||||
assert.ok(line.includes('2k'), 'expected cache token display');
|
||||
});
|
||||
|
||||
test('renderSessionLine handles missing input tokens and cache creation usage', () => {
|
||||
const ctx = baseContext();
|
||||
// For 90%: (tokens + 45000) / 200000 = 0.9 → tokens = 135000 (all from cache)
|
||||
ctx.stdin.context_window.context_window_size = 200000;
|
||||
ctx.stdin.context_window.current_usage = {
|
||||
cache_creation_input_tokens: 135000,
|
||||
};
|
||||
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();
|
||||
// For 90%: (tokens + 45000) / 200000 = 0.9 → tokens = 135000
|
||||
ctx.stdin.context_window.context_window_size = 200000;
|
||||
ctx.stdin.context_window.current_usage = {
|
||||
input_tokens: 135000,
|
||||
};
|
||||
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.stdin.cwd = '/tmp/my-project';
|
||||
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('renderSessionLine displays project name from POSIX cwd', () => {
|
||||
const ctx = baseContext();
|
||||
ctx.stdin.cwd = '/Users/jarrod/my-project';
|
||||
const line = renderSessionLine(ctx);
|
||||
assert.ok(line.includes('my-project'));
|
||||
assert.ok(!line.includes('/Users/jarrod'));
|
||||
});
|
||||
|
||||
test('renderSessionLine displays project name from Windows cwd', { skip: process.platform !== 'win32' }, () => {
|
||||
const ctx = baseContext();
|
||||
ctx.stdin.cwd = 'C:\\Users\\jarrod\\my-project';
|
||||
const line = renderSessionLine(ctx);
|
||||
assert.ok(line.includes('my-project'));
|
||||
assert.ok(!line.includes('C:\\'));
|
||||
});
|
||||
|
||||
test('renderSessionLine handles root path gracefully', () => {
|
||||
const ctx = baseContext();
|
||||
ctx.stdin.cwd = '/';
|
||||
const line = renderSessionLine(ctx);
|
||||
assert.ok(line.includes('[Opus]'));
|
||||
});
|
||||
|
||||
test('renderSessionLine omits project name when cwd is undefined', () => {
|
||||
const ctx = baseContext();
|
||||
ctx.stdin.cwd = undefined;
|
||||
const line = renderSessionLine(ctx);
|
||||
assert.ok(line.includes('[Opus]'));
|
||||
});
|
||||
|
||||
test('renderSessionLine displays git branch when present', () => {
|
||||
const ctx = baseContext();
|
||||
ctx.stdin.cwd = '/tmp/my-project';
|
||||
ctx.gitStatus = { branch: 'main', isDirty: false, ahead: 0, behind: 0 };
|
||||
const line = renderSessionLine(ctx);
|
||||
assert.ok(line.includes('git:('));
|
||||
assert.ok(line.includes('main'));
|
||||
});
|
||||
|
||||
test('renderSessionLine omits git branch when null', () => {
|
||||
const ctx = baseContext();
|
||||
ctx.stdin.cwd = '/tmp/my-project';
|
||||
ctx.gitStatus = null;
|
||||
const line = renderSessionLine(ctx);
|
||||
assert.ok(!line.includes('git:('));
|
||||
});
|
||||
|
||||
test('renderSessionLine displays branch with slashes', () => {
|
||||
const ctx = baseContext();
|
||||
ctx.stdin.cwd = '/tmp/my-project';
|
||||
ctx.gitStatus = { branch: 'feature/add-auth', isDirty: false, ahead: 0, behind: 0 };
|
||||
const line = renderSessionLine(ctx);
|
||||
assert.ok(line.includes('git:('));
|
||||
assert.ok(line.includes('feature/add-auth'));
|
||||
});
|
||||
|
||||
test('renderToolsLine renders running and completed tools', () => {
|
||||
const ctx = baseContext();
|
||||
ctx.transcript.tools = [
|
||||
{
|
||||
id: 'tool-1',
|
||||
name: 'Read',
|
||||
status: 'completed',
|
||||
startTime: new Date(0),
|
||||
endTime: new Date(0),
|
||||
duration: 0,
|
||||
},
|
||||
{
|
||||
id: 'tool-2',
|
||||
name: 'Edit',
|
||||
target: '/tmp/very/long/path/to/authentication.ts',
|
||||
status: 'running',
|
||||
startTime: new Date(0),
|
||||
},
|
||||
];
|
||||
|
||||
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', () => {
|
||||
const ctx = baseContext();
|
||||
ctx.transcript.agents = [
|
||||
{
|
||||
id: 'agent-1',
|
||||
type: 'explore',
|
||||
model: 'haiku',
|
||||
description: 'Finding auth code',
|
||||
status: 'completed',
|
||||
startTime: new Date(0),
|
||||
endTime: new Date(0),
|
||||
elapsed: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const line = renderAgentsLine(ctx);
|
||||
assert.ok(line?.includes('explore'));
|
||||
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 = [
|
||||
{ content: 'First task', status: 'completed' },
|
||||
{ content: 'Second task', status: 'in_progress' },
|
||||
];
|
||||
assert.ok(renderTodosLine(ctx)?.includes('Second task'));
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
// Usage display tests
|
||||
test('renderSessionLine displays plan name in model bracket', () => {
|
||||
const ctx = baseContext();
|
||||
ctx.usageData = {
|
||||
planName: 'Max',
|
||||
fiveHour: 23,
|
||||
sevenDay: 45,
|
||||
fiveHourResetAt: null,
|
||||
sevenDayResetAt: null,
|
||||
};
|
||||
const line = renderSessionLine(ctx);
|
||||
assert.ok(line.includes('Opus'), 'should include model name');
|
||||
assert.ok(line.includes('Max'), 'should include plan name');
|
||||
});
|
||||
|
||||
test('renderSessionLine displays usage percentages (7d hidden when low)', () => {
|
||||
const ctx = baseContext();
|
||||
ctx.usageData = {
|
||||
planName: 'Pro',
|
||||
fiveHour: 6,
|
||||
sevenDay: 13,
|
||||
fiveHourResetAt: null,
|
||||
sevenDayResetAt: null,
|
||||
};
|
||||
const line = renderSessionLine(ctx);
|
||||
assert.ok(line.includes('5h:'), 'should include 5h label');
|
||||
assert.ok(!line.includes('7d:'), 'should NOT include 7d when below 80%');
|
||||
assert.ok(line.includes('6%'), 'should include 5h percentage');
|
||||
});
|
||||
|
||||
test('renderSessionLine shows 7d when approaching limit (>=80%)', () => {
|
||||
const ctx = baseContext();
|
||||
ctx.usageData = {
|
||||
planName: 'Pro',
|
||||
fiveHour: 45,
|
||||
sevenDay: 85,
|
||||
fiveHourResetAt: null,
|
||||
sevenDayResetAt: null,
|
||||
};
|
||||
const line = renderSessionLine(ctx);
|
||||
assert.ok(line.includes('5h:'), 'should include 5h label');
|
||||
assert.ok(line.includes('7d:'), 'should include 7d when >= 80%');
|
||||
assert.ok(line.includes('85%'), 'should include 7d percentage');
|
||||
});
|
||||
|
||||
test('renderSessionLine shows 5hr reset countdown', () => {
|
||||
const ctx = baseContext();
|
||||
const resetTime = new Date(Date.now() + 7200000); // 2 hours from now
|
||||
ctx.usageData = {
|
||||
planName: 'Pro',
|
||||
fiveHour: 45,
|
||||
sevenDay: 20,
|
||||
fiveHourResetAt: resetTime,
|
||||
sevenDayResetAt: null,
|
||||
};
|
||||
const line = renderSessionLine(ctx);
|
||||
assert.ok(line.includes('5h:'), 'should include 5h label');
|
||||
assert.ok(line.includes('2h'), 'should include reset countdown');
|
||||
});
|
||||
|
||||
test('renderSessionLine displays limit reached warning', () => {
|
||||
const ctx = baseContext();
|
||||
const resetTime = new Date(Date.now() + 3600000); // 1 hour from now
|
||||
ctx.usageData = {
|
||||
planName: 'Pro',
|
||||
fiveHour: 100,
|
||||
sevenDay: 45,
|
||||
fiveHourResetAt: resetTime,
|
||||
sevenDayResetAt: null,
|
||||
};
|
||||
const line = renderSessionLine(ctx);
|
||||
assert.ok(line.includes('Limit reached'), 'should show limit reached');
|
||||
assert.ok(line.includes('resets'), 'should show reset time');
|
||||
});
|
||||
|
||||
test('renderSessionLine displays -- for null usage values', () => {
|
||||
const ctx = baseContext();
|
||||
ctx.usageData = {
|
||||
planName: 'Max',
|
||||
fiveHour: null,
|
||||
sevenDay: null,
|
||||
fiveHourResetAt: null,
|
||||
sevenDayResetAt: null,
|
||||
};
|
||||
const line = renderSessionLine(ctx);
|
||||
assert.ok(line.includes('5h:'), 'should include 5h label');
|
||||
assert.ok(line.includes('--'), 'should show -- for null values');
|
||||
});
|
||||
|
||||
test('renderSessionLine omits usage when usageData is null', () => {
|
||||
const ctx = baseContext();
|
||||
ctx.usageData = null;
|
||||
const line = renderSessionLine(ctx);
|
||||
assert.ok(!line.includes('5h:'), 'should not include 5h label');
|
||||
assert.ok(!line.includes('7d:'), 'should not include 7d label');
|
||||
});
|
||||
|
||||
test('renderSessionLine displays warning when API is unavailable', () => {
|
||||
const ctx = baseContext();
|
||||
ctx.usageData = {
|
||||
planName: 'Max',
|
||||
fiveHour: null,
|
||||
sevenDay: null,
|
||||
fiveHourResetAt: null,
|
||||
sevenDayResetAt: null,
|
||||
apiUnavailable: true,
|
||||
};
|
||||
const line = renderSessionLine(ctx);
|
||||
assert.ok(line.includes('usage:'), 'should show usage label');
|
||||
assert.ok(line.includes('⚠'), 'should show warning indicator');
|
||||
assert.ok(!line.includes('5h:'), 'should not show 5h when API unavailable');
|
||||
});
|
||||
|
||||
test('renderSessionLine hides usage when showUsage config is false (hybrid toggle)', () => {
|
||||
const ctx = baseContext();
|
||||
ctx.usageData = {
|
||||
planName: 'Pro',
|
||||
fiveHour: 25,
|
||||
sevenDay: 10,
|
||||
fiveHourResetAt: null,
|
||||
sevenDayResetAt: null,
|
||||
};
|
||||
// Even with usageData present, setting showUsage to false should hide it
|
||||
ctx.config.display.showUsage = false;
|
||||
const line = renderSessionLine(ctx);
|
||||
assert.ok(!line.includes('5h:'), 'should not show usage when showUsage is false');
|
||||
assert.ok(!line.includes('Pro'), 'should not show plan name when showUsage is false');
|
||||
});
|
||||
|
||||
test('renderSessionLine uses buffered percent when autocompactBuffer is enabled', () => {
|
||||
const ctx = baseContext();
|
||||
// 10000 tokens / 200000 = 5% raw, + 22.5% buffer = 28% buffered (rounded)
|
||||
ctx.stdin.context_window.current_usage.input_tokens = 10000;
|
||||
ctx.config.display.autocompactBuffer = 'enabled';
|
||||
const line = renderSessionLine(ctx);
|
||||
// Should show ~28% (buffered), not 5% (raw)
|
||||
assert.ok(line.includes('28%'), `expected buffered percent 28%, got: ${line}`);
|
||||
});
|
||||
|
||||
test('renderSessionLine uses raw percent when autocompactBuffer is disabled', () => {
|
||||
const ctx = baseContext();
|
||||
// 10000 tokens / 200000 = 5% raw
|
||||
ctx.stdin.context_window.current_usage.input_tokens = 10000;
|
||||
ctx.config.display.autocompactBuffer = 'disabled';
|
||||
const line = renderSessionLine(ctx);
|
||||
// Should show 5% (raw), not 28% (buffered)
|
||||
assert.ok(line.includes('5%'), `expected raw percent 5%, got: ${line}`);
|
||||
});
|
||||
|
||||
test('render adds separator line when showSeparators is true and activity exists', () => {
|
||||
const ctx = baseContext();
|
||||
ctx.config.showSeparators = true;
|
||||
ctx.transcript.tools = [
|
||||
{ id: 'tool-1', name: 'Read', status: 'completed', startTime: new Date(0), endTime: new Date(0), duration: 0 },
|
||||
];
|
||||
|
||||
const logs = [];
|
||||
const originalLog = console.log;
|
||||
console.log = (line) => logs.push(line);
|
||||
try {
|
||||
render(ctx);
|
||||
} finally {
|
||||
console.log = originalLog;
|
||||
}
|
||||
|
||||
assert.ok(logs.length >= 2, 'should have at least 2 lines');
|
||||
assert.ok(logs.some(l => l.includes('─')), 'should include separator character');
|
||||
});
|
||||
|
||||
test('render omits separator when showSeparators is true but no activity', () => {
|
||||
const ctx = baseContext();
|
||||
ctx.config.showSeparators = true;
|
||||
|
||||
const logs = [];
|
||||
const originalLog = console.log;
|
||||
console.log = (line) => logs.push(line);
|
||||
try {
|
||||
render(ctx);
|
||||
} finally {
|
||||
console.log = originalLog;
|
||||
}
|
||||
|
||||
assert.equal(logs.length, 1, 'should only have session line');
|
||||
assert.ok(!logs.some(l => l.includes('─')), 'should not include separator');
|
||||
});
|
||||
|
||||
// fileStats tests
|
||||
test('renderSessionLine displays file stats when showFileStats is true', () => {
|
||||
const ctx = baseContext();
|
||||
ctx.stdin.cwd = '/tmp/my-project';
|
||||
ctx.config.gitStatus.showFileStats = true;
|
||||
ctx.gitStatus = {
|
||||
branch: 'main',
|
||||
isDirty: true,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
fileStats: { modified: 2, added: 1, deleted: 0, untracked: 3 },
|
||||
};
|
||||
const line = renderSessionLine(ctx);
|
||||
assert.ok(line.includes('!2'), 'expected modified count');
|
||||
assert.ok(line.includes('+1'), 'expected added count');
|
||||
assert.ok(line.includes('?3'), 'expected untracked count');
|
||||
assert.ok(!line.includes('✘'), 'should not show deleted when 0');
|
||||
});
|
||||
|
||||
test('renderSessionLine omits file stats when showFileStats is false', () => {
|
||||
const ctx = baseContext();
|
||||
ctx.stdin.cwd = '/tmp/my-project';
|
||||
ctx.config.gitStatus.showFileStats = false;
|
||||
ctx.gitStatus = {
|
||||
branch: 'main',
|
||||
isDirty: true,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
fileStats: { modified: 2, added: 1, deleted: 0, untracked: 3 },
|
||||
};
|
||||
const line = renderSessionLine(ctx);
|
||||
assert.ok(!line.includes('!2'), 'should not show modified count');
|
||||
assert.ok(!line.includes('+1'), 'should not show added count');
|
||||
});
|
||||
|
||||
test('renderSessionLine handles missing showFileStats config (backward compatibility)', () => {
|
||||
const ctx = baseContext();
|
||||
ctx.stdin.cwd = '/tmp/my-project';
|
||||
// Simulate old config without showFileStats
|
||||
delete ctx.config.gitStatus.showFileStats;
|
||||
ctx.gitStatus = {
|
||||
branch: 'main',
|
||||
isDirty: true,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
fileStats: { modified: 2, added: 1, deleted: 0, untracked: 3 },
|
||||
};
|
||||
// Should not crash and should not show file stats (default is false)
|
||||
const line = renderSessionLine(ctx);
|
||||
assert.ok(line.includes('git:('), 'should still show git info');
|
||||
assert.ok(!line.includes('!2'), 'should not show file stats when config missing');
|
||||
});
|
||||
|
||||
test('renderSessionLine combines showFileStats with showDirty and showAheadBehind', () => {
|
||||
const ctx = baseContext();
|
||||
ctx.stdin.cwd = '/tmp/my-project';
|
||||
ctx.config.gitStatus = {
|
||||
enabled: true,
|
||||
showDirty: true,
|
||||
showAheadBehind: true,
|
||||
showFileStats: true,
|
||||
};
|
||||
ctx.gitStatus = {
|
||||
branch: 'feature',
|
||||
isDirty: true,
|
||||
ahead: 2,
|
||||
behind: 1,
|
||||
fileStats: { modified: 3, added: 0, deleted: 1, untracked: 0 },
|
||||
};
|
||||
const line = renderSessionLine(ctx);
|
||||
assert.ok(line.includes('feature'), 'expected branch name');
|
||||
assert.ok(line.includes('*'), 'expected dirty indicator');
|
||||
assert.ok(line.includes('↑2'), 'expected ahead count');
|
||||
assert.ok(line.includes('↓1'), 'expected behind count');
|
||||
assert.ok(line.includes('!3'), 'expected modified count');
|
||||
assert.ok(line.includes('✘1'), 'expected deleted count');
|
||||
});
|
||||
|
||||
32
plugins/claude-hud/tests/stdin.test.js
Normal file
32
plugins/claude-hud/tests/stdin.test.js
Normal file
@@ -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 });
|
||||
}
|
||||
});
|
||||
397
plugins/claude-hud/tests/usage-api.test.js
Normal file
397
plugins/claude-hud/tests/usage-api.test.js
Normal file
@@ -0,0 +1,397 @@
|
||||
import { test, describe, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { getUsage, clearCache } from '../dist/usage-api.js';
|
||||
import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
let tempHome = null;
|
||||
|
||||
async function createTempHome() {
|
||||
return await mkdtemp(path.join(tmpdir(), 'claude-hud-usage-'));
|
||||
}
|
||||
|
||||
async function writeCredentials(homeDir, credentials) {
|
||||
const credDir = path.join(homeDir, '.claude');
|
||||
await mkdir(credDir, { recursive: true });
|
||||
await writeFile(path.join(credDir, '.credentials.json'), JSON.stringify(credentials), 'utf8');
|
||||
}
|
||||
|
||||
function buildCredentials(overrides = {}) {
|
||||
return {
|
||||
claudeAiOauth: {
|
||||
accessToken: 'test-token',
|
||||
subscriptionType: 'claude_pro_2024',
|
||||
expiresAt: Date.now() + 3600000, // 1 hour from now
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildApiResponse(overrides = {}) {
|
||||
return {
|
||||
five_hour: {
|
||||
utilization: 25,
|
||||
resets_at: '2026-01-06T15:00:00Z',
|
||||
},
|
||||
seven_day: {
|
||||
utilization: 10,
|
||||
resets_at: '2026-01-13T00:00:00Z',
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('getUsage', () => {
|
||||
beforeEach(async () => {
|
||||
tempHome = await createTempHome();
|
||||
clearCache(tempHome);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (tempHome) {
|
||||
await rm(tempHome, { recursive: true, force: true });
|
||||
tempHome = null;
|
||||
}
|
||||
});
|
||||
|
||||
test('returns null when credentials file does not exist', async () => {
|
||||
let fetchCalls = 0;
|
||||
const result = await getUsage({
|
||||
homeDir: () => tempHome,
|
||||
fetchApi: async () => {
|
||||
fetchCalls += 1;
|
||||
return null;
|
||||
},
|
||||
now: () => 1000,
|
||||
readKeychain: () => null, // Disable Keychain for tests
|
||||
});
|
||||
|
||||
assert.equal(result, null);
|
||||
assert.equal(fetchCalls, 0);
|
||||
});
|
||||
|
||||
test('returns null when claudeAiOauth is missing', async () => {
|
||||
await writeCredentials(tempHome, {});
|
||||
let fetchCalls = 0;
|
||||
const result = await getUsage({
|
||||
homeDir: () => tempHome,
|
||||
fetchApi: async () => {
|
||||
fetchCalls += 1;
|
||||
return buildApiResponse();
|
||||
},
|
||||
now: () => 1000,
|
||||
readKeychain: () => null,
|
||||
});
|
||||
|
||||
assert.equal(result, null);
|
||||
assert.equal(fetchCalls, 0);
|
||||
});
|
||||
|
||||
test('returns null when token is expired', async () => {
|
||||
await writeCredentials(tempHome, buildCredentials({ expiresAt: 500 }));
|
||||
let fetchCalls = 0;
|
||||
const result = await getUsage({
|
||||
homeDir: () => tempHome,
|
||||
fetchApi: async () => {
|
||||
fetchCalls += 1;
|
||||
return buildApiResponse();
|
||||
},
|
||||
now: () => 1000,
|
||||
readKeychain: () => null,
|
||||
});
|
||||
|
||||
assert.equal(result, null);
|
||||
assert.equal(fetchCalls, 0);
|
||||
});
|
||||
|
||||
test('returns null for API users (no subscriptionType)', async () => {
|
||||
await writeCredentials(tempHome, buildCredentials({ subscriptionType: 'api' }));
|
||||
let fetchCalls = 0;
|
||||
const result = await getUsage({
|
||||
homeDir: () => tempHome,
|
||||
fetchApi: async () => {
|
||||
fetchCalls += 1;
|
||||
return buildApiResponse();
|
||||
},
|
||||
now: () => 1000,
|
||||
readKeychain: () => null,
|
||||
});
|
||||
|
||||
assert.equal(result, null);
|
||||
assert.equal(fetchCalls, 0);
|
||||
});
|
||||
|
||||
test('uses complete keychain credentials without falling back to file', async () => {
|
||||
// No file credentials - keychain should be sufficient
|
||||
let usedToken = null;
|
||||
const result = await getUsage({
|
||||
homeDir: () => tempHome,
|
||||
fetchApi: async (token) => {
|
||||
usedToken = token;
|
||||
return buildApiResponse();
|
||||
},
|
||||
now: () => 1000,
|
||||
readKeychain: () => ({ accessToken: 'keychain-token', subscriptionType: 'claude_max_2024' }),
|
||||
});
|
||||
|
||||
assert.equal(usedToken, 'keychain-token');
|
||||
assert.equal(result?.planName, 'Max');
|
||||
});
|
||||
|
||||
test('uses keychain token with file subscriptionType when keychain lacks subscriptionType', async () => {
|
||||
await writeCredentials(tempHome, buildCredentials({
|
||||
accessToken: 'old-file-token',
|
||||
subscriptionType: 'claude_pro_2024',
|
||||
}));
|
||||
let usedToken = null;
|
||||
const result = await getUsage({
|
||||
homeDir: () => tempHome,
|
||||
fetchApi: async (token) => {
|
||||
usedToken = token;
|
||||
return buildApiResponse();
|
||||
},
|
||||
now: () => 1000,
|
||||
readKeychain: () => ({ accessToken: 'keychain-token', subscriptionType: '' }),
|
||||
});
|
||||
|
||||
// Must use keychain token (authoritative), but can use file's subscriptionType
|
||||
assert.equal(usedToken, 'keychain-token', 'should use keychain token, not file token');
|
||||
assert.equal(result?.planName, 'Pro');
|
||||
});
|
||||
|
||||
test('returns null when keychain has token but no subscriptionType anywhere', async () => {
|
||||
// No file credentials, keychain has no subscriptionType
|
||||
// This user is treated as an API user (no usage limits)
|
||||
let fetchCalls = 0;
|
||||
const result = await getUsage({
|
||||
homeDir: () => tempHome,
|
||||
fetchApi: async () => {
|
||||
fetchCalls += 1;
|
||||
return buildApiResponse();
|
||||
},
|
||||
now: () => 1000,
|
||||
readKeychain: () => ({ accessToken: 'keychain-token', subscriptionType: '' }),
|
||||
});
|
||||
|
||||
// No subscriptionType means API user, returns null without calling API
|
||||
assert.equal(result, null);
|
||||
assert.equal(fetchCalls, 0);
|
||||
});
|
||||
|
||||
test('parses plan name and usage data', async () => {
|
||||
await writeCredentials(tempHome, buildCredentials({ subscriptionType: 'claude_pro_2024' }));
|
||||
let fetchCalls = 0;
|
||||
const result = await getUsage({
|
||||
homeDir: () => tempHome,
|
||||
fetchApi: async () => {
|
||||
fetchCalls += 1;
|
||||
return buildApiResponse();
|
||||
},
|
||||
now: () => 1000,
|
||||
readKeychain: () => null,
|
||||
});
|
||||
|
||||
assert.equal(fetchCalls, 1);
|
||||
assert.equal(result?.planName, 'Pro');
|
||||
assert.equal(result?.fiveHour, 25);
|
||||
assert.equal(result?.sevenDay, 10);
|
||||
});
|
||||
|
||||
test('parses Team plan name', async () => {
|
||||
await writeCredentials(tempHome, buildCredentials({ subscriptionType: 'claude_team_2024' }));
|
||||
const result = await getUsage({
|
||||
homeDir: () => tempHome,
|
||||
fetchApi: async () => buildApiResponse(),
|
||||
now: () => 1000,
|
||||
readKeychain: () => null,
|
||||
});
|
||||
|
||||
assert.equal(result?.planName, 'Team');
|
||||
});
|
||||
|
||||
test('returns apiUnavailable and caches failures', async () => {
|
||||
await writeCredentials(tempHome, buildCredentials());
|
||||
let fetchCalls = 0;
|
||||
let nowValue = 1000;
|
||||
const fetchApi = async () => {
|
||||
fetchCalls += 1;
|
||||
return null;
|
||||
};
|
||||
|
||||
const first = await getUsage({
|
||||
homeDir: () => tempHome,
|
||||
fetchApi,
|
||||
now: () => nowValue,
|
||||
readKeychain: () => null,
|
||||
});
|
||||
assert.equal(first?.apiUnavailable, true);
|
||||
assert.equal(fetchCalls, 1);
|
||||
|
||||
nowValue += 10_000;
|
||||
const cached = await getUsage({
|
||||
homeDir: () => tempHome,
|
||||
fetchApi,
|
||||
now: () => nowValue,
|
||||
readKeychain: () => null,
|
||||
});
|
||||
assert.equal(cached?.apiUnavailable, true);
|
||||
assert.equal(fetchCalls, 1);
|
||||
|
||||
nowValue += 6_000;
|
||||
const second = await getUsage({
|
||||
homeDir: () => tempHome,
|
||||
fetchApi,
|
||||
now: () => nowValue,
|
||||
readKeychain: () => null,
|
||||
});
|
||||
assert.equal(second?.apiUnavailable, true);
|
||||
assert.equal(fetchCalls, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUsage caching behavior', () => {
|
||||
beforeEach(async () => {
|
||||
tempHome = await createTempHome();
|
||||
clearCache(tempHome);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (tempHome) {
|
||||
await rm(tempHome, { recursive: true, force: true });
|
||||
tempHome = null;
|
||||
}
|
||||
});
|
||||
|
||||
test('cache expires after 60 seconds for success', async () => {
|
||||
await writeCredentials(tempHome, buildCredentials());
|
||||
let fetchCalls = 0;
|
||||
let nowValue = 1000;
|
||||
const fetchApi = async () => {
|
||||
fetchCalls += 1;
|
||||
return buildApiResponse();
|
||||
};
|
||||
|
||||
await getUsage({ homeDir: () => tempHome, fetchApi, now: () => nowValue, readKeychain: () => null });
|
||||
assert.equal(fetchCalls, 1);
|
||||
|
||||
nowValue += 30_000;
|
||||
await getUsage({ homeDir: () => tempHome, fetchApi, now: () => nowValue, readKeychain: () => null });
|
||||
assert.equal(fetchCalls, 1);
|
||||
|
||||
nowValue += 31_000;
|
||||
await getUsage({ homeDir: () => tempHome, fetchApi, now: () => nowValue, readKeychain: () => null });
|
||||
assert.equal(fetchCalls, 2);
|
||||
});
|
||||
|
||||
test('cache expires after 15 seconds for failures', async () => {
|
||||
await writeCredentials(tempHome, buildCredentials());
|
||||
let fetchCalls = 0;
|
||||
let nowValue = 1000;
|
||||
const fetchApi = async () => {
|
||||
fetchCalls += 1;
|
||||
return null;
|
||||
};
|
||||
|
||||
await getUsage({ homeDir: () => tempHome, fetchApi, now: () => nowValue, readKeychain: () => null });
|
||||
assert.equal(fetchCalls, 1);
|
||||
|
||||
nowValue += 10_000;
|
||||
await getUsage({ homeDir: () => tempHome, fetchApi, now: () => nowValue, readKeychain: () => null });
|
||||
assert.equal(fetchCalls, 1);
|
||||
|
||||
nowValue += 6_000;
|
||||
await getUsage({ homeDir: () => tempHome, fetchApi, now: () => nowValue, readKeychain: () => null });
|
||||
assert.equal(fetchCalls, 2);
|
||||
});
|
||||
|
||||
test('clearCache removes file-based cache', async () => {
|
||||
await writeCredentials(tempHome, buildCredentials());
|
||||
let fetchCalls = 0;
|
||||
const fetchApi = async () => {
|
||||
fetchCalls += 1;
|
||||
return buildApiResponse();
|
||||
};
|
||||
|
||||
await getUsage({ homeDir: () => tempHome, fetchApi, now: () => 1000, readKeychain: () => null });
|
||||
assert.equal(fetchCalls, 1);
|
||||
|
||||
clearCache(tempHome);
|
||||
await getUsage({ homeDir: () => tempHome, fetchApi, now: () => 2000, readKeychain: () => null });
|
||||
assert.equal(fetchCalls, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLimitReached', () => {
|
||||
test('returns true when fiveHour is 100', async () => {
|
||||
// Import from types since isLimitReached is exported there
|
||||
const { isLimitReached } = await import('../dist/types.js');
|
||||
|
||||
const data = {
|
||||
planName: 'Pro',
|
||||
fiveHour: 100,
|
||||
sevenDay: 50,
|
||||
fiveHourResetAt: null,
|
||||
sevenDayResetAt: null,
|
||||
};
|
||||
|
||||
assert.equal(isLimitReached(data), true);
|
||||
});
|
||||
|
||||
test('returns true when sevenDay is 100', async () => {
|
||||
const { isLimitReached } = await import('../dist/types.js');
|
||||
|
||||
const data = {
|
||||
planName: 'Pro',
|
||||
fiveHour: 50,
|
||||
sevenDay: 100,
|
||||
fiveHourResetAt: null,
|
||||
sevenDayResetAt: null,
|
||||
};
|
||||
|
||||
assert.equal(isLimitReached(data), true);
|
||||
});
|
||||
|
||||
test('returns false when both are below 100', async () => {
|
||||
const { isLimitReached } = await import('../dist/types.js');
|
||||
|
||||
const data = {
|
||||
planName: 'Pro',
|
||||
fiveHour: 50,
|
||||
sevenDay: 50,
|
||||
fiveHourResetAt: null,
|
||||
sevenDayResetAt: null,
|
||||
};
|
||||
|
||||
assert.equal(isLimitReached(data), false);
|
||||
});
|
||||
|
||||
test('handles null values correctly', async () => {
|
||||
const { isLimitReached } = await import('../dist/types.js');
|
||||
|
||||
const data = {
|
||||
planName: 'Pro',
|
||||
fiveHour: null,
|
||||
sevenDay: null,
|
||||
fiveHourResetAt: null,
|
||||
sevenDayResetAt: null,
|
||||
};
|
||||
|
||||
// null !== 100, so should return false
|
||||
assert.equal(isLimitReached(data), false);
|
||||
});
|
||||
|
||||
test('returns true when sevenDay is 100 but fiveHour is null', async () => {
|
||||
const { isLimitReached } = await import('../dist/types.js');
|
||||
|
||||
const data = {
|
||||
planName: 'Pro',
|
||||
fiveHour: null,
|
||||
sevenDay: 100,
|
||||
fiveHourResetAt: null,
|
||||
sevenDayResetAt: null,
|
||||
};
|
||||
|
||||
assert.equal(isLimitReached(data), true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user