From d7e5fcf76484dc570ae2ad2c6c8dd150b867315a Mon Sep 17 00:00:00 2001 From: jango-blockchained Date: Thu, 30 Jan 2025 09:04:07 +0100 Subject: [PATCH] Enhance Jest configuration and testing infrastructure - Updated Jest configuration to support ESM and improve test coverage - Added comprehensive test files for helpers, index, context, and HASS integration - Configured coverage reporting and added new test scripts - Updated Jest resolver to handle module resolution for chalk and related packages - Introduced new test setup files for mocking and environment configuration --- .gitignore | 2 + __tests__/context/context.test.ts | 210 ++++++---------------- __tests__/hass/hass.test.ts | 108 ++++++----- __tests__/helpers.test.ts | 45 +++++ __tests__/index.test.ts | 286 ++++++++++++++++++++++++++++++ coverage/lcov-report/index.html | 60 +++++-- coverage/lcov.info | 188 +++++++++++++++++++- jest-resolver.cjs | 11 +- jest.config.cjs | 74 ++++---- jest.setup.cjs | 32 ++++ jest.setup.js | 46 +++-- package.json | 8 +- src/schemas/hass.ts | 186 +++++++++++++++++++ src/types/hass.d.ts | 81 +++++++++ tsconfig.json | 22 ++- 15 files changed, 1077 insertions(+), 282 deletions(-) create mode 100644 __tests__/helpers.test.ts create mode 100644 __tests__/index.test.ts create mode 100644 jest.setup.cjs create mode 100644 src/schemas/hass.ts create mode 100644 src/types/hass.d.ts diff --git a/.gitignore b/.gitignore index 73fbe2c..90f0363 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,5 @@ package-lock.json yarn.lock pnpm-lock.yaml bun.lockb + +coverage/ \ No newline at end of file diff --git a/__tests__/context/context.test.ts b/__tests__/context/context.test.ts index 6a42ebc..d2e5ce5 100644 --- a/__tests__/context/context.test.ts +++ b/__tests__/context/context.test.ts @@ -1,14 +1,36 @@ +import { jest, describe, beforeEach, it, expect } from '@jest/globals'; import { z } from 'zod'; import { DomainSchema } from '../../src/schemas.js'; +type MockResponse = { success: true }; +type MockFn = jest.Mock, any[]>; + // Define types for tool and server interface Tool { name: string; description: string; - execute: (params: any) => Promise; + execute: (params: any) => Promise; parameters: z.ZodType; } +interface MockService { + [key: string]: MockFn; +} + +interface MockServices { + light: { + turn_on: MockFn; + turn_off: MockFn; + }; + climate: { + set_temperature: MockFn; + }; +} + +interface MockHassInstance { + services: MockServices; +} + // Mock LiteMCP class class MockLiteMCP { private tools: Tool[] = []; @@ -24,19 +46,27 @@ class MockLiteMCP { } } +const createMockFn = () => { + const fn = jest.fn(); + fn.mockReturnValue(Promise.resolve({ success: true as const })); + return fn as unknown as MockFn; +}; + // Mock the Home Assistant instance -jest.mock('../../src/hass/index.js', () => ({ - get_hass: jest.fn().mockResolvedValue({ - services: { - light: { - turn_on: jest.fn().mockResolvedValue(undefined), - turn_off: jest.fn().mockResolvedValue(undefined), - }, - climate: { - set_temperature: jest.fn().mockResolvedValue(undefined), - }, +const mockHassServices: MockHassInstance = { + services: { + light: { + turn_on: createMockFn(), + turn_off: createMockFn(), }, - }), + climate: { + set_temperature: createMockFn(), + }, + }, +}; + +jest.mock('../../src/hass/index.js', () => ({ + get_hass: jest.fn().mockReturnValue(Promise.resolve(mockHassServices)), })); describe('MCP Server Context and Tools', () => { @@ -48,156 +78,20 @@ describe('MCP Server Context and Tools', () => { // Add the control tool to the server server.addTool({ name: 'control', - description: 'Control Home Assistant devices and services', - execute: async (params: any) => { - const domain = params.entity_id.split('.')[0]; - if (params.command === 'set_temperature' && domain !== 'climate') { - return { - success: false, - message: `Unsupported operation for domain: ${domain}`, - }; - } - return { - success: true, - message: `Successfully executed ${params.command} for ${params.entity_id}`, - }; - }, - parameters: z.object({ - command: z.string(), - entity_id: z.string(), - brightness: z.number().min(0).max(255).optional(), - color_temp: z.number().optional(), - rgb_color: z.tuple([z.number(), z.number(), z.number()]).optional(), - temperature: z.number().optional(), - hvac_mode: z.enum(['off', 'heat', 'cool', 'heat_cool', 'auto', 'dry', 'fan_only']).optional(), - fan_mode: z.enum(['auto', 'low', 'medium', 'high']).optional(), - position: z.number().min(0).max(100).optional(), - tilt_position: z.number().min(0).max(100).optional(), - area: z.string().optional(), - }), + description: 'Control Home Assistant devices', + parameters: DomainSchema, + execute: createMockFn(), }); }); - afterEach(() => { - jest.clearAllMocks(); + it('should initialize with correct name and version', () => { + expect(server.name).toBe('home-assistant'); + expect(server.version).toBe('0.1.0'); }); - describe('Custom Prompts', () => { - it('should handle natural language commands for lights', async () => { - const tools = server.getTools(); - const tool = tools.find(t => t.name === 'control'); - expect(tool).toBeDefined(); - - // Test natural language command execution - const result = await tool!.execute({ - command: 'turn_on', - entity_id: 'light.living_room', - brightness: 128, - }); - - expect(result).toEqual({ - success: true, - message: expect.stringContaining('Successfully executed turn_on for light.living_room'), - }); - }); - - it('should handle natural language commands for climate control', async () => { - const tools = server.getTools(); - const tool = tools.find(t => t.name === 'control'); - expect(tool).toBeDefined(); - - // Test temperature control command - const result = await tool!.execute({ - command: 'set_temperature', - entity_id: 'climate.living_room', - temperature: 22, - }); - - expect(result).toEqual({ - success: true, - message: expect.stringContaining('Successfully executed set_temperature for climate.living_room'), - }); - }); - }); - - describe('High-Level Context', () => { - it('should validate domain-specific commands', async () => { - const tools = server.getTools(); - const tool = tools.find(t => t.name === 'control'); - expect(tool).toBeDefined(); - - // Test invalid command for domain - const result = await tool!.execute({ - command: 'set_temperature', // Climate command - entity_id: 'light.living_room', // Light entity - temperature: 22, - }); - - expect(result).toEqual({ - success: false, - message: expect.stringContaining('Unsupported operation'), - }); - }); - - it('should handle area-based commands', async () => { - const tools = server.getTools(); - const tool = tools.find(t => t.name === 'control'); - expect(tool).toBeDefined(); - - // Test command with area context - const result = await tool!.execute({ - command: 'turn_on', - entity_id: 'light.living_room', - area: 'Living Room', - }); - - expect(result).toEqual({ - success: true, - message: expect.stringContaining('Successfully executed turn_on for light.living_room'), - }); - }); - }); - - describe('Tool Organization', () => { - it('should have all required tools available', () => { - const tools = server.getTools(); - const toolNames = tools.map(t => t.name); - expect(toolNames).toContain('control'); - }); - - it('should support all defined domains', () => { - const tools = server.getTools(); - const tool = tools.find(t => t.name === 'control'); - expect(tool).toBeDefined(); - - // Check if tool supports all domains from DomainSchema - const supportedDomains = Object.values(DomainSchema.Values); - const schema = tool!.parameters as z.ZodObject; - const shape = schema.shape; - - expect(shape).toBeDefined(); - expect(shape.entity_id).toBeDefined(); - expect(shape.command).toBeDefined(); - - // Test each domain has its specific parameters - supportedDomains.forEach(domain => { - switch (domain) { - case 'light': - expect(shape.brightness).toBeDefined(); - expect(shape.color_temp).toBeDefined(); - expect(shape.rgb_color).toBeDefined(); - break; - case 'climate': - expect(shape.temperature).toBeDefined(); - expect(shape.hvac_mode).toBeDefined(); - expect(shape.fan_mode).toBeDefined(); - break; - case 'cover': - expect(shape.position).toBeDefined(); - expect(shape.tilt_position).toBeDefined(); - break; - } - }); - }); + it('should add and retrieve tools', () => { + const tools = server.getTools(); + expect(tools).toHaveLength(1); + expect(tools[0].name).toBe('control'); }); }); \ No newline at end of file diff --git a/__tests__/hass/hass.test.ts b/__tests__/hass/hass.test.ts index 2069920..1511ed4 100644 --- a/__tests__/hass/hass.test.ts +++ b/__tests__/hass/hass.test.ts @@ -1,28 +1,54 @@ -import { get_hass } from '../../src/hass/index.js'; +import { jest, describe, beforeEach, afterAll, it, expect } from '@jest/globals'; +import type { Mock } from 'jest-mock'; -// Mock the entire module -jest.mock('../../src/hass/index.js', () => { - let mockInstance: any = null; +// Define types +interface MockResponse { + success: boolean; +} - return { - get_hass: jest.fn(async () => { - if (!mockInstance) { - mockInstance = { - services: { - light: { - turn_on: jest.fn().mockResolvedValue(undefined), - turn_off: jest.fn().mockResolvedValue(undefined), - }, - climate: { - set_temperature: jest.fn().mockResolvedValue(undefined), - }, - }, - }; - } - return mockInstance; - }), +type MockFn = () => Promise; + +interface MockService { + [key: string]: Mock; +} + +interface MockServices { + light: { + turn_on: Mock; + turn_off: Mock; }; -}); + climate: { + set_temperature: Mock; + }; +} + +interface MockHassInstance { + services: MockServices; +} + +// Mock instance +let mockInstance: MockHassInstance | null = null; + +const createMockFn = (): Mock => { + return jest.fn().mockImplementation(async () => ({ success: true })); +}; + +// Mock the digital-alchemy modules before tests +jest.unstable_mockModule('@digital-alchemy/core', () => ({ + CreateApplication: jest.fn(() => ({ + configuration: {}, + bootstrap: async () => mockInstance, + services: {} + })), + TServiceParams: jest.fn() +})); + +jest.unstable_mockModule('@digital-alchemy/hass', () => ({ + LIB_HASS: { + configuration: {}, + services: {} + } +})); describe('Home Assistant Connection', () => { // Backup the original environment @@ -31,7 +57,18 @@ describe('Home Assistant Connection', () => { beforeEach(() => { // Clear all mocks jest.clearAllMocks(); - + // Initialize mock instance + mockInstance = { + services: { + light: { + turn_on: createMockFn(), + turn_off: createMockFn(), + }, + climate: { + set_temperature: createMockFn(), + }, + }, + }; // Reset environment variables process.env = { ...originalEnv }; }); @@ -42,6 +79,7 @@ describe('Home Assistant Connection', () => { }); it('should return a Home Assistant instance with services', async () => { + const { get_hass } = await import('../../src/hass/index.js'); const hass = await get_hass(); expect(hass).toBeDefined(); @@ -51,31 +89,11 @@ describe('Home Assistant Connection', () => { expect(typeof hass.services.climate.set_temperature).toBe('function'); }); - it('should reuse the same instance on multiple calls', async () => { + it('should reuse the same instance on subsequent calls', async () => { + const { get_hass } = await import('../../src/hass/index.js'); const firstInstance = await get_hass(); const secondInstance = await get_hass(); expect(firstInstance).toBe(secondInstance); }); - - it('should use "development" as default environment', async () => { - // Unset NODE_ENV - delete process.env.NODE_ENV; - - const hass = await get_hass(); - - // You might need to add a way to check the environment in your actual implementation - // This is a placeholder and might need adjustment based on your exact implementation - expect(process.env.NODE_ENV).toBe(undefined); - }); - - it('should use process.env.NODE_ENV when set', async () => { - // Set a specific environment - process.env.NODE_ENV = 'production'; - - const hass = await get_hass(); - - // You might need to add a way to check the environment in your actual implementation - expect(process.env.NODE_ENV).toBe('production'); - }); }); \ No newline at end of file diff --git a/__tests__/helpers.test.ts b/__tests__/helpers.test.ts new file mode 100644 index 0000000..faeaaa4 --- /dev/null +++ b/__tests__/helpers.test.ts @@ -0,0 +1,45 @@ +import { jest, describe, it, expect } from '@jest/globals'; +import { formatToolCall } from '../src/helpers.js'; + +describe('helpers', () => { + describe('formatToolCall', () => { + it('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 + }] + }); + }); + + it('should handle error cases correctly', () => { + const testObj = { error: 'test error' }; + const result = formatToolCall(testObj, true); + + expect(result).toEqual({ + content: [{ + type: 'text', + text: JSON.stringify(testObj, null, 2), + isError: true + }] + }); + }); + + it('should handle empty objects', () => { + const testObj = {}; + const result = formatToolCall(testObj); + + expect(result).toEqual({ + content: [{ + type: 'text', + text: '{}', + isError: false + }] + }); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts new file mode 100644 index 0000000..66d1ae6 --- /dev/null +++ b/__tests__/index.test.ts @@ -0,0 +1,286 @@ +import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals'; +import type { Mock } from 'jest-mock'; +import { LiteMCP } from 'litemcp'; + +// Mock environment variables +process.env.HASS_HOST = 'http://localhost:8123'; +process.env.HASS_TOKEN = 'test_token'; + +// Mock fetch +const mockFetch = jest.fn().mockImplementation( + async (input: string | URL | Request, init?: RequestInit): Promise => { + return {} as Response; + } +) as unknown as jest.MockedFunction; +global.fetch = mockFetch; + +// Mock LiteMCP +jest.mock('litemcp', () => { + return { + LiteMCP: jest.fn().mockImplementation(() => ({ + addTool: jest.fn(), + start: jest.fn().mockResolvedValue(undefined) + })) + }; +}); + +// Mock get_hass +jest.unstable_mockModule('../src/hass/index.js', () => ({ + get_hass: jest.fn().mockResolvedValue({ + services: { + light: { + turn_on: jest.fn(), + turn_off: jest.fn() + } + } + }) +})); + +interface Tool { + name: string; + execute: (...args: any[]) => Promise; +} + +describe('Home Assistant MCP Server', () => { + beforeEach(async () => { + // Reset all mocks + jest.clearAllMocks(); + mockFetch.mockReset(); + + // Import the module which will execute the main function + await import('../src/index.js'); + }); + + afterEach(() => { + jest.resetModules(); + }); + + describe('list_devices tool', () => { + it('should successfully list devices', async () => { + // Mock the fetch response for listing devices + const mockDevices = [ + { + entity_id: 'light.living_room', + state: 'on', + attributes: { brightness: 255 } + }, + { + entity_id: 'climate.bedroom', + state: 'heat', + attributes: { temperature: 22 } + } + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockDevices + } as Response); + + // Get the tool registration + const liteMcpInstance = (LiteMCP as jest.MockedClass).mock.results[0].value; + const addToolCalls = liteMcpInstance.addTool.mock.calls; + const listDevicesTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'list_devices')?.[0] as Tool; + + // Execute the tool + const result = await listDevicesTool.execute({}); + + // Verify the results + 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 } + }] + }); + }); + + it('should handle fetch errors', async () => { + // Mock a fetch error + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + // Get the tool registration + const liteMcpInstance = (LiteMCP as jest.MockedClass).mock.results[0].value; + const addToolCalls = liteMcpInstance.addTool.mock.calls; + const listDevicesTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'list_devices')?.[0] as Tool; + + // Execute the tool + const result = await listDevicesTool.execute({}); + + // Verify error handling + expect(result.success).toBe(false); + expect(result.message).toBe('Network error'); + }); + }); + + describe('control tool', () => { + it('should successfully control a light device', async () => { + // Mock successful service call + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({}) + } as Response); + + // Get the tool registration + const liteMcpInstance = (LiteMCP as jest.MockedClass).mock.results[0].value; + const addToolCalls = liteMcpInstance.addTool.mock.calls; + const controlTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'control')?.[0] as Tool; + + // Execute the tool + const result = await controlTool.execute({ + command: 'turn_on', + entity_id: 'light.living_room', + brightness: 255 + }); + + // Verify the results + expect(result.success).toBe(true); + expect(result.message).toBe('Successfully executed turn_on for light.living_room'); + + // Verify the fetch call + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:8123/api/services/light/turn_on', + { + method: 'POST', + headers: { + Authorization: 'Bearer test_token', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + entity_id: 'light.living_room', + brightness: 255 + }) + } + ); + }); + + it('should handle unsupported domains', async () => { + // Get the tool registration + const liteMcpInstance = (LiteMCP as jest.MockedClass).mock.results[0].value; + const addToolCalls = liteMcpInstance.addTool.mock.calls; + const controlTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'control')?.[0] as Tool; + + // Execute the tool with an unsupported domain + const result = await controlTool.execute({ + command: 'turn_on', + entity_id: 'unsupported.device' + }); + + // Verify error handling + expect(result.success).toBe(false); + expect(result.message).toBe('Unsupported domain: unsupported'); + }); + + it('should handle service call errors', async () => { + // Mock a failed service call + mockFetch.mockResolvedValueOnce({ + ok: false, + statusText: 'Service unavailable' + } as Response); + + // Get the tool registration + const liteMcpInstance = (LiteMCP as jest.MockedClass).mock.results[0].value; + const addToolCalls = liteMcpInstance.addTool.mock.calls; + const controlTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'control')?.[0] as Tool; + + // Execute the tool + const result = await controlTool.execute({ + command: 'turn_on', + entity_id: 'light.living_room' + }); + + // Verify error handling + expect(result.success).toBe(false); + expect(result.message).toContain('Failed to execute turn_on for light.living_room'); + }); + + it('should handle climate device controls', async () => { + // Mock successful service call + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({}) + } as Response); + + // Get the tool registration + const liteMcpInstance = (LiteMCP as jest.MockedClass).mock.results[0].value; + const addToolCalls = liteMcpInstance.addTool.mock.calls; + const controlTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'control')?.[0] as Tool; + + // Execute the tool + const result = await controlTool.execute({ + command: 'set_temperature', + entity_id: 'climate.bedroom', + temperature: 22, + target_temp_high: 24, + target_temp_low: 20 + }); + + // Verify the results + expect(result.success).toBe(true); + expect(result.message).toBe('Successfully executed set_temperature for climate.bedroom'); + + // Verify the fetch call + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:8123/api/services/climate/set_temperature', + { + method: 'POST', + headers: { + Authorization: 'Bearer test_token', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + entity_id: 'climate.bedroom', + temperature: 22, + target_temp_high: 24, + target_temp_low: 20 + }) + } + ); + }); + + it('should handle cover device controls', async () => { + // Mock successful service call + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({}) + } as Response); + + // Get the tool registration + const liteMcpInstance = (LiteMCP as jest.MockedClass).mock.results[0].value; + const addToolCalls = liteMcpInstance.addTool.mock.calls; + const controlTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'control')?.[0] as Tool; + + // Execute the tool + const result = await controlTool.execute({ + command: 'set_position', + entity_id: 'cover.living_room', + position: 50 + }); + + // Verify the results + expect(result.success).toBe(true); + expect(result.message).toBe('Successfully executed set_position for cover.living_room'); + + // Verify the fetch call + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:8123/api/services/cover/set_position', + { + method: 'POST', + headers: { + Authorization: 'Bearer test_token', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + entity_id: 'cover.living_room', + position: 50 + }) + } + ); + }); + }); +}); \ No newline at end of file diff --git a/coverage/lcov-report/index.html b/coverage/lcov-report/index.html index ae7a774..c94782f 100644 --- a/coverage/lcov-report/index.html +++ b/coverage/lcov-report/index.html @@ -23,30 +23,30 @@
- 100% + 38.18% Statements - 32/32 + 42/110
- 100% + 12.96% Branches - 0/0 + 7/54
- 100% + 28.57% Functions - 0/0 + 2/7
- 100% + 38.18% Lines - 32/32 + 42/110
@@ -61,7 +61,7 @@
-
+
@@ -79,18 +79,48 @@ - + + + + + + + + + + + + + + - + + + - - - + + + + + + + + + + + + + + @@ -101,7 +131,7 @@
schemas.tssrc +
+
33%33/1002.43%1/4120%1/533%33/100
src/config
100%32/322/250%4/8 100% 0/0 100%0/0100%32/322/2
src/hass +
+
87.5%7/840%2/550%1/287.5%7/8