diff --git a/__tests__/core/server.test.ts b/__tests__/core/server.test.ts new file mode 100644 index 0000000..fb2451c --- /dev/null +++ b/__tests__/core/server.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"; +import { + type MockLiteMCPInstance, + type Tool, + createMockLiteMCPInstance, + createMockServices, + setupTestEnvironment, + cleanupMocks +} from '../utils/test-utils'; + +describe('Home Assistant MCP Server', () => { + let liteMcpInstance: MockLiteMCPInstance; + let addToolCalls: Tool[]; + let mocks: ReturnType; + + beforeEach(async () => { + // Setup test environment + mocks = setupTestEnvironment(); + liteMcpInstance = createMockLiteMCPInstance(); + + // Import the module which will execute the main function + await import('../../src/index.js'); + + // Get the mock instance and tool calls + addToolCalls = liteMcpInstance.addTool.mock.calls.map(call => call.args[0]); + }); + + afterEach(() => { + cleanupMocks({ liteMcpInstance, ...mocks }); + }); + + test('should connect to Home Assistant', async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + // Verify connection + expect(mocks.mockFetch.mock.calls.length).toBeGreaterThan(0); + expect(liteMcpInstance.start.mock.calls.length).toBeGreaterThan(0); + }); + + test('should handle connection errors', async () => { + // Setup error response + mocks.mockFetch = mock(() => Promise.reject(new Error('Connection failed'))); + globalThis.fetch = mocks.mockFetch; + + // Import module again with error mock + await import('../../src/index.js'); + + // Verify error handling + expect(mocks.mockFetch.mock.calls.length).toBeGreaterThan(0); + expect(liteMcpInstance.start.mock.calls.length).toBe(0); + }); + + describe('Tool Registration', () => { + test('should register all required tools', () => { + const toolNames = addToolCalls.map(tool => tool.name); + + expect(toolNames).toContain('list_devices'); + expect(toolNames).toContain('control'); + expect(toolNames).toContain('get_history'); + expect(toolNames).toContain('scene'); + expect(toolNames).toContain('notify'); + expect(toolNames).toContain('automation'); + expect(toolNames).toContain('addon'); + expect(toolNames).toContain('package'); + expect(toolNames).toContain('automation_config'); + }); + + test('should configure tools with correct parameters', () => { + const listDevicesTool = addToolCalls.find(tool => tool.name === 'list_devices'); + expect(listDevicesTool).toBeDefined(); + expect(listDevicesTool?.parameters).toBeDefined(); + + const controlTool = addToolCalls.find(tool => tool.name === 'control'); + expect(controlTool).toBeDefined(); + expect(controlTool?.parameters).toBeDefined(); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/helpers.test.ts b/__tests__/helpers.test.ts index 4c02405..fcc3e5b 100644 --- a/__tests__/helpers.test.ts +++ b/__tests__/helpers.test.ts @@ -1,15 +1,9 @@ -import { jest, describe, it, expect } from '@jest/globals'; - -// Helper function moved from src/helpers.ts -const formatToolCall = (obj: any, isError: boolean = false) => { - return { - content: [{ type: "text", text: JSON.stringify(obj, null, 2), isError }], - }; -}; +import { describe, expect, test } from "bun:test"; +import { formatToolCall } from "../src/utils/helpers"; describe('helpers', () => { describe('formatToolCall', () => { - it('should format an object into the correct structure', () => { + test('should format an object into the correct structure', () => { const testObj = { name: 'test', value: 123 }; const result = formatToolCall(testObj); @@ -22,7 +16,7 @@ describe('helpers', () => { }); }); - it('should handle error cases correctly', () => { + test('should handle error cases correctly', () => { const testObj = { error: 'test error' }; const result = formatToolCall(testObj, true); @@ -35,7 +29,7 @@ describe('helpers', () => { }); }); - it('should handle empty objects', () => { + test('should handle empty objects', () => { const testObj = {}; const result = formatToolCall(testObj); @@ -47,5 +41,26 @@ describe('helpers', () => { }] }); }); + + test('should handle null and undefined', () => { + const nullResult = formatToolCall(null); + const undefinedResult = formatToolCall(undefined); + + expect(nullResult).toEqual({ + content: [{ + type: 'text', + text: 'null', + isError: false + }] + }); + + expect(undefinedResult).toEqual({ + content: [{ + type: 'text', + text: 'undefined', + isError: false + }] + }); + }); }); }); \ No newline at end of file diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index e6ce733..361febf 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -1,42 +1,15 @@ -import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals'; -import { LiteMCP } from 'litemcp'; -import { get_hass } from '../src/hass/index.js'; +import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"; +import type { Mock } from "bun:test"; import type { WebSocket } from 'ws'; +import type { LiteMCP } from 'litemcp'; -// Load test environment variables with defaults -const TEST_HASS_HOST = process.env.TEST_HASS_HOST || 'http://localhost:8123'; -const TEST_HASS_TOKEN = process.env.TEST_HASS_TOKEN || 'test_token'; -const TEST_HASS_SOCKET_URL = process.env.TEST_HASS_SOCKET_URL || 'ws://localhost:8123/api/websocket'; +// Extend the global scope +declare global { + // eslint-disable-next-line no-var + var mockResponse: Response; +} -// Set environment variables for testing -process.env.HASS_HOST = TEST_HASS_HOST; -process.env.HASS_TOKEN = TEST_HASS_TOKEN; -process.env.HASS_SOCKET_URL = TEST_HASS_SOCKET_URL; - -// Mock fetch -const mockFetchResponse = { - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ automation_id: 'test_automation' }), - text: async () => '{"automation_id":"test_automation"}', - headers: new Headers(), - body: null, - bodyUsed: false, - arrayBuffer: async () => new ArrayBuffer(0), - blob: async () => new Blob([]), - formData: async () => new FormData(), - clone: function () { return { ...this }; }, - type: 'default', - url: '', - redirected: false, - redirect: () => Promise.resolve(new Response()) -} as Response; - -const mockFetch = jest.fn(async (_input: string | URL | Request, _init?: RequestInit) => mockFetchResponse); -(global as any).fetch = mockFetch; - -// Mock LiteMCP +// Types interface Tool { name: string; description: string; @@ -44,30 +17,18 @@ interface Tool { execute: (params: Record) => Promise; } -type MockFunction = jest.Mock, any[]>; - interface MockLiteMCPInstance { - addTool: ReturnType; - start: ReturnType; + addTool: Mock<(tool: Tool) => void>; + start: Mock<() => Promise>; } -const mockLiteMCPInstance: MockLiteMCPInstance = { - addTool: jest.fn(), - start: jest.fn().mockResolvedValue(undefined) -}; - -jest.mock('litemcp', () => ({ - LiteMCP: jest.fn(() => mockLiteMCPInstance) -})); - -// Mock get_hass interface MockServices { light: { - turn_on: jest.Mock; - turn_off: jest.Mock; + turn_on: Mock<() => Promise<{ success: boolean }>>; + turn_off: Mock<() => Promise<{ success: boolean }>>; }; climate: { - set_temperature: jest.Mock; + set_temperature: Mock<() => Promise<{ success: boolean }>>; }; } @@ -75,21 +36,6 @@ interface MockHassInstance { services: MockServices; } -// Create mock services -const mockServices: MockServices = { - light: { - turn_on: jest.fn().mockResolvedValue({ success: true }), - turn_off: jest.fn().mockResolvedValue({ success: true }) - }, - climate: { - set_temperature: jest.fn().mockResolvedValue({ success: true }) - } -}; - -jest.unstable_mockModule('../src/hass/index.js', () => ({ - get_hass: jest.fn().mockResolvedValue({ services: mockServices }) -})); - interface TestResponse { success: boolean; message?: string; @@ -103,99 +49,212 @@ interface TestResponse { new_automation_id?: string; } -type WebSocketEventMap = { - message: MessageEvent; - open: Event; - close: Event; - error: Event; +// Test configuration +const TEST_CONFIG = { + HASS_HOST: process.env.TEST_HASS_HOST || 'http://localhost:8123', + HASS_TOKEN: process.env.TEST_HASS_TOKEN || 'test_token', + HASS_SOCKET_URL: process.env.TEST_HASS_SOCKET_URL || 'ws://localhost:8123/api/websocket' +} as const; + +// Setup test environment +Object.entries(TEST_CONFIG).forEach(([key, value]) => { + process.env[key] = value; +}); + +// Mock instances +const mockLiteMCPInstance: MockLiteMCPInstance = { + addTool: mock((tool: Tool) => undefined), + start: mock(() => Promise.resolve()) }; -type WebSocketEventListener = (event: Event) => void; -type WebSocketMessageListener = (event: MessageEvent) => void; +const mockServices: MockServices = { + light: { + turn_on: mock(() => Promise.resolve({ success: true })), + turn_off: mock(() => Promise.resolve({ success: true })) + }, + climate: { + set_temperature: mock(() => Promise.resolve({ success: true })) + } +}; -interface MockWebSocketInstance { - addEventListener: jest.Mock; - removeEventListener: jest.Mock; - send: jest.Mock; - close: jest.Mock; - readyState: number; - binaryType: 'blob' | 'arraybuffer'; - bufferedAmount: number; - extensions: string; - protocol: string; - url: string; - onopen: WebSocketEventListener | null; - onerror: WebSocketEventListener | null; - onclose: WebSocketEventListener | null; - onmessage: WebSocketMessageListener | null; - CONNECTING: number; - OPEN: number; - CLOSING: number; - CLOSED: number; +// Mock WebSocket +class MockWebSocket implements Partial { + public static readonly CONNECTING = 0; + public static readonly OPEN = 1; + public static readonly CLOSING = 2; + public static readonly CLOSED = 3; + + public readyState: 0 | 1 | 2 | 3 = MockWebSocket.OPEN; + public bufferedAmount = 0; + public extensions = ''; + public protocol = ''; + public url = ''; + public binaryType: 'arraybuffer' | 'nodebuffer' | 'fragments' = 'arraybuffer'; + + public onopen: ((event: any) => void) | null = null; + public onerror: ((event: any) => void) | null = null; + public onclose: ((event: any) => void) | null = null; + public onmessage: ((event: any) => void) | null = null; + + public addEventListener = mock(() => undefined); + public removeEventListener = mock(() => undefined); + public send = mock(() => undefined); + public close = mock(() => undefined); + public ping = mock(() => undefined); + public pong = mock(() => undefined); + public terminate = mock(() => undefined); + public dispatchEvent = mock(() => true); + + constructor(url: string | URL, protocols?: string | string[]) { + this.url = url.toString(); + if (protocols) { + this.protocol = Array.isArray(protocols) ? protocols[0] : protocols; + } + } } -const createMockWebSocket = (): MockWebSocketInstance => ({ - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - send: jest.fn(), - close: jest.fn(), - readyState: 0, - binaryType: 'blob', - bufferedAmount: 0, - extensions: '', - protocol: '', - url: '', - onopen: null, - onerror: null, - onclose: null, - onmessage: null, - CONNECTING: 0, - OPEN: 1, - CLOSING: 2, - CLOSED: 3 +// Create fetch mock with implementation +let mockFetch = mock(() => { + return Promise.resolve(new Response()); }); +// Override globals +globalThis.fetch = mockFetch; +// Use type assertion to handle WebSocket compatibility +globalThis.WebSocket = MockWebSocket as any; + describe('Home Assistant MCP Server', () => { let mockHass: MockHassInstance; let liteMcpInstance: MockLiteMCPInstance; - let addToolCalls: Array<[Tool]>; + let addToolCalls: Tool[]; beforeEach(async () => { mockHass = { services: mockServices }; - // Reset all mocks - jest.clearAllMocks(); - mockFetch.mockClear(); + // Reset mocks + mockLiteMCPInstance.addTool.mock.calls = []; + mockLiteMCPInstance.start.mock.calls = []; + + // Setup default response + mockFetch = mock(() => { + return Promise.resolve(new Response( + JSON.stringify({ state: 'connected' }), + { status: 200 } + )); + }); + globalThis.fetch = mockFetch; // Import the module which will execute the main function await import('../src/index.js'); - // Mock WebSocket - const mockWs = createMockWebSocket(); - (global as any).WebSocket = jest.fn(() => mockWs); - // Get the mock instance liteMcpInstance = mockLiteMCPInstance; - addToolCalls = liteMcpInstance.addTool.mock.calls as Array<[Tool]>; + addToolCalls = mockLiteMCPInstance.addTool.mock.calls.map(call => call.args[0]); }); afterEach(() => { - jest.resetModules(); + // Clean up + mockLiteMCPInstance.addTool.mock.calls = []; + mockLiteMCPInstance.start.mock.calls = []; + mockFetch = mock(() => Promise.resolve(new Response())); + globalThis.fetch = mockFetch; }); - it('should connect to Home Assistant', async () => { - const hass = await get_hass(); - expect(hass).toBeDefined(); - expect(hass.services).toBeDefined(); - expect(typeof hass.services.light.turn_on).toBe('function'); + test('should connect to Home Assistant', async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + // Verify connection + expect(mockFetch.mock.calls.length).toBeGreaterThan(0); + expect(mockLiteMCPInstance.start.mock.calls.length).toBeGreaterThan(0); }); - it('should reuse the same instance on subsequent calls', async () => { - const firstInstance = await get_hass(); - const secondInstance = await get_hass(); - expect(firstInstance).toBe(secondInstance); + test('should handle connection errors', async () => { + // Setup error response + mockFetch = mock(() => Promise.reject(new Error('Connection failed'))); + globalThis.fetch = mockFetch; + + // Import module again with error mock + await import('../src/index.js'); + + // Verify error handling + expect(mockFetch.mock.calls.length).toBeGreaterThan(0); + expect(mockLiteMCPInstance.start.mock.calls.length).toBe(0); + }); + + describe('Tool Registration', () => { + test('should register all required tools', () => { + const toolNames = addToolCalls.map(tool => tool.name); + + expect(toolNames).toContain('list_devices'); + expect(toolNames).toContain('control'); + expect(toolNames).toContain('get_history'); + expect(toolNames).toContain('scene'); + expect(toolNames).toContain('notify'); + expect(toolNames).toContain('automation'); + expect(toolNames).toContain('addon'); + expect(toolNames).toContain('package'); + expect(toolNames).toContain('automation_config'); + }); + + test('should configure tools with correct parameters', () => { + const listDevicesTool = addToolCalls.find(tool => tool.name === 'list_devices'); + expect(listDevicesTool).toBeDefined(); + expect(listDevicesTool?.parameters).toBeDefined(); + + const controlTool = addToolCalls.find(tool => tool.name === 'control'); + expect(controlTool).toBeDefined(); + expect(controlTool?.parameters).toBeDefined(); + }); + }); + + describe('Tool Execution', () => { + test('should execute list_devices tool', async () => { + const listDevicesTool = addToolCalls.find(tool => tool.name === 'list_devices'); + expect(listDevicesTool).toBeDefined(); + + if (listDevicesTool) { + const mockDevices = [ + { + entity_id: 'light.living_room', + state: 'on', + attributes: { brightness: 255 } + } + ]; + + // Setup response for this test + mockFetch = mock(() => Promise.resolve(new Response( + JSON.stringify(mockDevices) + ))); + globalThis.fetch = mockFetch; + + const result = await listDevicesTool.execute({}) as TestResponse; + expect(result.success).toBe(true); + expect(result.devices).toBeDefined(); + } + }); + + test('should execute control tool', async () => { + const controlTool = addToolCalls.find(tool => tool.name === 'control'); + expect(controlTool).toBeDefined(); + + if (controlTool) { + // Setup response for this test + mockFetch = mock(() => Promise.resolve(new Response( + JSON.stringify({ success: true }) + ))); + globalThis.fetch = mockFetch; + + const result = await controlTool.execute({ + command: 'turn_on', + entity_id: 'light.living_room', + brightness: 255 + }) as TestResponse; + + expect(result.success).toBe(true); + expect(mockFetch.mock.calls.length).toBeGreaterThan(0); + } + }); }); describe('list_devices tool', () => { @@ -220,7 +279,7 @@ describe('Home Assistant MCP Server', () => { } as Response); // Get the tool registration - const listDevicesTool = addToolCalls.find(call => call[0].name === 'list_devices')?.[0]; + const listDevicesTool = addToolCalls.find(call => call.name === 'list_devices'); expect(listDevicesTool).toBeDefined(); if (!listDevicesTool) { @@ -251,7 +310,7 @@ describe('Home Assistant MCP Server', () => { mockFetch.mockRejectedValueOnce(new Error('Network error')); // Get the tool registration - const listDevicesTool = addToolCalls.find(call => call[0].name === 'list_devices')?.[0]; + const listDevicesTool = addToolCalls.find(call => call.name === 'list_devices'); expect(listDevicesTool).toBeDefined(); if (!listDevicesTool) { @@ -276,7 +335,7 @@ describe('Home Assistant MCP Server', () => { } as Response); // Get the tool registration - const controlTool = addToolCalls.find(call => call[0].name === 'control')?.[0]; + const controlTool = addToolCalls.find(call => call.name === 'control'); expect(controlTool).toBeDefined(); if (!controlTool) { @@ -296,11 +355,11 @@ describe('Home Assistant MCP Server', () => { // Verify the fetch call expect(mockFetch).toHaveBeenCalledWith( - `${TEST_HASS_HOST}/api/services/light/turn_on`, + `${TEST_CONFIG.HASS_HOST}/api/services/light/turn_on`, { method: 'POST', headers: { - Authorization: `Bearer ${TEST_HASS_TOKEN}`, + Authorization: `Bearer ${TEST_CONFIG.HASS_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -313,7 +372,7 @@ describe('Home Assistant MCP Server', () => { it('should handle unsupported domains', async () => { // Get the tool registration - const controlTool = addToolCalls.find(call => call[0].name === 'control')?.[0]; + const controlTool = addToolCalls.find(call => call.name === 'control'); expect(controlTool).toBeDefined(); if (!controlTool) { @@ -339,7 +398,7 @@ describe('Home Assistant MCP Server', () => { } as Response); // Get the tool registration - const controlTool = addToolCalls.find(call => call[0].name === 'control')?.[0]; + const controlTool = addToolCalls.find(call => call.name === 'control'); expect(controlTool).toBeDefined(); if (!controlTool) { @@ -365,7 +424,7 @@ describe('Home Assistant MCP Server', () => { } as Response); // Get the tool registration - const controlTool = addToolCalls.find(call => call[0].name === 'control')?.[0]; + const controlTool = addToolCalls.find(call => call.name === 'control'); expect(controlTool).toBeDefined(); if (!controlTool) { @@ -387,11 +446,11 @@ describe('Home Assistant MCP Server', () => { // Verify the fetch call expect(mockFetch).toHaveBeenCalledWith( - `${TEST_HASS_HOST}/api/services/climate/set_temperature`, + `${TEST_CONFIG.HASS_HOST}/api/services/climate/set_temperature`, { method: 'POST', headers: { - Authorization: `Bearer ${TEST_HASS_TOKEN}`, + Authorization: `Bearer ${TEST_CONFIG.HASS_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -412,7 +471,7 @@ describe('Home Assistant MCP Server', () => { } as Response); // Get the tool registration - const controlTool = addToolCalls.find(call => call[0].name === 'control')?.[0]; + const controlTool = addToolCalls.find(call => call.name === 'control'); expect(controlTool).toBeDefined(); if (!controlTool) { @@ -432,11 +491,11 @@ describe('Home Assistant MCP Server', () => { // Verify the fetch call expect(mockFetch).toHaveBeenCalledWith( - `${TEST_HASS_HOST}/api/services/cover/set_position`, + `${TEST_CONFIG.HASS_HOST}/api/services/cover/set_position`, { method: 'POST', headers: { - Authorization: `Bearer ${TEST_HASS_TOKEN}`, + Authorization: `Bearer ${TEST_CONFIG.HASS_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -449,36 +508,29 @@ describe('Home Assistant MCP Server', () => { }); describe('get_history tool', () => { - it('should successfully fetch history', async () => { + test('should successfully fetch history', async () => { const mockHistory = [ { entity_id: 'light.living_room', state: 'on', last_changed: '2024-01-01T00:00:00Z', attributes: { brightness: 255 } - }, - { - entity_id: 'light.living_room', - state: 'off', - last_changed: '2024-01-01T01:00:00Z', - attributes: { brightness: 0 } } ]; - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockHistory - } as Response); + // Setup response for this test + mockFetch = mock(() => Promise.resolve(new Response( + JSON.stringify(mockHistory) + ))); + globalThis.fetch = mockFetch; - // Get the tool registration - const historyTool = addToolCalls.find(call => call[0].name === 'get_history')?.[0]; + const historyTool = addToolCalls.find(call => call.name === 'get_history'); expect(historyTool).toBeDefined(); if (!historyTool) { throw new Error('get_history tool not found'); } - // Execute the tool const result = (await historyTool.execute({ entity_id: 'light.living_room', start_time: '2024-01-01T00:00:00Z', @@ -491,29 +543,36 @@ describe('Home Assistant MCP Server', () => { expect(result.success).toBe(true); expect(result.history).toEqual(mockHistory); - // Verify the fetch call - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining('/api/history/period/2024-01-01T00:00:00Z?'), - expect.objectContaining({ - headers: { - Authorization: `Bearer ${TEST_HASS_TOKEN}`, - 'Content-Type': 'application/json' - } - }) - ); + // Verify the fetch call was made with correct URL and parameters + const calls = mockFetch.mock.calls; + expect(calls.length).toBeGreaterThan(0); - // Verify query parameters - const url = mockFetch.mock.calls[0][0] as string; - const queryParams = new URL(url).searchParams; - expect(queryParams.get('filter_entity_id')).toBe('light.living_room'); - expect(queryParams.get('minimal_response')).toBe('true'); - expect(queryParams.get('significant_changes_only')).toBe('true'); + const firstCall = calls[0]; + if (!firstCall?.args) { + throw new Error('No fetch calls recorded'); + } + + const [urlStr, options] = firstCall.args; + const url = new URL(urlStr); + expect(url.pathname).toContain('/api/history/period/2024-01-01T00:00:00Z'); + expect(url.searchParams.get('filter_entity_id')).toBe('light.living_room'); + expect(url.searchParams.get('minimal_response')).toBe('true'); + expect(url.searchParams.get('significant_changes_only')).toBe('true'); + + expect(options).toEqual({ + headers: { + Authorization: `Bearer ${TEST_CONFIG.HASS_TOKEN}`, + 'Content-Type': 'application/json' + } + }); }); - it('should handle fetch errors', async () => { - mockFetch.mockRejectedValueOnce(new Error('Network error')); + test('should handle fetch errors', async () => { + // Setup error response + mockFetch = mock(() => Promise.reject(new Error('Network error'))); + globalThis.fetch = mockFetch; - const historyTool = addToolCalls.find(call => call[0].name === 'get_history')?.[0]; + const historyTool = addToolCalls.find(call => call.name === 'get_history'); expect(historyTool).toBeDefined(); if (!historyTool) { @@ -555,7 +614,7 @@ describe('Home Assistant MCP Server', () => { json: async () => mockScenes } as Response); - const sceneTool = addToolCalls.find(call => call[0].name === 'scene')?.[0]; + const sceneTool = addToolCalls.find(call => call.name === 'scene'); expect(sceneTool).toBeDefined(); if (!sceneTool) { @@ -587,7 +646,7 @@ describe('Home Assistant MCP Server', () => { json: async () => ({}) } as Response); - const sceneTool = addToolCalls.find(call => call[0].name === 'scene')?.[0]; + const sceneTool = addToolCalls.find(call => call.name === 'scene'); expect(sceneTool).toBeDefined(); if (!sceneTool) { @@ -603,11 +662,11 @@ describe('Home Assistant MCP Server', () => { expect(result.message).toBe('Successfully activated scene scene.movie_time'); expect(mockFetch).toHaveBeenCalledWith( - `${TEST_HASS_HOST}/api/services/scene/turn_on`, + `${TEST_CONFIG.HASS_HOST}/api/services/scene/turn_on`, { method: 'POST', headers: { - Authorization: `Bearer ${TEST_HASS_TOKEN}`, + Authorization: `Bearer ${TEST_CONFIG.HASS_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -625,7 +684,7 @@ describe('Home Assistant MCP Server', () => { json: async () => ({}) } as Response); - const notifyTool = addToolCalls.find(call => call[0].name === 'notify')?.[0]; + const notifyTool = addToolCalls.find(call => call.name === 'notify'); expect(notifyTool).toBeDefined(); if (!notifyTool) { @@ -643,11 +702,11 @@ describe('Home Assistant MCP Server', () => { expect(result.message).toBe('Notification sent successfully'); expect(mockFetch).toHaveBeenCalledWith( - `${TEST_HASS_HOST}/api/services/notify/mobile_app_phone`, + `${TEST_CONFIG.HASS_HOST}/api/services/notify/mobile_app_phone`, { method: 'POST', headers: { - Authorization: `Bearer ${TEST_HASS_TOKEN}`, + Authorization: `Bearer ${TEST_CONFIG.HASS_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -660,12 +719,13 @@ describe('Home Assistant MCP Server', () => { }); it('should use default notification service when no target is specified', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({}) - } as Response); + // Setup response for this test + mockFetch = mock(() => Promise.resolve(new Response( + JSON.stringify({}) + ))); + globalThis.fetch = mockFetch; - const notifyTool = addToolCalls.find(call => call[0].name === 'notify')?.[0]; + const notifyTool = addToolCalls.find(call => call.name === 'notify'); expect(notifyTool).toBeDefined(); if (!notifyTool) { @@ -676,10 +736,11 @@ describe('Home Assistant MCP Server', () => { message: 'Test notification' }); - expect(mockFetch).toHaveBeenCalledWith( - `${TEST_HASS_HOST}/api/services/notify/notify`, - expect.any(Object) - ); + const calls = mockFetch.mock.calls; + expect(calls.length).toBeGreaterThan(0); + + const [url, _options] = calls[0].args; + expect(url.toString()).toBe(`${TEST_CONFIG.HASS_HOST}/api/services/notify/notify`); }); }); @@ -709,7 +770,7 @@ describe('Home Assistant MCP Server', () => { json: async () => mockAutomations } as Response); - const automationTool = addToolCalls.find(call => call[0].name === 'automation')?.[0]; + const automationTool = addToolCalls.find(call => call.name === 'automation'); expect(automationTool).toBeDefined(); if (!automationTool) { @@ -743,7 +804,7 @@ describe('Home Assistant MCP Server', () => { json: async () => ({}) } as Response); - const automationTool = addToolCalls.find(call => call[0].name === 'automation')?.[0]; + const automationTool = addToolCalls.find(call => call.name === 'automation'); expect(automationTool).toBeDefined(); if (!automationTool) { @@ -759,11 +820,11 @@ describe('Home Assistant MCP Server', () => { expect(result.message).toBe('Successfully toggled automation automation.morning_routine'); expect(mockFetch).toHaveBeenCalledWith( - `${TEST_HASS_HOST}/api/services/automation/toggle`, + `${TEST_CONFIG.HASS_HOST}/api/services/automation/toggle`, { method: 'POST', headers: { - Authorization: `Bearer ${TEST_HASS_TOKEN}`, + Authorization: `Bearer ${TEST_CONFIG.HASS_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -779,7 +840,7 @@ describe('Home Assistant MCP Server', () => { json: async () => ({}) } as Response); - const automationTool = addToolCalls.find(call => call[0].name === 'automation')?.[0]; + const automationTool = addToolCalls.find(call => call.name === 'automation'); expect(automationTool).toBeDefined(); if (!automationTool) { @@ -795,11 +856,11 @@ describe('Home Assistant MCP Server', () => { expect(result.message).toBe('Successfully triggered automation automation.morning_routine'); expect(mockFetch).toHaveBeenCalledWith( - `${TEST_HASS_HOST}/api/services/automation/trigger`, + `${TEST_CONFIG.HASS_HOST}/api/services/automation/trigger`, { method: 'POST', headers: { - Authorization: `Bearer ${TEST_HASS_TOKEN}`, + Authorization: `Bearer ${TEST_CONFIG.HASS_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -810,7 +871,7 @@ describe('Home Assistant MCP Server', () => { }); it('should require automation_id for toggle and trigger actions', async () => { - const automationTool = addToolCalls.find(call => call[0].name === 'automation')?.[0]; + const automationTool = addToolCalls.find(call => call.name === 'automation'); expect(automationTool).toBeDefined(); if (!automationTool) { @@ -858,7 +919,7 @@ describe('Home Assistant MCP Server', () => { json: async () => mockAddons } as Response); - const addonTool = addToolCalls.find(call => call[0].name === 'addon')?.[0]; + const addonTool = addToolCalls.find(call => call.name === 'addon'); expect(addonTool).toBeDefined(); if (!addonTool) { @@ -879,7 +940,7 @@ describe('Home Assistant MCP Server', () => { json: async () => ({ data: { state: 'installing' } }) } as Response); - const addonTool = addToolCalls.find(call => call[0].name === 'addon')?.[0]; + const addonTool = addToolCalls.find(call => call.name === 'addon'); expect(addonTool).toBeDefined(); if (!addonTool) { @@ -896,11 +957,11 @@ describe('Home Assistant MCP Server', () => { expect(result.message).toBe('Successfully installed add-on core_configurator'); expect(mockFetch).toHaveBeenCalledWith( - `${TEST_HASS_HOST}/api/hassio/addons/core_configurator/install`, + `${TEST_CONFIG.HASS_HOST}/api/hassio/addons/core_configurator/install`, { method: 'POST', headers: { - Authorization: `Bearer ${TEST_HASS_TOKEN}`, + Authorization: `Bearer ${TEST_CONFIG.HASS_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ version: '5.6.0' }) @@ -931,7 +992,7 @@ describe('Home Assistant MCP Server', () => { json: async () => mockPackages } as Response); - const packageTool = addToolCalls.find(call => call[0].name === 'package')?.[0]; + const packageTool = addToolCalls.find(call => call.name === 'package'); expect(packageTool).toBeDefined(); if (!packageTool) { @@ -953,7 +1014,7 @@ describe('Home Assistant MCP Server', () => { json: async () => ({}) } as Response); - const packageTool = addToolCalls.find(call => call[0].name === 'package')?.[0]; + const packageTool = addToolCalls.find(call => call.name === 'package'); expect(packageTool).toBeDefined(); if (!packageTool) { @@ -971,11 +1032,11 @@ describe('Home Assistant MCP Server', () => { expect(result.message).toBe('Successfully installed package hacs/integration'); expect(mockFetch).toHaveBeenCalledWith( - `${TEST_HASS_HOST}/api/hacs/repository/install`, + `${TEST_CONFIG.HASS_HOST}/api/hacs/repository/install`, { method: 'POST', headers: { - Authorization: `Bearer ${TEST_HASS_TOKEN}`, + Authorization: `Bearer ${TEST_CONFIG.HASS_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -1016,7 +1077,7 @@ describe('Home Assistant MCP Server', () => { json: async () => ({ automation_id: 'new_automation_1' }) } as Response); - const automationConfigTool = addToolCalls.find(call => call[0].name === 'automation_config')?.[0]; + const automationConfigTool = addToolCalls.find(call => call.name === 'automation_config'); expect(automationConfigTool).toBeDefined(); if (!automationConfigTool) { @@ -1033,11 +1094,11 @@ describe('Home Assistant MCP Server', () => { expect(result.automation_id).toBe('new_automation_1'); expect(mockFetch).toHaveBeenCalledWith( - `${TEST_HASS_HOST}/api/config/automation/config`, + `${TEST_CONFIG.HASS_HOST}/api/config/automation/config`, { method: 'POST', headers: { - Authorization: `Bearer ${TEST_HASS_TOKEN}`, + Authorization: `Bearer ${TEST_CONFIG.HASS_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify(mockAutomationConfig) @@ -1058,7 +1119,7 @@ describe('Home Assistant MCP Server', () => { json: async () => ({ automation_id: 'new_automation_2' }) } as Response); - const automationConfigTool = addToolCalls.find(call => call[0].name === 'automation_config')?.[0]; + const automationConfigTool = addToolCalls.find(call => call.name === 'automation_config'); expect(automationConfigTool).toBeDefined(); if (!automationConfigTool) { @@ -1076,17 +1137,17 @@ describe('Home Assistant MCP Server', () => { // Verify both API calls expect(mockFetch).toHaveBeenCalledWith( - `${TEST_HASS_HOST}/api/config/automation/config/automation.test`, + `${TEST_CONFIG.HASS_HOST}/api/config/automation/config/automation.test`, expect.any(Object) ); const duplicateConfig = { ...mockAutomationConfig, alias: 'Test Automation (Copy)' }; expect(mockFetch).toHaveBeenCalledWith( - `${TEST_HASS_HOST}/api/config/automation/config`, + `${TEST_CONFIG.HASS_HOST}/api/config/automation/config`, { method: 'POST', headers: { - Authorization: `Bearer ${TEST_HASS_TOKEN}`, + Authorization: `Bearer ${TEST_CONFIG.HASS_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify(duplicateConfig) @@ -1095,7 +1156,7 @@ describe('Home Assistant MCP Server', () => { }); it('should require config for create action', async () => { - const automationConfigTool = addToolCalls.find(call => call[0].name === 'automation_config')?.[0]; + const automationConfigTool = addToolCalls.find(call => call.name === 'automation_config'); expect(automationConfigTool).toBeDefined(); if (!automationConfigTool) { diff --git a/__tests__/server.test.ts b/__tests__/server.test.ts index bb3fd16..ed17fc3 100644 --- a/__tests__/server.test.ts +++ b/__tests__/server.test.ts @@ -1,61 +1,81 @@ -import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals'; -import express from 'express'; -import { LiteMCP } from 'litemcp'; -import { logger } from '../src/utils/logger.js'; +import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"; +import type { Mock } from "bun:test"; +import type { Express, Application } from 'express'; +import type { Logger } from 'winston'; + +// Types for our mocks +interface MockApp { + use: Mock<() => void>; + listen: Mock<(port: number, callback: () => void) => { close: Mock<() => void> }>; +} + +interface MockLiteMCPInstance { + addTool: Mock<() => void>; + start: Mock<() => Promise>; +} + +type MockLogger = { + info: Mock<(message: string) => void>; + error: Mock<(message: string) => void>; + debug: Mock<(message: string) => void>; +}; // Mock express -jest.mock('express', () => { - const mockApp = { - use: jest.fn(), - listen: jest.fn((port: number, callback: () => void) => { - callback(); - return { close: jest.fn() }; - }) - }; - return jest.fn(() => mockApp); -}); +const mockApp: MockApp = { + use: mock(() => undefined), + listen: mock((port: number, callback: () => void) => { + callback(); + return { close: mock(() => undefined) }; + }) +}; +const mockExpress = mock(() => mockApp); -// Mock LiteMCP -jest.mock('litemcp', () => ({ - LiteMCP: jest.fn(() => ({ - addTool: jest.fn(), - start: jest.fn().mockImplementation(async () => { }) - })) -})); +// Mock LiteMCP instance +const mockLiteMCPInstance: MockLiteMCPInstance = { + addTool: mock(() => undefined), + start: mock(() => Promise.resolve()) +}; +const mockLiteMCP = mock((name: string, version: string) => mockLiteMCPInstance); // Mock logger -jest.mock('../src/utils/logger.js', () => ({ - logger: { - info: jest.fn(), - error: jest.fn(), - debug: jest.fn() - } -})); +const mockLogger: MockLogger = { + info: mock((message: string) => undefined), + error: mock((message: string) => undefined), + debug: mock((message: string) => undefined) +}; describe('Server Initialization', () => { let originalEnv: NodeJS.ProcessEnv; - let mockApp: ReturnType; beforeEach(() => { // Store original environment originalEnv = { ...process.env }; - // Reset all mocks - jest.clearAllMocks(); + // Setup mocks + (globalThis as any).express = mockExpress; + (globalThis as any).LiteMCP = mockLiteMCP; + (globalThis as any).logger = mockLogger; - // Get the mock express app - mockApp = express(); + // Reset all mocks + mockApp.use.mockReset(); + mockApp.listen.mockReset(); + mockLogger.info.mockReset(); + mockLogger.error.mockReset(); + mockLogger.debug.mockReset(); + mockLiteMCP.mockReset(); }); afterEach(() => { // Restore original environment process.env = originalEnv; - // Clear module cache to ensure fresh imports - jest.resetModules(); + // Clean up mocks + delete (globalThis as any).express; + delete (globalThis as any).LiteMCP; + delete (globalThis as any).logger; }); - it('should start Express server when not in Claude mode', async () => { + test('should start Express server when not in Claude mode', async () => { // Set OpenAI mode process.env.PROCESSOR_TYPE = 'openai'; @@ -63,13 +83,15 @@ describe('Server Initialization', () => { await import('../src/index.js'); // Verify Express server was initialized - expect(express).toHaveBeenCalled(); - expect(mockApp.use).toHaveBeenCalled(); - expect(mockApp.listen).toHaveBeenCalled(); - expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Server is running on port')); + expect(mockExpress.mock.calls.length).toBeGreaterThan(0); + expect(mockApp.use.mock.calls.length).toBeGreaterThan(0); + expect(mockApp.listen.mock.calls.length).toBeGreaterThan(0); + + const infoMessages = mockLogger.info.mock.calls.map(([msg]) => msg); + expect(infoMessages.some(msg => msg.includes('Server is running on port'))).toBe(true); }); - it('should not start Express server in Claude mode', async () => { + test('should not start Express server in Claude mode', async () => { // Set Claude mode process.env.PROCESSOR_TYPE = 'claude'; @@ -77,28 +99,38 @@ describe('Server Initialization', () => { await import('../src/index.js'); // Verify Express server was not initialized - expect(express).not.toHaveBeenCalled(); - expect(mockApp.use).not.toHaveBeenCalled(); - expect(mockApp.listen).not.toHaveBeenCalled(); - expect(logger.info).toHaveBeenCalledWith('Running in Claude mode - Express server disabled'); + expect(mockExpress.mock.calls.length).toBe(0); + expect(mockApp.use.mock.calls.length).toBe(0); + expect(mockApp.listen.mock.calls.length).toBe(0); + + const infoMessages = mockLogger.info.mock.calls.map(([msg]) => msg); + expect(infoMessages).toContain('Running in Claude mode - Express server disabled'); }); - it('should initialize LiteMCP in both modes', async () => { + test('should initialize LiteMCP in both modes', async () => { // Test OpenAI mode process.env.PROCESSOR_TYPE = 'openai'; await import('../src/index.js'); - expect(LiteMCP).toHaveBeenCalledWith('home-assistant', expect.any(String)); - // Reset modules - jest.resetModules(); + expect(mockLiteMCP.mock.calls.length).toBeGreaterThan(0); + const [name, version] = mockLiteMCP.mock.calls[0] ?? []; + expect(name).toBe('home-assistant'); + expect(typeof version).toBe('string'); + + // Reset for next test + mockLiteMCP.mockReset(); // Test Claude mode process.env.PROCESSOR_TYPE = 'claude'; await import('../src/index.js'); - expect(LiteMCP).toHaveBeenCalledWith('home-assistant', expect.any(String)); + + expect(mockLiteMCP.mock.calls.length).toBeGreaterThan(0); + const [name2, version2] = mockLiteMCP.mock.calls[0] ?? []; + expect(name2).toBe('home-assistant'); + expect(typeof version2).toBe('string'); }); - it('should handle missing PROCESSOR_TYPE (default to Express server)', async () => { + test('should handle missing PROCESSOR_TYPE (default to Express server)', async () => { // Remove PROCESSOR_TYPE delete process.env.PROCESSOR_TYPE; @@ -106,9 +138,11 @@ describe('Server Initialization', () => { await import('../src/index.js'); // Verify Express server was initialized (default behavior) - expect(express).toHaveBeenCalled(); - expect(mockApp.use).toHaveBeenCalled(); - expect(mockApp.listen).toHaveBeenCalled(); - expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Server is running on port')); + expect(mockExpress.mock.calls.length).toBeGreaterThan(0); + expect(mockApp.use.mock.calls.length).toBeGreaterThan(0); + expect(mockApp.listen.mock.calls.length).toBeGreaterThan(0); + + const infoMessages = mockLogger.info.mock.calls.map(([msg]) => msg); + expect(infoMessages.some(msg => msg.includes('Server is running on port'))).toBe(true); }); }); \ No newline at end of file diff --git a/__tests__/speech/speechToText.test.ts b/__tests__/speech/speechToText.test.ts new file mode 100644 index 0000000..f08e905 --- /dev/null +++ b/__tests__/speech/speechToText.test.ts @@ -0,0 +1,327 @@ +import { SpeechToText, TranscriptionResult, WakeWordEvent, TranscriptionError, TranscriptionOptions } from '../../src/speech/speechToText'; +import { EventEmitter } from 'events'; +import fs from 'fs'; +import path from 'path'; +import { spawn } from 'child_process'; +import { describe, expect, beforeEach, afterEach, it, mock, spyOn } from 'bun:test'; + +// Mock child_process spawn +const spawnMock = mock((cmd: string, args: string[]) => ({ + stdout: new EventEmitter(), + stderr: new EventEmitter(), + on: (event: string, cb: (code: number) => void) => { + if (event === 'close') setTimeout(() => cb(0), 0); + } +})); + +describe('SpeechToText', () => { + let speechToText: SpeechToText; + const testAudioDir = path.join(import.meta.dir, 'test_audio'); + const mockConfig = { + containerName: 'test-whisper', + modelPath: '/models/whisper', + modelType: 'base.en' + }; + + beforeEach(() => { + speechToText = new SpeechToText(mockConfig); + // Create test audio directory if it doesn't exist + if (!fs.existsSync(testAudioDir)) { + fs.mkdirSync(testAudioDir, { recursive: true }); + } + // Reset spawn mock + spawnMock.mockReset(); + }); + + afterEach(() => { + speechToText.stopWakeWordDetection(); + // Clean up test files + if (fs.existsSync(testAudioDir)) { + fs.rmSync(testAudioDir, { recursive: true, force: true }); + } + }); + + describe('Initialization', () => { + it('should create instance with default config', () => { + const instance = new SpeechToText({ modelPath: '/models/whisper', modelType: 'base.en' }); + expect(instance instanceof EventEmitter).toBe(true); + expect(instance instanceof SpeechToText).toBe(true); + }); + + it('should initialize successfully', async () => { + const initSpy = spyOn(speechToText, 'initialize'); + await speechToText.initialize(); + expect(initSpy).toHaveBeenCalled(); + }); + + it('should not initialize twice', async () => { + await speechToText.initialize(); + const initSpy = spyOn(speechToText, 'initialize'); + await speechToText.initialize(); + expect(initSpy.mock.calls.length).toBe(1); + }); + }); + + describe('Health Check', () => { + it('should return true when Docker container is running', async () => { + const mockProcess = { + stdout: new EventEmitter(), + stderr: new EventEmitter(), + on: (event: string, cb: (code: number) => void) => { + if (event === 'close') setTimeout(() => cb(0), 0); + } + }; + spawnMock.mockImplementation(() => mockProcess); + + setTimeout(() => { + mockProcess.stdout.emit('data', Buffer.from('Up 2 hours')); + }, 0); + + const result = await speechToText.checkHealth(); + expect(result).toBe(true); + }); + + it('should return false when Docker container is not running', async () => { + const mockProcess = { + stdout: new EventEmitter(), + stderr: new EventEmitter(), + on: (event: string, cb: (code: number) => void) => { + if (event === 'close') setTimeout(() => cb(1), 0); + } + }; + spawnMock.mockImplementation(() => mockProcess); + + const result = await speechToText.checkHealth(); + expect(result).toBe(false); + }); + + it('should handle Docker command errors', async () => { + spawnMock.mockImplementation(() => { + throw new Error('Docker not found'); + }); + + const result = await speechToText.checkHealth(); + expect(result).toBe(false); + }); + }); + + describe('Wake Word Detection', () => { + it('should detect wake word and emit event', async () => { + const testFile = path.join(testAudioDir, 'wake_word_test_123456.wav'); + const testMetadata = `${testFile}.json`; + + return new Promise((resolve) => { + speechToText.startWakeWordDetection(testAudioDir); + + speechToText.on('wake_word', (event: WakeWordEvent) => { + expect(event).toBeDefined(); + expect(event.audioFile).toBe(testFile); + expect(event.metadataFile).toBe(testMetadata); + expect(event.timestamp).toBe('123456'); + resolve(); + }); + + // Create a test audio file to trigger the event + fs.writeFileSync(testFile, 'test audio content'); + }); + }); + + it('should handle non-wake-word files', async () => { + const testFile = path.join(testAudioDir, 'regular_audio.wav'); + let eventEmitted = false; + + return new Promise((resolve) => { + speechToText.startWakeWordDetection(testAudioDir); + + speechToText.on('wake_word', () => { + eventEmitted = true; + }); + + fs.writeFileSync(testFile, 'test audio content'); + + setTimeout(() => { + expect(eventEmitted).toBe(false); + resolve(); + }, 100); + }); + }); + }); + + describe('Audio Transcription', () => { + const mockTranscriptionResult: TranscriptionResult = { + text: 'Hello world', + segments: [{ + text: 'Hello world', + start: 0, + end: 1, + confidence: 0.95 + }] + }; + + it('should transcribe audio successfully', async () => { + const mockProcess = { + stdout: new EventEmitter(), + stderr: new EventEmitter(), + on: (event: string, cb: (code: number) => void) => { + if (event === 'close') setTimeout(() => cb(0), 0); + } + }; + spawnMock.mockImplementation(() => mockProcess); + + const transcriptionPromise = speechToText.transcribeAudio('/test/audio.wav'); + + setTimeout(() => { + mockProcess.stdout.emit('data', Buffer.from(JSON.stringify(mockTranscriptionResult))); + }, 0); + + const result = await transcriptionPromise; + expect(result).toEqual(mockTranscriptionResult); + }); + + it('should handle transcription errors', async () => { + const mockProcess = { + stdout: new EventEmitter(), + stderr: new EventEmitter(), + on: (event: string, cb: (code: number) => void) => { + if (event === 'close') setTimeout(() => cb(1), 0); + } + }; + spawnMock.mockImplementation(() => mockProcess); + + const transcriptionPromise = speechToText.transcribeAudio('/test/audio.wav'); + + setTimeout(() => { + mockProcess.stderr.emit('data', Buffer.from('Transcription failed')); + }, 0); + + await expect(transcriptionPromise).rejects.toThrow(TranscriptionError); + }); + + it('should handle invalid JSON output', async () => { + const mockProcess = { + stdout: new EventEmitter(), + stderr: new EventEmitter(), + on: (event: string, cb: (code: number) => void) => { + if (event === 'close') setTimeout(() => cb(0), 0); + } + }; + spawnMock.mockImplementation(() => mockProcess); + + const transcriptionPromise = speechToText.transcribeAudio('/test/audio.wav'); + + setTimeout(() => { + mockProcess.stdout.emit('data', Buffer.from('Invalid JSON')); + }, 0); + + await expect(transcriptionPromise).rejects.toThrow(TranscriptionError); + }); + + it('should pass correct transcription options', async () => { + const options: TranscriptionOptions = { + model: 'large-v2', + language: 'en', + temperature: 0.5, + beamSize: 3, + patience: 2, + device: 'cuda' + }; + + const mockProcess = { + stdout: new EventEmitter(), + stderr: new EventEmitter(), + on: (event: string, cb: (code: number) => void) => { + if (event === 'close') setTimeout(() => cb(0), 0); + } + }; + spawnMock.mockImplementation(() => mockProcess); + + const transcriptionPromise = speechToText.transcribeAudio('/test/audio.wav', options); + + const expectedArgs = [ + 'exec', + mockConfig.containerName, + 'fast-whisper', + '--model', options.model, + '--language', options.language, + '--temperature', String(options.temperature ?? 0), + '--beam-size', String(options.beamSize ?? 5), + '--patience', String(options.patience ?? 1), + '--device', options.device + ].filter((arg): arg is string => arg !== undefined); + + const mockCalls = spawnMock.mock.calls; + expect(mockCalls.length).toBe(1); + const [cmd, args] = mockCalls[0].args; + expect(cmd).toBe('docker'); + expect(expectedArgs.every(arg => args.includes(arg))).toBe(true); + + await transcriptionPromise.catch(() => { }); + }); + }); + + describe('Event Handling', () => { + it('should emit progress events', async () => { + const mockProcess = { + stdout: new EventEmitter(), + stderr: new EventEmitter(), + on: (event: string, cb: (code: number) => void) => { + if (event === 'close') setTimeout(() => cb(0), 0); + } + }; + spawnMock.mockImplementation(() => mockProcess); + + return new Promise((resolve) => { + const progressEvents: any[] = []; + speechToText.on('progress', (event) => { + progressEvents.push(event); + if (progressEvents.length === 2) { + expect(progressEvents).toEqual([ + { type: 'stdout', data: 'Processing' }, + { type: 'stderr', data: 'Loading model' } + ]); + resolve(); + } + }); + + void speechToText.transcribeAudio('/test/audio.wav'); + + mockProcess.stdout.emit('data', Buffer.from('Processing')); + mockProcess.stderr.emit('data', Buffer.from('Loading model')); + }); + }); + + it('should emit error events', async () => { + return new Promise((resolve) => { + speechToText.on('error', (error) => { + expect(error instanceof Error).toBe(true); + expect(error.message).toBe('Test error'); + resolve(); + }); + + speechToText.emit('error', new Error('Test error')); + }); + }); + }); + + describe('Cleanup', () => { + it('should stop wake word detection', () => { + speechToText.startWakeWordDetection(testAudioDir); + speechToText.stopWakeWordDetection(); + // Verify no more file watching events are processed + const testFile = path.join(testAudioDir, 'wake_word_test_123456.wav'); + let eventEmitted = false; + speechToText.on('wake_word', () => { + eventEmitted = true; + }); + fs.writeFileSync(testFile, 'test audio content'); + expect(eventEmitted).toBe(false); + }); + + it('should clean up resources on shutdown', async () => { + await speechToText.initialize(); + const shutdownSpy = spyOn(speechToText, 'shutdown'); + await speechToText.shutdown(); + expect(shutdownSpy).toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/tools/automation-config.test.ts b/__tests__/tools/automation-config.test.ts new file mode 100644 index 0000000..78b4036 --- /dev/null +++ b/__tests__/tools/automation-config.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"; +import { + type MockLiteMCPInstance, + type Tool, + type TestResponse, + TEST_CONFIG, + createMockLiteMCPInstance, + setupTestEnvironment, + cleanupMocks, + createMockResponse, + getMockCallArgs +} from '../utils/test-utils'; + +describe('Automation Configuration Tools', () => { + let liteMcpInstance: MockLiteMCPInstance; + let addToolCalls: Tool[]; + let mocks: ReturnType; + + const mockAutomationConfig = { + alias: 'Test Automation', + description: 'Test automation description', + mode: 'single', + trigger: [ + { + platform: 'state', + entity_id: 'binary_sensor.motion', + to: 'on' + } + ], + action: [ + { + service: 'light.turn_on', + target: { + entity_id: 'light.living_room' + } + } + ] + }; + + beforeEach(async () => { + // Setup test environment + mocks = setupTestEnvironment(); + liteMcpInstance = createMockLiteMCPInstance(); + + // Import the module which will execute the main function + await import('../../src/index.js'); + + // Get the mock instance and tool calls + addToolCalls = liteMcpInstance.addTool.mock.calls.map(call => call.args[0]); + }); + + afterEach(() => { + cleanupMocks({ liteMcpInstance, ...mocks }); + }); + + describe('automation_config tool', () => { + test('should successfully create an automation', async () => { + // Setup response + mocks.mockFetch = mock(() => Promise.resolve(createMockResponse({ + automation_id: 'new_automation_1' + }))); + globalThis.fetch = mocks.mockFetch; + + const automationConfigTool = addToolCalls.find(tool => tool.name === 'automation_config'); + expect(automationConfigTool).toBeDefined(); + + if (!automationConfigTool) { + throw new Error('automation_config tool not found'); + } + + const result = await automationConfigTool.execute({ + action: 'create', + config: mockAutomationConfig + }) as TestResponse; + + expect(result.success).toBe(true); + expect(result.message).toBe('Successfully created automation'); + expect(result.automation_id).toBe('new_automation_1'); + + // Verify the fetch call + type FetchArgs = [url: string, init: RequestInit]; + const args = getMockCallArgs(mocks.mockFetch); + expect(args).toBeDefined(); + + if (!args) { + throw new Error('No fetch calls recorded'); + } + + const [urlStr, options] = args; + expect(urlStr).toBe(`${TEST_CONFIG.HASS_HOST}/api/config/automation/config`); + expect(options).toEqual({ + method: 'POST', + headers: { + Authorization: `Bearer ${TEST_CONFIG.HASS_TOKEN}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(mockAutomationConfig) + }); + }); + + test('should successfully duplicate an automation', async () => { + // Setup responses for get and create + let callCount = 0; + mocks.mockFetch = mock(() => { + callCount++; + return Promise.resolve( + callCount === 1 + ? createMockResponse(mockAutomationConfig) + : createMockResponse({ automation_id: 'new_automation_2' }) + ); + }); + globalThis.fetch = mocks.mockFetch; + + const automationConfigTool = addToolCalls.find(tool => tool.name === 'automation_config'); + expect(automationConfigTool).toBeDefined(); + + if (!automationConfigTool) { + throw new Error('automation_config tool not found'); + } + + const result = await automationConfigTool.execute({ + action: 'duplicate', + automation_id: 'automation.test' + }) as TestResponse; + + expect(result.success).toBe(true); + expect(result.message).toBe('Successfully duplicated automation automation.test'); + expect(result.new_automation_id).toBe('new_automation_2'); + + // Verify both API calls + type FetchArgs = [url: string, init: RequestInit]; + const calls = mocks.mockFetch.mock.calls; + expect(calls.length).toBe(2); + + // Verify get call + const getArgs = getMockCallArgs(mocks.mockFetch, 0); + expect(getArgs).toBeDefined(); + if (!getArgs) throw new Error('No get call recorded'); + + const [getUrl, getOptions] = getArgs; + expect(getUrl).toBe(`${TEST_CONFIG.HASS_HOST}/api/config/automation/config/automation.test`); + expect(getOptions).toEqual({ + headers: { + Authorization: `Bearer ${TEST_CONFIG.HASS_TOKEN}`, + 'Content-Type': 'application/json' + } + }); + + // Verify create call + const createArgs = getMockCallArgs(mocks.mockFetch, 1); + expect(createArgs).toBeDefined(); + if (!createArgs) throw new Error('No create call recorded'); + + const [createUrl, createOptions] = createArgs; + expect(createUrl).toBe(`${TEST_CONFIG.HASS_HOST}/api/config/automation/config`); + expect(createOptions).toEqual({ + method: 'POST', + headers: { + Authorization: `Bearer ${TEST_CONFIG.HASS_TOKEN}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + ...mockAutomationConfig, + alias: 'Test Automation (Copy)' + }) + }); + }); + + test('should require config for create action', async () => { + const automationConfigTool = addToolCalls.find(tool => tool.name === 'automation_config'); + expect(automationConfigTool).toBeDefined(); + + if (!automationConfigTool) { + throw new Error('automation_config tool not found'); + } + + const result = await automationConfigTool.execute({ + action: 'create' + }) as TestResponse; + + expect(result.success).toBe(false); + expect(result.message).toBe('Configuration is required for creating automation'); + }); + + test('should require automation_id for update action', async () => { + const automationConfigTool = addToolCalls.find(tool => tool.name === 'automation_config'); + expect(automationConfigTool).toBeDefined(); + + if (!automationConfigTool) { + throw new Error('automation_config tool not found'); + } + + const result = await automationConfigTool.execute({ + action: 'update', + config: mockAutomationConfig + }) as TestResponse; + + expect(result.success).toBe(false); + expect(result.message).toBe('Automation ID and configuration are required for updating automation'); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/tools/automation.test.ts b/__tests__/tools/automation.test.ts new file mode 100644 index 0000000..73efcd5 --- /dev/null +++ b/__tests__/tools/automation.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"; +import { + type MockLiteMCPInstance, + type Tool, + type TestResponse, + TEST_CONFIG, + createMockLiteMCPInstance, + setupTestEnvironment, + cleanupMocks, + createMockResponse, + getMockCallArgs +} from '../utils/test-utils'; + +describe('Automation Tools', () => { + let liteMcpInstance: MockLiteMCPInstance; + let addToolCalls: Tool[]; + let mocks: ReturnType; + + beforeEach(async () => { + // Setup test environment + mocks = setupTestEnvironment(); + liteMcpInstance = createMockLiteMCPInstance(); + + // Import the module which will execute the main function + await import('../../src/index.js'); + + // Get the mock instance and tool calls + addToolCalls = liteMcpInstance.addTool.mock.calls.map(call => call.args[0]); + }); + + afterEach(() => { + cleanupMocks({ liteMcpInstance, ...mocks }); + }); + + describe('automation tool', () => { + const mockAutomations = [ + { + entity_id: 'automation.morning_routine', + state: 'on', + attributes: { + friendly_name: 'Morning Routine', + last_triggered: '2024-01-01T07:00:00Z' + } + }, + { + entity_id: 'automation.night_mode', + state: 'off', + attributes: { + friendly_name: 'Night Mode', + last_triggered: '2024-01-01T22:00:00Z' + } + } + ]; + + test('should successfully list automations', async () => { + // Setup response + mocks.mockFetch = mock(() => Promise.resolve(createMockResponse(mockAutomations))); + globalThis.fetch = mocks.mockFetch; + + const automationTool = addToolCalls.find(tool => tool.name === 'automation'); + expect(automationTool).toBeDefined(); + + if (!automationTool) { + throw new Error('automation tool not found'); + } + + const result = await automationTool.execute({ + action: 'list' + }) as TestResponse; + + expect(result.success).toBe(true); + expect(result.automations).toEqual([ + { + entity_id: 'automation.morning_routine', + name: 'Morning Routine', + state: 'on', + last_triggered: '2024-01-01T07:00:00Z' + }, + { + entity_id: 'automation.night_mode', + name: 'Night Mode', + state: 'off', + last_triggered: '2024-01-01T22:00:00Z' + } + ]); + }); + + test('should successfully toggle an automation', async () => { + // Setup response + mocks.mockFetch = mock(() => Promise.resolve(createMockResponse({}))); + globalThis.fetch = mocks.mockFetch; + + const automationTool = addToolCalls.find(tool => tool.name === 'automation'); + expect(automationTool).toBeDefined(); + + if (!automationTool) { + throw new Error('automation tool not found'); + } + + const result = await automationTool.execute({ + action: 'toggle', + automation_id: 'automation.morning_routine' + }) as TestResponse; + + expect(result.success).toBe(true); + expect(result.message).toBe('Successfully toggled automation automation.morning_routine'); + + // Verify the fetch call + type FetchArgs = [url: string, init: RequestInit]; + const args = getMockCallArgs(mocks.mockFetch); + expect(args).toBeDefined(); + + if (!args) { + throw new Error('No fetch calls recorded'); + } + + const [urlStr, options] = args; + expect(urlStr).toBe(`${TEST_CONFIG.HASS_HOST}/api/services/automation/toggle`); + expect(options).toEqual({ + method: 'POST', + headers: { + Authorization: `Bearer ${TEST_CONFIG.HASS_TOKEN}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + entity_id: 'automation.morning_routine' + }) + }); + }); + + test('should successfully trigger an automation', async () => { + // Setup response + mocks.mockFetch = mock(() => Promise.resolve(createMockResponse({}))); + globalThis.fetch = mocks.mockFetch; + + const automationTool = addToolCalls.find(tool => tool.name === 'automation'); + expect(automationTool).toBeDefined(); + + if (!automationTool) { + throw new Error('automation tool not found'); + } + + const result = await automationTool.execute({ + action: 'trigger', + automation_id: 'automation.morning_routine' + }) as TestResponse; + + expect(result.success).toBe(true); + expect(result.message).toBe('Successfully triggered automation automation.morning_routine'); + + // Verify the fetch call + type FetchArgs = [url: string, init: RequestInit]; + const args = getMockCallArgs(mocks.mockFetch); + expect(args).toBeDefined(); + + if (!args) { + throw new Error('No fetch calls recorded'); + } + + const [urlStr, options] = args; + expect(urlStr).toBe(`${TEST_CONFIG.HASS_HOST}/api/services/automation/trigger`); + expect(options).toEqual({ + method: 'POST', + headers: { + Authorization: `Bearer ${TEST_CONFIG.HASS_TOKEN}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + entity_id: 'automation.morning_routine' + }) + }); + }); + + test('should require automation_id for toggle and trigger actions', async () => { + const automationTool = addToolCalls.find(tool => tool.name === 'automation'); + expect(automationTool).toBeDefined(); + + if (!automationTool) { + throw new Error('automation tool not found'); + } + + const result = await automationTool.execute({ + action: 'toggle' + }) as TestResponse; + + expect(result.success).toBe(false); + expect(result.message).toBe('Automation ID is required for toggle and trigger actions'); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/tools/device-control.test.ts b/__tests__/tools/device-control.test.ts new file mode 100644 index 0000000..eda5c69 --- /dev/null +++ b/__tests__/tools/device-control.test.ts @@ -0,0 +1,361 @@ +import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"; +import { + type MockLiteMCPInstance, + type Tool, + type TestResponse, + TEST_CONFIG, + createMockLiteMCPInstance, + createMockServices, + setupTestEnvironment, + cleanupMocks, + createMockResponse, + getMockCallArgs +} from '../utils/test-utils'; + +describe('Device Control Tools', () => { + let liteMcpInstance: MockLiteMCPInstance; + let addToolCalls: Tool[]; + let mocks: ReturnType; + + beforeEach(async () => { + // Setup test environment + mocks = setupTestEnvironment(); + liteMcpInstance = createMockLiteMCPInstance(); + + // Import the module which will execute the main function + await import('../../src/index.js'); + + // Get the mock instance and tool calls + addToolCalls = liteMcpInstance.addTool.mock.calls.map(call => call.args[0]); + }); + + afterEach(() => { + cleanupMocks({ liteMcpInstance, ...mocks }); + }); + + describe('list_devices tool', () => { + test('should successfully list devices', async () => { + const mockDevices = [ + { + entity_id: 'light.living_room', + state: 'on', + attributes: { brightness: 255 } + }, + { + entity_id: 'climate.bedroom', + state: 'heat', + attributes: { temperature: 22 } + } + ]; + + // Setup response + mocks.mockFetch = mock(() => Promise.resolve(createMockResponse(mockDevices))); + globalThis.fetch = mocks.mockFetch; + + const listDevicesTool = addToolCalls.find(tool => tool.name === 'list_devices'); + expect(listDevicesTool).toBeDefined(); + + if (!listDevicesTool) { + throw new Error('list_devices tool not found'); + } + + const result = await listDevicesTool.execute({}) as TestResponse; + + expect(result.success).toBe(true); + expect(result.devices).toEqual({ + light: [{ + entity_id: 'light.living_room', + state: 'on', + attributes: { brightness: 255 } + }], + climate: [{ + entity_id: 'climate.bedroom', + state: 'heat', + attributes: { temperature: 22 } + }] + }); + }); + + test('should handle fetch errors', async () => { + // Setup error response + mocks.mockFetch = mock(() => Promise.reject(new Error('Network error'))); + globalThis.fetch = mocks.mockFetch; + + const listDevicesTool = addToolCalls.find(tool => tool.name === 'list_devices'); + expect(listDevicesTool).toBeDefined(); + + if (!listDevicesTool) { + throw new Error('list_devices tool not found'); + } + + const result = await listDevicesTool.execute({}) as TestResponse; + + expect(result.success).toBe(false); + expect(result.message).toBe('Network error'); + }); + }); + + describe('control tool', () => { + test('should successfully control a light device', async () => { + // Setup response + mocks.mockFetch = mock(() => Promise.resolve(createMockResponse({}))); + globalThis.fetch = mocks.mockFetch; + + const controlTool = addToolCalls.find(tool => tool.name === 'control'); + expect(controlTool).toBeDefined(); + + if (!controlTool) { + throw new Error('control tool not found'); + } + + const result = await controlTool.execute({ + command: 'turn_on', + entity_id: 'light.living_room', + brightness: 255 + }) as TestResponse; + + expect(result.success).toBe(true); + expect(result.message).toBe('Successfully executed turn_on for light.living_room'); + + // Verify the fetch call + const calls = mocks.mockFetch.mock.calls; + expect(calls.length).toBeGreaterThan(0); + + type FetchArgs = [url: string, init: RequestInit]; + const args = getMockCallArgs(mocks.mockFetch); + expect(args).toBeDefined(); + + if (!args) { + throw new Error('No fetch calls recorded'); + } + + const [urlStr, options] = args; + expect(urlStr).toBe(`${TEST_CONFIG.HASS_HOST}/api/services/light/turn_on`); + expect(options).toEqual({ + method: 'POST', + headers: { + Authorization: `Bearer ${TEST_CONFIG.HASS_TOKEN}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + entity_id: 'light.living_room', + brightness: 255 + }) + }); + }); + + test('should handle unsupported domains', async () => { + const controlTool = addToolCalls.find(tool => tool.name === 'control'); + expect(controlTool).toBeDefined(); + + if (!controlTool) { + throw new Error('control tool not found'); + } + + const result = await controlTool.execute({ + command: 'turn_on', + entity_id: 'unsupported.device' + }) as TestResponse; + + expect(result.success).toBe(false); + expect(result.message).toBe('Unsupported domain: unsupported'); + }); + + test('should handle service call errors', async () => { + // Setup error response + mocks.mockFetch = mock(() => Promise.resolve(new Response(null, { + status: 503, + statusText: 'Service unavailable' + }))); + globalThis.fetch = mocks.mockFetch; + + const controlTool = addToolCalls.find(tool => tool.name === 'control'); + expect(controlTool).toBeDefined(); + + if (!controlTool) { + throw new Error('control tool not found'); + } + + const result = await controlTool.execute({ + command: 'turn_on', + entity_id: 'light.living_room' + }) as TestResponse; + + expect(result.success).toBe(false); + expect(result.message).toContain('Failed to execute turn_on for light.living_room'); + }); + + test('should handle climate device controls', async () => { + // Setup response + mocks.mockFetch = mock(() => Promise.resolve(createMockResponse({}))); + globalThis.fetch = mocks.mockFetch; + + const controlTool = addToolCalls.find(tool => tool.name === 'control'); + expect(controlTool).toBeDefined(); + + if (!controlTool) { + throw new Error('control tool not found'); + } + + const result = await controlTool.execute({ + command: 'set_temperature', + entity_id: 'climate.bedroom', + temperature: 22, + target_temp_high: 24, + target_temp_low: 20 + }) as TestResponse; + + expect(result.success).toBe(true); + expect(result.message).toBe('Successfully executed set_temperature for climate.bedroom'); + + // Verify the fetch call + const calls = mocks.mockFetch.mock.calls; + expect(calls.length).toBeGreaterThan(0); + + type FetchArgs = [url: string, init: RequestInit]; + const args = getMockCallArgs(mocks.mockFetch); + expect(args).toBeDefined(); + + if (!args) { + throw new Error('No fetch calls recorded'); + } + + const [urlStr, options] = args; + expect(urlStr).toBe(`${TEST_CONFIG.HASS_HOST}/api/services/climate/set_temperature`); + expect(options).toEqual({ + method: 'POST', + headers: { + Authorization: `Bearer ${TEST_CONFIG.HASS_TOKEN}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + entity_id: 'climate.bedroom', + temperature: 22, + target_temp_high: 24, + target_temp_low: 20 + }) + }); + }); + }); + + describe('device_control tool', () => { + test('should successfully control a device', async () => { + // Setup response + mocks.mockFetch = mock(() => Promise.resolve(createMockResponse({ success: true }))); + globalThis.fetch = mocks.mockFetch; + + const deviceControlTool = addToolCalls.find(tool => tool.name === 'device_control'); + expect(deviceControlTool).toBeDefined(); + + if (!deviceControlTool) { + throw new Error('device_control tool not found'); + } + + const result = await deviceControlTool.execute({ + entity_id: 'light.living_room', + service: 'turn_on', + data: { + brightness: 255, + color_temp: 400 + } + }) as TestResponse; + + expect(result.success).toBe(true); + expect(result.message).toBe('Successfully controlled device light.living_room'); + + // Verify the fetch call + type FetchArgs = [url: string, init: RequestInit]; + const args = getMockCallArgs(mocks.mockFetch); + expect(args).toBeDefined(); + + if (!args) { + throw new Error('No fetch calls recorded'); + } + + const [urlStr, options] = args; + expect(urlStr).toBe(`${TEST_CONFIG.HASS_HOST}/api/services/light/turn_on`); + expect(options).toEqual({ + method: 'POST', + headers: { + Authorization: `Bearer ${TEST_CONFIG.HASS_TOKEN}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + entity_id: 'light.living_room', + brightness: 255, + color_temp: 400 + }) + }); + }); + + test('should handle device control failure', async () => { + // Setup error response + mocks.mockFetch = mock(() => Promise.reject(new Error('Failed to control device'))); + globalThis.fetch = mocks.mockFetch; + + const deviceControlTool = addToolCalls.find(tool => tool.name === 'device_control'); + expect(deviceControlTool).toBeDefined(); + + if (!deviceControlTool) { + throw new Error('device_control tool not found'); + } + + const result = await deviceControlTool.execute({ + entity_id: 'light.living_room', + service: 'turn_on' + }) as TestResponse; + + expect(result.success).toBe(false); + expect(result.message).toBe('Failed to control device: Failed to control device'); + }); + + test('should require entity_id', async () => { + const deviceControlTool = addToolCalls.find(tool => tool.name === 'device_control'); + expect(deviceControlTool).toBeDefined(); + + if (!deviceControlTool) { + throw new Error('device_control tool not found'); + } + + const result = await deviceControlTool.execute({ + service: 'turn_on' + }) as TestResponse; + + expect(result.success).toBe(false); + expect(result.message).toBe('Entity ID is required'); + }); + + test('should require service', async () => { + const deviceControlTool = addToolCalls.find(tool => tool.name === 'device_control'); + expect(deviceControlTool).toBeDefined(); + + if (!deviceControlTool) { + throw new Error('device_control tool not found'); + } + + const result = await deviceControlTool.execute({ + entity_id: 'light.living_room' + }) as TestResponse; + + expect(result.success).toBe(false); + expect(result.message).toBe('Service is required'); + }); + + test('should handle invalid service domain', async () => { + const deviceControlTool = addToolCalls.find(tool => tool.name === 'device_control'); + expect(deviceControlTool).toBeDefined(); + + if (!deviceControlTool) { + throw new Error('device_control tool not found'); + } + + const result = await deviceControlTool.execute({ + entity_id: 'light.living_room', + service: 'invalid_domain.turn_on' + }) as TestResponse; + + expect(result.success).toBe(false); + expect(result.message).toBe('Invalid service domain: invalid_domain'); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/tools/entity-state.test.ts b/__tests__/tools/entity-state.test.ts new file mode 100644 index 0000000..4b11fce --- /dev/null +++ b/__tests__/tools/entity-state.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"; +import { + type MockLiteMCPInstance, + type Tool, + type TestResponse, + TEST_CONFIG, + createMockLiteMCPInstance, + setupTestEnvironment, + cleanupMocks, + createMockResponse, + getMockCallArgs +} from '../utils/test-utils'; + +describe('Entity State Tools', () => { + let liteMcpInstance: MockLiteMCPInstance; + let addToolCalls: Tool[]; + let mocks: ReturnType; + + const mockEntityState = { + entity_id: 'light.living_room', + state: 'on', + attributes: { + brightness: 255, + color_temp: 400, + friendly_name: 'Living Room Light' + }, + last_changed: '2024-03-20T12:00:00Z', + last_updated: '2024-03-20T12:00:00Z', + context: { + id: 'test_context_id', + parent_id: null, + user_id: null + } + }; + + beforeEach(async () => { + // Setup test environment + mocks = setupTestEnvironment(); + liteMcpInstance = createMockLiteMCPInstance(); + + // Import the module which will execute the main function + await import('../../src/index.js'); + + // Get the mock instance and tool calls + addToolCalls = liteMcpInstance.addTool.mock.calls.map(call => call.args[0]); + }); + + afterEach(() => { + cleanupMocks({ liteMcpInstance, ...mocks }); + }); + + describe('entity_state tool', () => { + test('should successfully get entity state', async () => { + // Setup response + mocks.mockFetch = mock(() => Promise.resolve(createMockResponse(mockEntityState))); + globalThis.fetch = mocks.mockFetch; + + const entityStateTool = addToolCalls.find(tool => tool.name === 'entity_state'); + expect(entityStateTool).toBeDefined(); + + if (!entityStateTool) { + throw new Error('entity_state tool not found'); + } + + const result = await entityStateTool.execute({ + entity_id: 'light.living_room' + }) as TestResponse; + + expect(result.success).toBe(true); + expect(result.state).toBe('on'); + expect(result.attributes).toEqual(mockEntityState.attributes); + + // Verify the fetch call + type FetchArgs = [url: string, init: RequestInit]; + const args = getMockCallArgs(mocks.mockFetch); + expect(args).toBeDefined(); + + if (!args) { + throw new Error('No fetch calls recorded'); + } + + const [urlStr, options] = args; + expect(urlStr).toBe(`${TEST_CONFIG.HASS_HOST}/api/states/light.living_room`); + expect(options).toEqual({ + headers: { + Authorization: `Bearer ${TEST_CONFIG.HASS_TOKEN}`, + 'Content-Type': 'application/json' + } + }); + }); + + test('should handle entity not found', async () => { + // Setup error response + mocks.mockFetch = mock(() => Promise.reject(new Error('Entity not found'))); + globalThis.fetch = mocks.mockFetch; + + const entityStateTool = addToolCalls.find(tool => tool.name === 'entity_state'); + expect(entityStateTool).toBeDefined(); + + if (!entityStateTool) { + throw new Error('entity_state tool not found'); + } + + const result = await entityStateTool.execute({ + entity_id: 'light.non_existent' + }) as TestResponse; + + expect(result.success).toBe(false); + expect(result.message).toBe('Failed to get entity state: Entity not found'); + }); + + test('should require entity_id', async () => { + const entityStateTool = addToolCalls.find(tool => tool.name === 'entity_state'); + expect(entityStateTool).toBeDefined(); + + if (!entityStateTool) { + throw new Error('entity_state tool not found'); + } + + const result = await entityStateTool.execute({}) as TestResponse; + + expect(result.success).toBe(false); + expect(result.message).toBe('Entity ID is required'); + }); + + test('should handle invalid entity_id format', async () => { + const entityStateTool = addToolCalls.find(tool => tool.name === 'entity_state'); + expect(entityStateTool).toBeDefined(); + + if (!entityStateTool) { + throw new Error('entity_state tool not found'); + } + + const result = await entityStateTool.execute({ + entity_id: 'invalid_entity_id' + }) as TestResponse; + + expect(result.success).toBe(false); + expect(result.message).toBe('Invalid entity ID format: invalid_entity_id'); + }); + + test('should successfully get multiple entity states', async () => { + // Setup response + const mockStates = [ + { ...mockEntityState }, + { + ...mockEntityState, + entity_id: 'light.kitchen', + attributes: { ...mockEntityState.attributes, friendly_name: 'Kitchen Light' } + } + ]; + mocks.mockFetch = mock(() => Promise.resolve(createMockResponse(mockStates))); + globalThis.fetch = mocks.mockFetch; + + const entityStateTool = addToolCalls.find(tool => tool.name === 'entity_state'); + expect(entityStateTool).toBeDefined(); + + if (!entityStateTool) { + throw new Error('entity_state tool not found'); + } + + const result = await entityStateTool.execute({ + entity_id: ['light.living_room', 'light.kitchen'] + }) as TestResponse; + + expect(result.success).toBe(true); + expect(Array.isArray(result.states)).toBe(true); + expect(result.states).toHaveLength(2); + expect(result.states[0].entity_id).toBe('light.living_room'); + expect(result.states[1].entity_id).toBe('light.kitchen'); + + // Verify the fetch call + type FetchArgs = [url: string, init: RequestInit]; + const args = getMockCallArgs(mocks.mockFetch); + expect(args).toBeDefined(); + + if (!args) { + throw new Error('No fetch calls recorded'); + } + + const [urlStr, options] = args; + expect(urlStr).toBe(`${TEST_CONFIG.HASS_HOST}/api/states`); + expect(options).toEqual({ + headers: { + Authorization: `Bearer ${TEST_CONFIG.HASS_TOKEN}`, + 'Content-Type': 'application/json' + } + }); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/tools/scene-control.test.ts b/__tests__/tools/scene-control.test.ts new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/__tests__/tools/scene-control.test.ts @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/__tests__/tools/script-control.test.ts b/__tests__/tools/script-control.test.ts new file mode 100644 index 0000000..832e05b --- /dev/null +++ b/__tests__/tools/script-control.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"; +import { + type MockLiteMCPInstance, + type Tool, + type TestResponse, + TEST_CONFIG, + createMockLiteMCPInstance, + setupTestEnvironment, + cleanupMocks, + createMockResponse, + getMockCallArgs +} from '../utils/test-utils'; + +describe('Script Control Tools', () => { + let liteMcpInstance: MockLiteMCPInstance; + let addToolCalls: Tool[]; + let mocks: ReturnType; + + beforeEach(async () => { + // Setup test environment + mocks = setupTestEnvironment(); + liteMcpInstance = createMockLiteMCPInstance(); + + // Import the module which will execute the main function + await import('../../src/index.js'); + + // Get the mock instance and tool calls + addToolCalls = liteMcpInstance.addTool.mock.calls.map(call => call.args[0]); + }); + + afterEach(() => { + cleanupMocks({ liteMcpInstance, ...mocks }); + }); + + describe('script_control tool', () => { + test('should successfully execute a script', async () => { + // Setup response + mocks.mockFetch = mock(() => Promise.resolve(createMockResponse({ success: true }))); + globalThis.fetch = mocks.mockFetch; + + const scriptControlTool = addToolCalls.find(tool => tool.name === 'script_control'); + expect(scriptControlTool).toBeDefined(); + + if (!scriptControlTool) { + throw new Error('script_control tool not found'); + } + + const result = await scriptControlTool.execute({ + script_id: 'script.welcome_home', + action: 'start', + variables: { + brightness: 100, + color_temp: 300 + } + }) as TestResponse; + + expect(result.success).toBe(true); + expect(result.message).toBe('Successfully executed script script.welcome_home'); + + // Verify the fetch call + type FetchArgs = [url: string, init: RequestInit]; + const args = getMockCallArgs(mocks.mockFetch); + expect(args).toBeDefined(); + + if (!args) { + throw new Error('No fetch calls recorded'); + } + + const [urlStr, options] = args; + expect(urlStr).toBe(`${TEST_CONFIG.HASS_HOST}/api/services/script/turn_on`); + expect(options).toEqual({ + method: 'POST', + headers: { + Authorization: `Bearer ${TEST_CONFIG.HASS_TOKEN}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + entity_id: 'script.welcome_home', + variables: { + brightness: 100, + color_temp: 300 + } + }) + }); + }); + + test('should successfully stop a script', async () => { + // Setup response + mocks.mockFetch = mock(() => Promise.resolve(createMockResponse({ success: true }))); + globalThis.fetch = mocks.mockFetch; + + const scriptControlTool = addToolCalls.find(tool => tool.name === 'script_control'); + expect(scriptControlTool).toBeDefined(); + + if (!scriptControlTool) { + throw new Error('script_control tool not found'); + } + + const result = await scriptControlTool.execute({ + script_id: 'script.welcome_home', + action: 'stop' + }) as TestResponse; + + expect(result.success).toBe(true); + expect(result.message).toBe('Successfully stopped script script.welcome_home'); + + // Verify the fetch call + type FetchArgs = [url: string, init: RequestInit]; + const args = getMockCallArgs(mocks.mockFetch); + expect(args).toBeDefined(); + + if (!args) { + throw new Error('No fetch calls recorded'); + } + + const [urlStr, options] = args; + expect(urlStr).toBe(`${TEST_CONFIG.HASS_HOST}/api/services/script/turn_off`); + expect(options).toEqual({ + method: 'POST', + headers: { + Authorization: `Bearer ${TEST_CONFIG.HASS_TOKEN}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + entity_id: 'script.welcome_home' + }) + }); + }); + + test('should handle script execution failure', async () => { + // Setup error response + mocks.mockFetch = mock(() => Promise.reject(new Error('Failed to execute script'))); + globalThis.fetch = mocks.mockFetch; + + const scriptControlTool = addToolCalls.find(tool => tool.name === 'script_control'); + expect(scriptControlTool).toBeDefined(); + + if (!scriptControlTool) { + throw new Error('script_control tool not found'); + } + + const result = await scriptControlTool.execute({ + script_id: 'script.welcome_home', + action: 'start' + }) as TestResponse; + + expect(result.success).toBe(false); + expect(result.message).toBe('Failed to execute script: Failed to execute script'); + }); + + test('should require script_id', async () => { + const scriptControlTool = addToolCalls.find(tool => tool.name === 'script_control'); + expect(scriptControlTool).toBeDefined(); + + if (!scriptControlTool) { + throw new Error('script_control tool not found'); + } + + const result = await scriptControlTool.execute({ + action: 'start' + }) as TestResponse; + + expect(result.success).toBe(false); + expect(result.message).toBe('Script ID is required'); + }); + + test('should require action', async () => { + const scriptControlTool = addToolCalls.find(tool => tool.name === 'script_control'); + expect(scriptControlTool).toBeDefined(); + + if (!scriptControlTool) { + throw new Error('script_control tool not found'); + } + + const result = await scriptControlTool.execute({ + script_id: 'script.welcome_home' + }) as TestResponse; + + expect(result.success).toBe(false); + expect(result.message).toBe('Action is required'); + }); + + test('should handle invalid script_id format', async () => { + const scriptControlTool = addToolCalls.find(tool => tool.name === 'script_control'); + expect(scriptControlTool).toBeDefined(); + + if (!scriptControlTool) { + throw new Error('script_control tool not found'); + } + + const result = await scriptControlTool.execute({ + script_id: 'invalid_script_id', + action: 'start' + }) as TestResponse; + + expect(result.success).toBe(false); + expect(result.message).toBe('Invalid script ID format: invalid_script_id'); + }); + + test('should handle invalid action', async () => { + const scriptControlTool = addToolCalls.find(tool => tool.name === 'script_control'); + expect(scriptControlTool).toBeDefined(); + + if (!scriptControlTool) { + throw new Error('script_control tool not found'); + } + + const result = await scriptControlTool.execute({ + script_id: 'script.welcome_home', + action: 'invalid_action' + }) as TestResponse; + + expect(result.success).toBe(false); + expect(result.message).toBe('Invalid action: invalid_action'); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/types/litemcp.d.ts b/__tests__/types/litemcp.d.ts new file mode 100644 index 0000000..81a9d02 --- /dev/null +++ b/__tests__/types/litemcp.d.ts @@ -0,0 +1,19 @@ +declare module 'litemcp' { + export interface Tool { + name: string; + description: string; + parameters: Record; + execute: (params: Record) => Promise; + } + + export interface LiteMCPOptions { + name: string; + version: string; + } + + export class LiteMCP { + constructor(options: LiteMCPOptions); + addTool(tool: Tool): void; + start(): Promise; + } +} \ No newline at end of file diff --git a/__tests__/utils/test-utils.ts b/__tests__/utils/test-utils.ts new file mode 100644 index 0000000..75b9a37 --- /dev/null +++ b/__tests__/utils/test-utils.ts @@ -0,0 +1,148 @@ +import { mock } from "bun:test"; +import type { Mock } from "bun:test"; +import type { WebSocket } from 'ws'; + +// Common Types +export interface Tool { + name: string; + description: string; + parameters: Record; + execute: (params: Record) => Promise; +} + +export interface MockLiteMCPInstance { + addTool: Mock<(tool: Tool) => void>; + start: Mock<() => Promise>; +} + +export interface MockServices { + light: { + turn_on: Mock<() => Promise<{ success: boolean }>>; + turn_off: Mock<() => Promise<{ success: boolean }>>; + }; + climate: { + set_temperature: Mock<() => Promise<{ success: boolean }>>; + }; +} + +export interface MockHassInstance { + services: MockServices; +} + +export type TestResponse = { + success: boolean; + message?: string; + automation_id?: string; + new_automation_id?: string; + state?: string; + attributes?: Record; + states?: Array<{ + entity_id: string; + state: string; + attributes: Record; + last_changed: string; + last_updated: string; + context: { + id: string; + parent_id: string | null; + user_id: string | null; + }; + }>; +}; + +// Test Configuration +export const TEST_CONFIG = { + HASS_HOST: process.env.TEST_HASS_HOST || 'http://localhost:8123', + HASS_TOKEN: process.env.TEST_HASS_TOKEN || 'test_token', + HASS_SOCKET_URL: process.env.TEST_HASS_SOCKET_URL || 'ws://localhost:8123/api/websocket' +} as const; + +// Mock WebSocket Implementation +export class MockWebSocket { + public static readonly CONNECTING = 0; + public static readonly OPEN = 1; + public static readonly CLOSING = 2; + public static readonly CLOSED = 3; + + public readyState: 0 | 1 | 2 | 3 = MockWebSocket.OPEN; + public bufferedAmount = 0; + public extensions = ''; + public protocol = ''; + public url = ''; + public binaryType: 'arraybuffer' | 'nodebuffer' | 'fragments' = 'arraybuffer'; + + public onopen: ((event: any) => void) | null = null; + public onerror: ((event: any) => void) | null = null; + public onclose: ((event: any) => void) | null = null; + public onmessage: ((event: any) => void) | null = null; + + public addEventListener = mock(() => undefined); + public removeEventListener = mock(() => undefined); + public send = mock(() => undefined); + public close = mock(() => undefined); + public ping = mock(() => undefined); + public pong = mock(() => undefined); + public terminate = mock(() => undefined); + + constructor(url: string | URL, protocols?: string | string[]) { + this.url = url.toString(); + if (protocols) { + this.protocol = Array.isArray(protocols) ? protocols[0] : protocols; + } + } +} + +// Mock Service Instances +export const createMockServices = (): MockServices => ({ + light: { + turn_on: mock(() => Promise.resolve({ success: true })), + turn_off: mock(() => Promise.resolve({ success: true })) + }, + climate: { + set_temperature: mock(() => Promise.resolve({ success: true })) + } +}); + +export const createMockLiteMCPInstance = (): MockLiteMCPInstance => ({ + addTool: mock((tool: Tool) => undefined), + start: mock(() => Promise.resolve()) +}); + +// Helper Functions +export const createMockResponse = (data: T, status = 200): Response => { + return new Response(JSON.stringify(data), { status }); +}; + +export const getMockCallArgs = ( + mock: Mock<(...args: any[]) => any>, + callIndex = 0 +): T | undefined => { + const call = mock.mock.calls[callIndex]; + return call?.args as T | undefined; +}; + +export const setupTestEnvironment = () => { + // Setup test environment variables + Object.entries(TEST_CONFIG).forEach(([key, value]) => { + process.env[key] = value; + }); + + // Create fetch mock + const mockFetch = mock(() => Promise.resolve(createMockResponse({ state: 'connected' }))); + + // Override globals + globalThis.fetch = mockFetch; + globalThis.WebSocket = MockWebSocket as any; + + return { mockFetch }; +}; + +export const cleanupMocks = (mocks: { + liteMcpInstance: MockLiteMCPInstance; + mockFetch: Mock<() => Promise>; +}) => { + mocks.liteMcpInstance.addTool.mock.calls = []; + mocks.liteMcpInstance.start.mock.calls = []; + mocks.mockFetch = mock(() => Promise.resolve(new Response())); + globalThis.fetch = mocks.mockFetch; +}; \ No newline at end of file diff --git a/bun.lock b/bun.lock old mode 100755 new mode 100644 index 0789379..c0200fb --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,5 @@ { - "lockfileVersion": 0, + "lockfileVersion": 1, "workspaces": { "": { "dependencies": { @@ -9,11 +9,13 @@ "@types/node": "^20.11.24", "@types/sanitize-html": "^2.9.5", "@types/ws": "^8.5.10", + "@xmldom/xmldom": "^0.9.7", "dotenv": "^16.4.5", "elysia": "^1.2.11", "helmet": "^7.1.0", "jsonwebtoken": "^9.0.2", "node-fetch": "^3.3.2", + "openai": "^4.82.0", "sanitize-html": "^2.11.0", "typescript": "^5.3.3", "winston": "^3.11.0", @@ -81,6 +83,8 @@ "@types/node": ["@types/node@20.17.17", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-/WndGO4kIfMicEQLTi/mDANUu/iVUhT7KboZPdEqqHQ4aTS+3qT3U5gIqWDFV+XouorjfgGqvKILJeHhuQgFYg=="], + "@types/node-fetch": ["@types/node-fetch@2.6.12", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA=="], + "@types/sanitize-html": ["@types/sanitize-html@2.13.0", "", { "dependencies": { "htmlparser2": "^8.0.0" } }, "sha512-X31WxbvW9TjIhZZNyNBZ/p5ax4ti7qsNDBDEnH4zAgmEh35YnFD1UiS6z9Cd34kKm0LslFW0KPmTQzu/oGtsqQ=="], "@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="], @@ -109,10 +113,16 @@ "@unhead/schema": ["@unhead/schema@1.11.18", "", { "dependencies": { "hookable": "^5.5.3", "zhead": "^2.2.4" } }, "sha512-a3TA/OJCRdfbFhcA3Hq24k1ZU1o9szicESrw8DZcGyQFacHnh84mVgnyqSkMnwgCmfN4kvjSiTBlLEHS6+wATw=="], + "@xmldom/xmldom": ["@xmldom/xmldom@0.9.7", "", {}, "sha512-syvR8iIJjpTZ/stv7l89UAViwGFh6lbheeOaqSxkYx9YNmIVvPTRH+CT/fpykFtUx5N+8eSMDRvggF9J8GEPzQ=="], + + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + "acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], + "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -233,6 +243,8 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="], @@ -267,6 +279,10 @@ "form-data": ["form-data@4.0.1", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "mime-types": "^2.1.12" } }, "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw=="], + "form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="], + + "formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="], + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], "formidable": ["formidable@2.1.2", "", { "dependencies": { "dezalgo": "^1.0.4", "hexoid": "^1.0.0", "once": "^1.4.0", "qs": "^6.11.0" } }, "sha512-CM3GuJ57US06mlpQ47YcunuUZ9jpm8Vx+P2CGt2j7HpgkKZO/DJYQ0Bobim8G6PFQmK5lOqOOdUXboU+h73A4g=="], @@ -305,6 +321,8 @@ "htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="], + "humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="], + "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], @@ -411,6 +429,8 @@ "one-time": ["one-time@1.0.0", "", { "dependencies": { "fn.name": "1.x.x" } }, "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g=="], + "openai": ["openai@4.82.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-1bTxOVGZuVGsKKUWbh3BEwX1QxIXUftJv+9COhhGGVDTFwiaOd4gWsMynF2ewj1mg6by3/O+U8+EEHpWRdPaJg=="], + "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], @@ -509,6 +529,8 @@ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + "triple-beam": ["triple-beam@1.4.1", "", {}, "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg=="], "ts-api-utils": ["ts-api-utils@1.4.3", "", { "peerDependencies": { "typescript": ">=4.2.0" } }, "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw=="], @@ -531,6 +553,10 @@ "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "winston": ["winston@3.17.0", "", { "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.2", "async": "^3.2.3", "is-stream": "^2.0.0", "logform": "^2.7.0", "one-time": "^1.0.0", "readable-stream": "^3.4.0", "safe-stable-stringify": "^2.3.1", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", "winston-transport": "^4.9.0" } }, "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw=="], @@ -561,10 +587,18 @@ "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "formdata-node/web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="], + + "openai/@types/node": ["@types/node@18.19.75", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-UIksWtThob6ZVSyxcOqCLOUNg/dyO1Qvx4McgeuhrEtHTLFTf7BBhEazaE4K806FGTPtzd/2sE90qn4fVr7cyw=="], + + "openai/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "@scalar/themes/@scalar/types/@scalar/openapi-types": ["@scalar/openapi-types@0.1.7", "", {}, "sha512-oOTG3JQifg55U3DhKB7WdNIxFnJzbPJe7rqdyWdio977l8IkxQTVmObftJhdNIMvhV2K+1f/bDoMQGu6yTaD0A=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], "color/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], + + "openai/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], } } diff --git a/docs/development/test-migration-guide.md b/docs/development/test-migration-guide.md new file mode 100644 index 0000000..2bff68f --- /dev/null +++ b/docs/development/test-migration-guide.md @@ -0,0 +1,323 @@ +# Migrating Tests from Jest to Bun + +This guide provides instructions for migrating test files from Jest to Bun's test framework. + +## Table of Contents +- [Basic Setup](#basic-setup) +- [Import Changes](#import-changes) +- [API Changes](#api-changes) +- [Mocking](#mocking) +- [Common Patterns](#common-patterns) +- [Examples](#examples) + +## Basic Setup + +1. Remove Jest-related dependencies from `package.json`: +```json +{ + "devDependencies": { + "@jest/globals": "...", + "jest": "...", + "ts-jest": "..." + } +} +``` + +2. Remove Jest configuration files: +- `jest.config.js` +- `jest.setup.js` + +3. Update test scripts in `package.json`: +```json +{ + "scripts": { + "test": "bun test", + "test:watch": "bun test --watch", + "test:coverage": "bun test --coverage" + } +} +``` + +## Import Changes + +### Before (Jest): +```typescript +import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +``` + +### After (Bun): +```typescript +import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"; +import type { Mock } from "bun:test"; +``` + +Note: `it` is replaced with `test` in Bun. + +## API Changes + +### Test Structure +```typescript +// Jest +describe('Suite', () => { + it('should do something', () => { + // test + }); +}); + +// Bun +describe('Suite', () => { + test('should do something', () => { + // test + }); +}); +``` + +### Assertions +Most Jest assertions work the same in Bun: + +```typescript +// These work the same in both: +expect(value).toBe(expected); +expect(value).toEqual(expected); +expect(value).toBeDefined(); +expect(value).toBeUndefined(); +expect(value).toBeTruthy(); +expect(value).toBeFalsy(); +expect(array).toContain(item); +expect(value).toBeInstanceOf(Class); +expect(spy).toHaveBeenCalled(); +expect(spy).toHaveBeenCalledWith(...args); +``` + +## Mocking + +### Function Mocking + +#### Before (Jest): +```typescript +const mockFn = jest.fn(); +mockFn.mockImplementation(() => 'result'); +mockFn.mockResolvedValue('result'); +mockFn.mockRejectedValue(new Error()); +``` + +#### After (Bun): +```typescript +const mockFn = mock(() => 'result'); +const mockAsyncFn = mock(() => Promise.resolve('result')); +const mockErrorFn = mock(() => Promise.reject(new Error())); +``` + +### Module Mocking + +#### Before (Jest): +```typescript +jest.mock('module-name', () => ({ + default: jest.fn(), + namedExport: jest.fn() +})); +``` + +#### After (Bun): +```typescript +// Option 1: Using vi.mock (if available) +vi.mock('module-name', () => ({ + default: mock(() => {}), + namedExport: mock(() => {}) +})); + +// Option 2: Using dynamic imports +const mockModule = { + default: mock(() => {}), + namedExport: mock(() => {}) +}; +``` + +### Mock Reset/Clear + +#### Before (Jest): +```typescript +jest.clearAllMocks(); +mockFn.mockClear(); +jest.resetModules(); +``` + +#### After (Bun): +```typescript +mockFn.mockReset(); +// or for specific calls +mockFn.mock.calls = []; +``` + +### Spy on Methods + +#### Before (Jest): +```typescript +jest.spyOn(object, 'method'); +``` + +#### After (Bun): +```typescript +const spy = mock(((...args) => object.method(...args))); +object.method = spy; +``` + +## Common Patterns + +### Async Tests +```typescript +// Works the same in both Jest and Bun: +test('async test', async () => { + const result = await someAsyncFunction(); + expect(result).toBe(expected); +}); +``` + +### Setup and Teardown +```typescript +describe('Suite', () => { + beforeEach(() => { + // setup + }); + + afterEach(() => { + // cleanup + }); + + test('test', () => { + // test + }); +}); +``` + +### Mocking Fetch +```typescript +// Before (Jest) +global.fetch = jest.fn(() => Promise.resolve(new Response())); + +// After (Bun) +const mockFetch = mock(() => Promise.resolve(new Response())); +global.fetch = mockFetch as unknown as typeof fetch; +``` + +### Mocking WebSocket +```typescript +// Create a MockWebSocket class implementing WebSocket interface +class MockWebSocket implements WebSocket { + public static readonly CONNECTING = 0; + public static readonly OPEN = 1; + public static readonly CLOSING = 2; + public static readonly CLOSED = 3; + + public readyState: 0 | 1 | 2 | 3 = MockWebSocket.OPEN; + public addEventListener = mock(() => undefined); + public removeEventListener = mock(() => undefined); + public send = mock(() => undefined); + public close = mock(() => undefined); + // ... implement other required methods +} + +// Use it in tests +global.WebSocket = MockWebSocket as unknown as typeof WebSocket; +``` + +## Examples + +### Basic Test +```typescript +import { describe, expect, test } from "bun:test"; + +describe('formatToolCall', () => { + test('should format an object into the correct structure', () => { + const testObj = { name: 'test', value: 123 }; + const result = formatToolCall(testObj); + + expect(result).toEqual({ + content: [{ + type: 'text', + text: JSON.stringify(testObj, null, 2), + isError: false + }] + }); + }); +}); +``` + +### Async Test with Mocking +```typescript +import { describe, expect, test, mock } from "bun:test"; + +describe('API Client', () => { + test('should fetch data', async () => { + const mockResponse = { data: 'test' }; + const mockFetch = mock(() => Promise.resolve(new Response( + JSON.stringify(mockResponse), + { status: 200, headers: new Headers() } + ))); + global.fetch = mockFetch as unknown as typeof fetch; + + const result = await apiClient.getData(); + expect(result).toEqual(mockResponse); + }); +}); +``` + +### Complex Mocking Example +```typescript +import { describe, expect, test, mock } from "bun:test"; +import type { Mock } from "bun:test"; + +interface MockServices { + light: { + turn_on: Mock<() => Promise<{ success: boolean }>>; + turn_off: Mock<() => Promise<{ success: boolean }>>; + }; +} + +const mockServices: MockServices = { + light: { + turn_on: mock(() => Promise.resolve({ success: true })), + turn_off: mock(() => Promise.resolve({ success: true })) + } +}; + +describe('Home Assistant Service', () => { + test('should control lights', async () => { + const result = await mockServices.light.turn_on(); + expect(result.success).toBe(true); + }); +}); +``` + +## Best Practices + +1. Use TypeScript for better type safety in mocks +2. Keep mocks as simple as possible +3. Prefer interface-based mocks over concrete implementations +4. Use proper type assertions when necessary +5. Clean up mocks in `afterEach` blocks +6. Use descriptive test names +7. Group related tests using `describe` blocks + +## Common Issues and Solutions + +### Issue: Type Errors with Mocks +```typescript +// Solution: Use proper typing with Mock type +import type { Mock } from "bun:test"; +const mockFn: Mock<() => string> = mock(() => "result"); +``` + +### Issue: Global Object Mocking +```typescript +// Solution: Use type assertions carefully +global.someGlobal = mockImplementation as unknown as typeof someGlobal; +``` + +### Issue: Module Mocking +```typescript +// Solution: Use dynamic imports or vi.mock if available +const mockModule = { + default: mock(() => mockImplementation) +}; +``` \ No newline at end of file diff --git a/package.json b/package.json index fae1a4f..321db29 100644 --- a/package.json +++ b/package.json @@ -30,11 +30,13 @@ "@types/node": "^20.11.24", "@types/sanitize-html": "^2.9.5", "@types/ws": "^8.5.10", + "@xmldom/xmldom": "^0.9.7", "dotenv": "^16.4.5", "elysia": "^1.2.11", "helmet": "^7.1.0", "jsonwebtoken": "^9.0.2", "node-fetch": "^3.3.2", + "openai": "^4.82.0", "sanitize-html": "^2.11.0", "typescript": "^5.3.3", "winston": "^3.11.0", diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts new file mode 100644 index 0000000..7e91faa --- /dev/null +++ b/src/utils/helpers.ts @@ -0,0 +1,12 @@ +/** + * Formats a tool call response into a standardized structure + * @param obj The object to format + * @param isError Whether this is an error response + * @returns Formatted response object + */ +export const formatToolCall = (obj: any, isError: boolean = false) => { + const text = obj === undefined ? 'undefined' : JSON.stringify(obj, null, 2); + return { + content: [{ type: "text", text, isError }], + }; +}; \ No newline at end of file diff --git a/test.wav b/test.wav new file mode 100644 index 0000000..766cc85 Binary files /dev/null and b/test.wav differ