mirror of
https://github.com/jarrodwatts/claude-hud.git
synced 2026-05-06 04:22:39 +00:00
Add i18n infrastructure with automatic language detection from system locale (LANG/LC_ALL/LC_MESSAGES) and manual override via config.json. Translates all user-facing HUD labels, status messages, and format strings. English remains the default. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
577 lines
15 KiB
JavaScript
577 lines
15 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { setLanguage } from "../dist/i18n/index.js";
|
|
import { formatSessionDuration, main } from "../dist/index.js";
|
|
|
|
// Ensure tests run with English locale regardless of system LANG
|
|
setLanguage("en");
|
|
|
|
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 originalLang = process.env.LANG;
|
|
const logs = [];
|
|
|
|
try {
|
|
process.env.LANG = "C";
|
|
setLanguage("en");
|
|
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()}`);
|
|
// main() is invoked with `void` (fire-and-forget), wait for it to complete
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
} finally {
|
|
console.log = originalLog;
|
|
process.argv = originalArgv;
|
|
process.env.LANG = originalLang;
|
|
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,
|
|
showClaudeCodeVersion: false,
|
|
showMemoryUsage: false,
|
|
autocompactBuffer: "enabled",
|
|
usageThreshold: 0,
|
|
sevenDayThreshold: 80,
|
|
environmentThreshold: 0,
|
|
},
|
|
}),
|
|
render: (ctx) => {
|
|
renderedContext = ctx;
|
|
},
|
|
});
|
|
|
|
assert.equal(renderedContext?.gitStatus?.branch, "feature/test");
|
|
});
|
|
|
|
test("main includes usageData 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 },
|
|
},
|
|
rate_limits: {
|
|
five_hour: { used_percentage: 49.6, resets_at: 1710000000 },
|
|
seven_day: { used_percentage: 25.2, resets_at: 1710600000 },
|
|
},
|
|
}),
|
|
parseTranscript: async () => ({ tools: [], agents: [], todos: [] }),
|
|
countConfigs: async () => ({
|
|
claudeMdCount: 0,
|
|
rulesCount: 0,
|
|
mcpCount: 0,
|
|
hooksCount: 0,
|
|
}),
|
|
getGitBranch: async () => null,
|
|
render: (ctx) => {
|
|
renderedContext = ctx;
|
|
},
|
|
});
|
|
|
|
assert.deepEqual(renderedContext?.usageData, {
|
|
fiveHour: 50,
|
|
sevenDay: 25,
|
|
fiveHourResetAt: new Date(1710000000 * 1000),
|
|
sevenDayResetAt: new Date(1710600000 * 1000),
|
|
});
|
|
});
|
|
|
|
test("main uses stdin-native rate_limits when available", async () => {
|
|
let renderedContext;
|
|
|
|
await main({
|
|
readStdin: async () => ({
|
|
model: { display_name: "Opus" },
|
|
rate_limits: {
|
|
five_hour: { used_percentage: 21.9, resets_at: 1710000000 },
|
|
seven_day: { used_percentage: 55.2, resets_at: 1710600000 },
|
|
},
|
|
}),
|
|
parseTranscript: async () => ({ tools: [], agents: [], todos: [] }),
|
|
countConfigs: async () => ({
|
|
claudeMdCount: 0,
|
|
rulesCount: 0,
|
|
mcpCount: 0,
|
|
hooksCount: 0,
|
|
}),
|
|
getGitStatus: async () => null,
|
|
render: (ctx) => {
|
|
renderedContext = ctx;
|
|
},
|
|
});
|
|
|
|
assert.deepEqual(renderedContext?.usageData, {
|
|
fiveHour: 22,
|
|
sevenDay: 55,
|
|
fiveHourResetAt: new Date(1710000000 * 1000),
|
|
sevenDayResetAt: new Date(1710600000 * 1000),
|
|
});
|
|
});
|
|
|
|
test("main leaves usageData null when stdin rate_limits are absent", async () => {
|
|
let renderedContext;
|
|
|
|
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,
|
|
}),
|
|
getGitStatus: async () => null,
|
|
render: (ctx) => {
|
|
renderedContext = ctx;
|
|
},
|
|
});
|
|
|
|
assert.equal(renderedContext?.usageData, null);
|
|
});
|
|
|
|
test("main includes Claude Code version in render context only when enabled", async () => {
|
|
let renderedContext;
|
|
let lookupCalls = 0;
|
|
|
|
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,
|
|
}),
|
|
getGitStatus: async () => null,
|
|
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: false,
|
|
showAgents: false,
|
|
showTodos: false,
|
|
showClaudeCodeVersion: true,
|
|
showMemoryUsage: false,
|
|
autocompactBuffer: "enabled",
|
|
usageThreshold: 0,
|
|
sevenDayThreshold: 80,
|
|
environmentThreshold: 0,
|
|
},
|
|
}),
|
|
getClaudeCodeVersion: async () => {
|
|
lookupCalls += 1;
|
|
return "2.1.81";
|
|
},
|
|
render: (ctx) => {
|
|
renderedContext = ctx;
|
|
},
|
|
});
|
|
|
|
assert.equal(lookupCalls, 1);
|
|
assert.equal(renderedContext?.claudeCodeVersion, "2.1.81");
|
|
});
|
|
|
|
test("main skips Claude Code version lookup when disabled", async () => {
|
|
let lookupCalls = 0;
|
|
|
|
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,
|
|
}),
|
|
getGitStatus: async () => null,
|
|
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: false,
|
|
showAgents: false,
|
|
showTodos: false,
|
|
showClaudeCodeVersion: false,
|
|
showMemoryUsage: false,
|
|
autocompactBuffer: "enabled",
|
|
usageThreshold: 0,
|
|
sevenDayThreshold: 80,
|
|
environmentThreshold: 0,
|
|
},
|
|
}),
|
|
getClaudeCodeVersion: async () => {
|
|
lookupCalls += 1;
|
|
return "2.1.81";
|
|
},
|
|
render: () => {},
|
|
});
|
|
|
|
assert.equal(lookupCalls, 0);
|
|
});
|
|
|
|
test("main includes memoryUsage in render context only for expanded layout when enabled", async () => {
|
|
let renderedContext;
|
|
let lookupCalls = 0;
|
|
const mockMemoryUsage = {
|
|
totalBytes: 16 * 1024 ** 3,
|
|
usedBytes: 10 * 1024 ** 3,
|
|
freeBytes: 6 * 1024 ** 3,
|
|
usedPercent: 63,
|
|
};
|
|
|
|
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,
|
|
}),
|
|
getGitStatus: async () => null,
|
|
getUsage: async () => null,
|
|
loadConfig: async () => ({
|
|
lineLayout: "expanded",
|
|
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: false,
|
|
showAgents: false,
|
|
showTodos: false,
|
|
showClaudeCodeVersion: false,
|
|
showMemoryUsage: true,
|
|
autocompactBuffer: "enabled",
|
|
usageThreshold: 0,
|
|
sevenDayThreshold: 80,
|
|
environmentThreshold: 0,
|
|
},
|
|
}),
|
|
getMemoryUsage: async () => {
|
|
lookupCalls += 1;
|
|
return mockMemoryUsage;
|
|
},
|
|
render: (ctx) => {
|
|
renderedContext = ctx;
|
|
},
|
|
});
|
|
|
|
assert.equal(lookupCalls, 1);
|
|
assert.deepEqual(renderedContext?.memoryUsage, mockMemoryUsage);
|
|
});
|
|
|
|
test("main skips memoryUsage lookup for compact layout even when enabled", async () => {
|
|
let lookupCalls = 0;
|
|
|
|
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,
|
|
}),
|
|
getGitStatus: async () => null,
|
|
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: false,
|
|
showAgents: false,
|
|
showTodos: false,
|
|
showClaudeCodeVersion: false,
|
|
showMemoryUsage: true,
|
|
autocompactBuffer: "enabled",
|
|
usageThreshold: 0,
|
|
sevenDayThreshold: 80,
|
|
environmentThreshold: 0,
|
|
},
|
|
}),
|
|
getMemoryUsage: async () => {
|
|
lookupCalls += 1;
|
|
return null;
|
|
},
|
|
render: () => {},
|
|
});
|
|
|
|
assert.equal(lookupCalls, 0);
|
|
});
|