chore: remove pnpm-lock.yaml and add tests for ClaudeUsageService

- Deleted the pnpm-lock.yaml file as part of project cleanup.
- Introduced comprehensive unit tests for the ClaudeUsageService, covering methods for checking CLI availability, parsing reset times, and handling usage output.
- Enhanced test coverage for both macOS and Windows environments, ensuring robust functionality across platforms.

These changes aim to streamline project dependencies and improve the reliability of the Claude usage tracking service through thorough testing.
This commit is contained in:
Kacper
2025-12-21 22:41:17 +01:00
parent 7b1b2fa463
commit 43c93fe19a
2 changed files with 637 additions and 70 deletions

View File

@@ -0,0 +1,637 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ClaudeUsageService } from '@/services/claude-usage-service.js';
import { spawn } from 'child_process';
import * as pty from 'node-pty';
import * as os from 'os';
vi.mock('child_process');
vi.mock('node-pty');
vi.mock('os');
describe('claude-usage-service.ts', () => {
let service: ClaudeUsageService;
let mockSpawnProcess: any;
let mockPtyProcess: any;
beforeEach(() => {
vi.clearAllMocks();
service = new ClaudeUsageService();
// Mock spawn process for isAvailable and Mac commands
mockSpawnProcess = {
on: vi.fn(),
kill: vi.fn(),
stdout: {
on: vi.fn(),
},
stderr: {
on: vi.fn(),
},
};
// Mock PTY process for Windows
mockPtyProcess = {
onData: vi.fn(),
onExit: vi.fn(),
write: vi.fn(),
kill: vi.fn(),
};
vi.mocked(spawn).mockReturnValue(mockSpawnProcess as any);
vi.mocked(pty.spawn).mockReturnValue(mockPtyProcess);
});
describe('isAvailable', () => {
it('should return true when Claude CLI is available', async () => {
vi.mocked(os.platform).mockReturnValue('darwin');
// Simulate successful which/where command
mockSpawnProcess.on.mockImplementation((event: string, callback: Function) => {
if (event === 'close') {
callback(0); // Exit code 0 = found
}
return mockSpawnProcess;
});
const result = await service.isAvailable();
expect(result).toBe(true);
expect(spawn).toHaveBeenCalledWith('which', ['claude']);
});
it('should return false when Claude CLI is not available', async () => {
vi.mocked(os.platform).mockReturnValue('darwin');
mockSpawnProcess.on.mockImplementation((event: string, callback: Function) => {
if (event === 'close') {
callback(1); // Exit code 1 = not found
}
return mockSpawnProcess;
});
const result = await service.isAvailable();
expect(result).toBe(false);
});
it('should return false on error', async () => {
vi.mocked(os.platform).mockReturnValue('darwin');
mockSpawnProcess.on.mockImplementation((event: string, callback: Function) => {
if (event === 'error') {
callback(new Error('Command failed'));
}
return mockSpawnProcess;
});
const result = await service.isAvailable();
expect(result).toBe(false);
});
it("should use 'where' command on Windows", async () => {
vi.mocked(os.platform).mockReturnValue('win32');
const windowsService = new ClaudeUsageService(); // Create new service after platform mock
mockSpawnProcess.on.mockImplementation((event: string, callback: Function) => {
if (event === 'close') {
callback(0);
}
return mockSpawnProcess;
});
await windowsService.isAvailable();
expect(spawn).toHaveBeenCalledWith('where', ['claude']);
});
});
describe('stripAnsiCodes', () => {
it('should strip ANSI color codes from text', () => {
const service = new ClaudeUsageService();
const input = '\x1B[31mRed text\x1B[0m Normal text';
// @ts-expect-error - accessing private method for testing
const result = service.stripAnsiCodes(input);
expect(result).toBe('Red text Normal text');
});
it('should handle text without ANSI codes', () => {
const service = new ClaudeUsageService();
const input = 'Plain text';
// @ts-expect-error - accessing private method for testing
const result = service.stripAnsiCodes(input);
expect(result).toBe('Plain text');
});
});
describe('parseResetTime', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-15T10:00:00Z'));
});
afterEach(() => {
vi.useRealTimers();
});
it('should parse duration format with hours and minutes', () => {
const service = new ClaudeUsageService();
const text = 'Resets in 2h 15m';
// @ts-expect-error - accessing private method for testing
const result = service.parseResetTime(text, 'session');
const expected = new Date('2025-01-15T12:15:00Z');
expect(new Date(result)).toEqual(expected);
});
it('should parse duration format with only minutes', () => {
const service = new ClaudeUsageService();
const text = 'Resets in 30m';
// @ts-expect-error - accessing private method for testing
const result = service.parseResetTime(text, 'session');
const expected = new Date('2025-01-15T10:30:00Z');
expect(new Date(result)).toEqual(expected);
});
it('should parse simple time format (AM)', () => {
const service = new ClaudeUsageService();
const text = 'Resets 11am';
// @ts-expect-error - accessing private method for testing
const result = service.parseResetTime(text, 'session');
// Should be today at 11am, or tomorrow if already passed
const resultDate = new Date(result);
expect(resultDate.getHours()).toBe(11);
expect(resultDate.getMinutes()).toBe(0);
});
it('should parse simple time format (PM)', () => {
const service = new ClaudeUsageService();
const text = 'Resets 3pm';
// @ts-expect-error - accessing private method for testing
const result = service.parseResetTime(text, 'session');
const resultDate = new Date(result);
expect(resultDate.getHours()).toBe(15);
expect(resultDate.getMinutes()).toBe(0);
});
it('should parse date format with month, day, and time', () => {
const service = new ClaudeUsageService();
const text = 'Resets Dec 22 at 8pm';
// @ts-expect-error - accessing private method for testing
const result = service.parseResetTime(text, 'weekly');
const resultDate = new Date(result);
expect(resultDate.getMonth()).toBe(11); // December = 11
expect(resultDate.getDate()).toBe(22);
expect(resultDate.getHours()).toBe(20);
});
it('should parse date format with comma separator', () => {
const service = new ClaudeUsageService();
const text = 'Resets Jan 15, 3:30pm';
// @ts-expect-error - accessing private method for testing
const result = service.parseResetTime(text, 'weekly');
const resultDate = new Date(result);
expect(resultDate.getMonth()).toBe(0); // January = 0
expect(resultDate.getDate()).toBe(15);
expect(resultDate.getHours()).toBe(15);
expect(resultDate.getMinutes()).toBe(30);
});
it('should handle 12am correctly', () => {
const service = new ClaudeUsageService();
const text = 'Resets 12am';
// @ts-expect-error - accessing private method for testing
const result = service.parseResetTime(text, 'session');
const resultDate = new Date(result);
expect(resultDate.getHours()).toBe(0);
});
it('should handle 12pm correctly', () => {
const service = new ClaudeUsageService();
const text = 'Resets 12pm';
// @ts-expect-error - accessing private method for testing
const result = service.parseResetTime(text, 'session');
const resultDate = new Date(result);
expect(resultDate.getHours()).toBe(12);
});
it('should return default reset time for unparseable text', () => {
const service = new ClaudeUsageService();
const text = 'Invalid reset text';
// @ts-expect-error - accessing private method for testing
const result = service.parseResetTime(text, 'session');
// @ts-expect-error - accessing private method for testing
const defaultResult = service.getDefaultResetTime('session');
expect(result).toBe(defaultResult);
});
});
describe('getDefaultResetTime', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-15T10:00:00Z')); // Wednesday
});
afterEach(() => {
vi.useRealTimers();
});
it('should return session default (5 hours from now)', () => {
const service = new ClaudeUsageService();
// @ts-expect-error - accessing private method for testing
const result = service.getDefaultResetTime('session');
const expected = new Date('2025-01-15T15:00:00Z');
expect(new Date(result)).toEqual(expected);
});
it('should return weekly default (next Monday at noon)', () => {
const service = new ClaudeUsageService();
// @ts-expect-error - accessing private method for testing
const result = service.getDefaultResetTime('weekly');
const resultDate = new Date(result);
// Next Monday from Wednesday should be 5 days away
expect(resultDate.getDay()).toBe(1); // Monday
expect(resultDate.getHours()).toBe(12);
expect(resultDate.getMinutes()).toBe(59);
});
});
describe('parseSection', () => {
it('should parse section with percentage left', () => {
const service = new ClaudeUsageService();
const lines = ['Current session', '████████████████░░░░ 65% left', 'Resets in 2h 15m'];
// @ts-expect-error - accessing private method for testing
const result = service.parseSection(lines, 'Current session', 'session');
expect(result.percentage).toBe(35); // 100 - 65 = 35% used
expect(result.resetText).toBe('Resets in 2h 15m');
});
it('should parse section with percentage used', () => {
const service = new ClaudeUsageService();
const lines = [
'Current week (all models)',
'██████████░░░░░░░░░░ 40% used',
'Resets Jan 15, 3:30pm',
];
// @ts-expect-error - accessing private method for testing
const result = service.parseSection(lines, 'Current week (all models)', 'weekly');
expect(result.percentage).toBe(40); // Already in % used
});
it('should return zero percentage when section not found', () => {
const service = new ClaudeUsageService();
const lines = ['Some other text', 'No matching section'];
// @ts-expect-error - accessing private method for testing
const result = service.parseSection(lines, 'Current session', 'session');
expect(result.percentage).toBe(0);
});
it('should strip timezone from reset text', () => {
const service = new ClaudeUsageService();
const lines = ['Current session', '65% left', 'Resets 3pm (America/Los_Angeles)'];
// @ts-expect-error - accessing private method for testing
const result = service.parseSection(lines, 'Current session', 'session');
expect(result.resetText).toBe('Resets 3pm');
expect(result.resetText).not.toContain('America/Los_Angeles');
});
it('should handle case-insensitive section matching', () => {
const service = new ClaudeUsageService();
const lines = ['CURRENT SESSION', '65% left', 'Resets in 2h'];
// @ts-expect-error - accessing private method for testing
const result = service.parseSection(lines, 'current session', 'session');
expect(result.percentage).toBe(35);
});
});
describe('parseUsageOutput', () => {
it('should parse complete usage output', () => {
const service = new ClaudeUsageService();
const output = `
Claude Code v1.0.27
Current session
████████████████░░░░ 65% left
Resets in 2h 15m
Current week (all models)
██████████░░░░░░░░░░ 35% left
Resets Jan 15, 3:30pm (America/Los_Angeles)
Current week (Sonnet only)
████████████████████ 80% left
Resets Jan 15, 3:30pm (America/Los_Angeles)
`;
// @ts-expect-error - accessing private method for testing
const result = service.parseUsageOutput(output);
expect(result.sessionPercentage).toBe(35); // 100 - 65
expect(result.weeklyPercentage).toBe(65); // 100 - 35
expect(result.sonnetWeeklyPercentage).toBe(20); // 100 - 80
expect(result.sessionResetText).toContain('Resets in 2h 15m');
expect(result.weeklyResetText).toContain('Resets Jan 15, 3:30pm');
expect(result.userTimezone).toBe(Intl.DateTimeFormat().resolvedOptions().timeZone);
});
it('should handle output with ANSI codes', () => {
const service = new ClaudeUsageService();
const output = `
\x1B[1mClaude Code v1.0.27\x1B[0m
\x1B[1mCurrent session\x1B[0m
\x1B[32m████████████████░░░░\x1B[0m 65% left
Resets in 2h 15m
`;
// @ts-expect-error - accessing private method for testing
const result = service.parseUsageOutput(output);
expect(result.sessionPercentage).toBe(35);
});
it('should handle Opus section name', () => {
const service = new ClaudeUsageService();
const output = `
Current session
65% left
Resets in 2h
Current week (all models)
35% left
Resets Jan 15, 3pm
Current week (Opus)
90% left
Resets Jan 15, 3pm
`;
// @ts-expect-error - accessing private method for testing
const result = service.parseUsageOutput(output);
expect(result.sonnetWeeklyPercentage).toBe(10); // 100 - 90
});
it('should set default values for missing sections', () => {
const service = new ClaudeUsageService();
const output = 'Claude Code v1.0.27';
// @ts-expect-error - accessing private method for testing
const result = service.parseUsageOutput(output);
expect(result.sessionPercentage).toBe(0);
expect(result.weeklyPercentage).toBe(0);
expect(result.sonnetWeeklyPercentage).toBe(0);
expect(result.sessionTokensUsed).toBe(0);
expect(result.sessionLimit).toBe(0);
expect(result.costUsed).toBeNull();
expect(result.costLimit).toBeNull();
expect(result.costCurrency).toBeNull();
});
});
describe('executeClaudeUsageCommandMac', () => {
beforeEach(() => {
vi.mocked(os.platform).mockReturnValue('darwin');
vi.spyOn(process, 'env', 'get').mockReturnValue({ HOME: '/Users/testuser' });
});
it('should execute expect script and return output', async () => {
const mockOutput = `
Current session
65% left
Resets in 2h
`;
let stdoutCallback: Function;
let closeCallback: Function;
mockSpawnProcess.stdout = {
on: vi.fn((event: string, callback: Function) => {
if (event === 'data') {
stdoutCallback = callback;
}
}),
};
mockSpawnProcess.stderr = {
on: vi.fn(),
};
mockSpawnProcess.on = vi.fn((event: string, callback: Function) => {
if (event === 'close') {
closeCallback = callback;
}
return mockSpawnProcess;
});
const promise = service.fetchUsageData();
// Simulate stdout data
stdoutCallback!(Buffer.from(mockOutput));
// Simulate successful close
closeCallback!(0);
const result = await promise;
expect(result.sessionPercentage).toBe(35); // 100 - 65
expect(spawn).toHaveBeenCalledWith(
'expect',
expect.arrayContaining(['-c']),
expect.any(Object)
);
});
it('should handle authentication errors', async () => {
const mockOutput = 'token_expired';
let stdoutCallback: Function;
let closeCallback: Function;
mockSpawnProcess.stdout = {
on: vi.fn((event: string, callback: Function) => {
if (event === 'data') {
stdoutCallback = callback;
}
}),
};
mockSpawnProcess.stderr = {
on: vi.fn(),
};
mockSpawnProcess.on = vi.fn((event: string, callback: Function) => {
if (event === 'close') {
closeCallback = callback;
}
return mockSpawnProcess;
});
const promise = service.fetchUsageData();
stdoutCallback!(Buffer.from(mockOutput));
closeCallback!(1);
await expect(promise).rejects.toThrow('Authentication required');
});
it('should handle timeout', async () => {
vi.useFakeTimers();
mockSpawnProcess.stdout = {
on: vi.fn(),
};
mockSpawnProcess.stderr = {
on: vi.fn(),
};
mockSpawnProcess.on = vi.fn(() => mockSpawnProcess);
mockSpawnProcess.kill = vi.fn();
const promise = service.fetchUsageData();
// Advance time past timeout (30 seconds)
vi.advanceTimersByTime(31000);
await expect(promise).rejects.toThrow('Command timed out');
vi.useRealTimers();
});
});
describe('executeClaudeUsageCommandWindows', () => {
beforeEach(() => {
vi.mocked(os.platform).mockReturnValue('win32');
vi.mocked(os.homedir).mockReturnValue('C:\\Users\\testuser');
vi.spyOn(process, 'env', 'get').mockReturnValue({ USERPROFILE: 'C:\\Users\\testuser' });
});
it('should use node-pty on Windows and return output', async () => {
const windowsService = new ClaudeUsageService(); // Create new service for Windows platform
const mockOutput = `
Current session
65% left
Resets in 2h
`;
let dataCallback: Function | undefined;
let exitCallback: Function | undefined;
const mockPty = {
onData: vi.fn((callback: Function) => {
dataCallback = callback;
}),
onExit: vi.fn((callback: Function) => {
exitCallback = callback;
}),
write: vi.fn(),
kill: vi.fn(),
};
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
const promise = windowsService.fetchUsageData();
// Simulate data
dataCallback!(mockOutput);
// Simulate successful exit
exitCallback!({ exitCode: 0 });
const result = await promise;
expect(result.sessionPercentage).toBe(35);
expect(pty.spawn).toHaveBeenCalledWith(
'cmd.exe',
['/c', 'claude', '/usage'],
expect.any(Object)
);
});
it('should send escape key after seeing usage data', async () => {
vi.useFakeTimers();
const windowsService = new ClaudeUsageService();
const mockOutput = 'Current session\n65% left';
let dataCallback: Function | undefined;
const mockPty = {
onData: vi.fn((callback: Function) => {
dataCallback = callback;
}),
onExit: vi.fn(),
write: vi.fn(),
kill: vi.fn(),
};
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
windowsService.fetchUsageData();
// Simulate seeing usage data
dataCallback!(mockOutput);
// Advance time to trigger escape key sending
vi.advanceTimersByTime(2100);
expect(mockPty.write).toHaveBeenCalledWith('\x1b');
vi.useRealTimers();
});
it('should handle authentication errors on Windows', async () => {
const windowsService = new ClaudeUsageService();
let dataCallback: Function | undefined;
let exitCallback: Function | undefined;
const mockPty = {
onData: vi.fn((callback: Function) => {
dataCallback = callback;
}),
onExit: vi.fn((callback: Function) => {
exitCallback = callback;
}),
write: vi.fn(),
kill: vi.fn(),
};
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
const promise = windowsService.fetchUsageData();
dataCallback!('authentication_error');
exitCallback!({ exitCode: 1 });
await expect(promise).rejects.toThrow('Authentication required');
});
it('should handle timeout on Windows', async () => {
vi.useFakeTimers();
const windowsService = new ClaudeUsageService();
const mockPty = {
onData: vi.fn(),
onExit: vi.fn(),
write: vi.fn(),
kill: vi.fn(),
};
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
const promise = windowsService.fetchUsageData();
vi.advanceTimersByTime(31000);
await expect(promise).rejects.toThrow('Command timed out');
expect(mockPty.kill).toHaveBeenCalled();
vi.useRealTimers();
});
});
});

70
pnpm-lock.yaml generated
View File

@@ -1,70 +0,0 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
cross-spawn:
specifier: ^7.0.6
version: 7.0.6
tree-kill:
specifier: ^1.2.2
version: 1.2.2
packages:
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
shebang-regex@3.0.0:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
tree-kill@1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
hasBin: true
snapshots:
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
shebang-command: 2.0.0
which: 2.0.2
isexe@2.0.0: {}
path-key@3.1.1: {}
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
shebang-regex@3.0.0: {}
tree-kill@1.2.2: {}
which@2.0.2:
dependencies:
isexe: 2.0.0