diff --git a/apps/server/tests/unit/services/claude-usage-service.test.ts b/apps/server/tests/unit/services/claude-usage-service.test.ts new file mode 100644 index 00000000..ed1ef69f --- /dev/null +++ b/apps/server/tests/unit/services/claude-usage-service.test.ts @@ -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(); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml deleted file mode 100644 index 06e3abdf..00000000 --- a/pnpm-lock.yaml +++ /dev/null @@ -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