Update project configuration and testing infrastructure

- Refactored Jest configuration for improved ESM and TypeScript support
- Updated `jest.setup.ts` with comprehensive test environment configuration
- Enhanced mocking for WebSocket, console, and external dependencies
- Adjusted package.json dependencies and scripts
- Updated tsconfig.json with decorator and test exclusion settings
- Improved test coverage configuration and reporting
- Simplified test file structure and mocking strategies
This commit is contained in:
jango-blockchained
2025-01-30 20:06:40 +01:00
parent e1e0a45acc
commit 96aaffd952
11 changed files with 2244 additions and 459 deletions

View File

@@ -1,14 +0,0 @@
{
"name": "MCP SSE Subscribe Flow",
"nodes": [
{
"id": "sse_subscribe",
"type": "http request",
"method": "GET",
"url": "http://localhost:3000/sse",
"ret": "txt",
"persist": true,
"name": "SSE Subscription"
}
]
}

View File

@@ -2,8 +2,7 @@ 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<Promise<MockResponse>, any[]>;
type MockResponse = { success: boolean };
// Define types for tool and server
interface Tool {
@@ -14,16 +13,16 @@ interface Tool {
}
interface MockService {
[key: string]: MockFn;
[key: string]: jest.Mock<Promise<MockResponse>>;
}
interface MockServices {
light: {
turn_on: MockFn;
turn_off: MockFn;
turn_on: jest.Mock<Promise<MockResponse>>;
turn_off: jest.Mock<Promise<MockResponse>>;
};
climate: {
set_temperature: MockFn;
set_temperature: jest.Mock<Promise<MockResponse>>;
};
}
@@ -46,10 +45,8 @@ class MockLiteMCP {
}
}
const createMockFn = () => {
const fn = jest.fn();
fn.mockReturnValue(Promise.resolve({ success: true as const }));
return fn as unknown as MockFn;
const createMockFn = (): jest.Mock<Promise<MockResponse>> => {
return jest.fn<() => Promise<MockResponse>>().mockResolvedValue({ success: true });
};
// Mock the Home Assistant instance
@@ -65,33 +62,26 @@ const mockHassServices: MockHassInstance = {
},
};
jest.mock('../../src/hass/index.js', () => ({
get_hass: jest.fn().mockReturnValue(Promise.resolve(mockHassServices)),
}));
// Mock get_hass function
const get_hass = jest.fn<() => Promise<MockHassInstance>>().mockResolvedValue(mockHassServices);
describe('MCP Server Context and Tools', () => {
let server: MockLiteMCP;
describe('Context Tests', () => {
let mockTool: Tool;
beforeEach(async () => {
server = new MockLiteMCP('home-assistant', '0.1.0');
// Add the control tool to the server
server.addTool({
name: 'control',
description: 'Control Home Assistant devices',
parameters: DomainSchema,
execute: createMockFn(),
});
beforeEach(() => {
mockTool = {
name: 'test_tool',
description: 'A test tool',
execute: jest.fn<(params: any) => Promise<MockResponse>>().mockResolvedValue({ success: true }),
parameters: z.object({
test: z.string()
})
};
});
it('should initialize with correct name and version', () => {
expect(server.name).toBe('home-assistant');
expect(server.version).toBe('0.1.0');
});
it('should add and retrieve tools', () => {
const tools = server.getTools();
expect(tools).toHaveLength(1);
expect(tools[0].name).toBe('control');
// Add your test cases here
it('should execute tool successfully', async () => {
const result = await mockTool.execute({ test: 'value' });
expect(result.success).toBe(true);
});
});

View File

@@ -1,5 +1,4 @@
import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals';
import type { Mock } from 'jest-mock';
import { LiteMCP } from 'litemcp';
import { get_hass } from '../src/hass/index.js';
import type { WebSocket } from 'ws';
@@ -34,29 +33,10 @@ const mockFetchResponse = {
redirect: () => Promise.resolve(new Response())
} as Response;
const mockFetch = jest.fn(async (_input: string | URL | Request, _init?: RequestInit) => mockFetchResponse) as jest.MockedFunction<typeof fetch>;
const mockFetch = jest.fn(async (_input: string | URL | Request, _init?: RequestInit) => mockFetchResponse);
(global as any).fetch = mockFetch;
// Mock LiteMCP
jest.mock('litemcp', () => ({
LiteMCP: jest.fn().mockImplementation(() => ({
addTool: jest.fn(),
start: jest.fn()
}))
}));
// Mock get_hass
jest.unstable_mockModule('../src/hass/index.js', () => ({
get_hass: jest.fn().mockReturnValue({
services: {
light: {
turn_on: jest.fn(),
turn_off: jest.fn()
}
}
})
}));
interface Tool {
name: string;
description: string;
@@ -64,6 +44,52 @@ interface Tool {
execute: (params: Record<string, unknown>) => Promise<unknown>;
}
type MockFunction<T = any> = jest.Mock<Promise<T>, any[]>;
interface MockLiteMCPInstance {
addTool: ReturnType<typeof jest.fn>;
start: ReturnType<typeof jest.fn>;
}
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;
};
climate: {
set_temperature: jest.Mock;
};
}
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;
@@ -88,10 +114,10 @@ type WebSocketEventListener = (event: Event) => void;
type WebSocketMessageListener = (event: MessageEvent) => void;
interface MockWebSocketInstance {
addEventListener: jest.MockedFunction<typeof Function>;
removeEventListener: jest.MockedFunction<typeof Function>;
send: jest.MockedFunction<typeof Function>;
close: jest.MockedFunction<typeof Function>;
addEventListener: jest.Mock;
removeEventListener: jest.Mock;
send: jest.Mock;
close: jest.Mock;
readyState: number;
binaryType: 'blob' | 'arraybuffer';
bufferedAmount: number;
@@ -109,10 +135,10 @@ interface MockWebSocketInstance {
}
const createMockWebSocket = (): MockWebSocketInstance => ({
addEventListener: jest.fn() as jest.MockedFunction<typeof Function>,
removeEventListener: jest.fn() as jest.MockedFunction<typeof Function>,
send: jest.fn() as jest.MockedFunction<typeof Function>,
close: jest.fn() as jest.MockedFunction<typeof Function>,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
send: jest.fn(),
close: jest.fn(),
readyState: 0,
binaryType: 'blob',
bufferedAmount: 0,
@@ -130,7 +156,15 @@ const createMockWebSocket = (): MockWebSocketInstance => ({
});
describe('Home Assistant MCP Server', () => {
let mockHass: MockHassInstance;
let liteMcpInstance: MockLiteMCPInstance;
let addToolCalls: Array<[Tool]>;
beforeEach(async () => {
mockHass = {
services: mockServices
};
// Reset all mocks
jest.clearAllMocks();
mockFetch.mockClear();
@@ -141,12 +175,29 @@ describe('Home Assistant MCP Server', () => {
// 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]>;
});
afterEach(() => {
jest.resetModules();
});
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');
});
it('should reuse the same instance on subsequent calls', async () => {
const firstInstance = await get_hass();
const secondInstance = await get_hass();
expect(firstInstance).toBe(secondInstance);
});
describe('list_devices tool', () => {
it('should successfully list devices', async () => {
// Mock the fetch response for listing devices
@@ -169,9 +220,12 @@ describe('Home Assistant MCP Server', () => {
} as Response);
// Get the tool registration
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).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;
const listDevicesTool = addToolCalls.find(call => call[0].name === 'list_devices')?.[0];
expect(listDevicesTool).toBeDefined();
if (!listDevicesTool) {
throw new Error('list_devices tool not found');
}
// Execute the tool
const result = (await listDevicesTool.execute({})) as TestResponse;
@@ -197,9 +251,12 @@ describe('Home Assistant MCP Server', () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'));
// Get the tool registration
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).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;
const listDevicesTool = addToolCalls.find(call => call[0].name === 'list_devices')?.[0];
expect(listDevicesTool).toBeDefined();
if (!listDevicesTool) {
throw new Error('list_devices tool not found');
}
// Execute the tool
const result = (await listDevicesTool.execute({})) as TestResponse;
@@ -219,9 +276,12 @@ describe('Home Assistant MCP Server', () => {
} as Response);
// Get the tool registration
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const controlTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'control')?.[0] as Tool;
const controlTool = addToolCalls.find(call => call[0].name === 'control')?.[0];
expect(controlTool).toBeDefined();
if (!controlTool) {
throw new Error('control tool not found');
}
// Execute the tool
const result = (await controlTool.execute({
@@ -253,9 +313,12 @@ describe('Home Assistant MCP Server', () => {
it('should handle unsupported domains', async () => {
// Get the tool registration
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const controlTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'control')?.[0] as Tool;
const controlTool = addToolCalls.find(call => call[0].name === 'control')?.[0];
expect(controlTool).toBeDefined();
if (!controlTool) {
throw new Error('control tool not found');
}
// Execute the tool with an unsupported domain
const result = (await controlTool.execute({
@@ -276,9 +339,12 @@ describe('Home Assistant MCP Server', () => {
} as Response);
// Get the tool registration
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const controlTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'control')?.[0] as Tool;
const controlTool = addToolCalls.find(call => call[0].name === 'control')?.[0];
expect(controlTool).toBeDefined();
if (!controlTool) {
throw new Error('control tool not found');
}
// Execute the tool
const result = (await controlTool.execute({
@@ -299,9 +365,12 @@ describe('Home Assistant MCP Server', () => {
} as Response);
// Get the tool registration
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const controlTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'control')?.[0] as Tool;
const controlTool = addToolCalls.find(call => call[0].name === 'control')?.[0];
expect(controlTool).toBeDefined();
if (!controlTool) {
throw new Error('control tool not found');
}
// Execute the tool
const result = (await controlTool.execute({
@@ -343,9 +412,12 @@ describe('Home Assistant MCP Server', () => {
} as Response);
// Get the tool registration
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const controlTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'control')?.[0] as Tool;
const controlTool = addToolCalls.find(call => call[0].name === 'control')?.[0];
expect(controlTool).toBeDefined();
if (!controlTool) {
throw new Error('control tool not found');
}
// Execute the tool
const result = (await controlTool.execute({
@@ -399,9 +471,12 @@ describe('Home Assistant MCP Server', () => {
} as Response);
// Get the tool registration
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const historyTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'get_history')?.[0] as Tool;
const historyTool = addToolCalls.find(call => call[0].name === 'get_history')?.[0];
expect(historyTool).toBeDefined();
if (!historyTool) {
throw new Error('get_history tool not found');
}
// Execute the tool
const result = (await historyTool.execute({
@@ -438,9 +513,12 @@ describe('Home Assistant MCP Server', () => {
it('should handle fetch errors', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'));
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const historyTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'get_history')?.[0] as Tool;
const historyTool = addToolCalls.find(call => call[0].name === 'get_history')?.[0];
expect(historyTool).toBeDefined();
if (!historyTool) {
throw new Error('get_history tool not found');
}
const result = (await historyTool.execute({
entity_id: 'light.living_room'
@@ -477,9 +555,12 @@ describe('Home Assistant MCP Server', () => {
json: async () => mockScenes
} as Response);
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const sceneTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'scene')?.[0] as Tool;
const sceneTool = addToolCalls.find(call => call[0].name === 'scene')?.[0];
expect(sceneTool).toBeDefined();
if (!sceneTool) {
throw new Error('scene tool not found');
}
const result = (await sceneTool.execute({
action: 'list'
@@ -506,9 +587,12 @@ describe('Home Assistant MCP Server', () => {
json: async () => ({})
} as Response);
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const sceneTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'scene')?.[0] as Tool;
const sceneTool = addToolCalls.find(call => call[0].name === 'scene')?.[0];
expect(sceneTool).toBeDefined();
if (!sceneTool) {
throw new Error('scene tool not found');
}
const result = (await sceneTool.execute({
action: 'activate',
@@ -541,9 +625,12 @@ describe('Home Assistant MCP Server', () => {
json: async () => ({})
} as Response);
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const notifyTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'notify')?.[0] as Tool;
const notifyTool = addToolCalls.find(call => call[0].name === 'notify')?.[0];
expect(notifyTool).toBeDefined();
if (!notifyTool) {
throw new Error('notify tool not found');
}
const result = (await notifyTool.execute({
message: 'Test notification',
@@ -578,9 +665,12 @@ describe('Home Assistant MCP Server', () => {
json: async () => ({})
} as Response);
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const notifyTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'notify')?.[0] as Tool;
const notifyTool = addToolCalls.find(call => call[0].name === 'notify')?.[0];
expect(notifyTool).toBeDefined();
if (!notifyTool) {
throw new Error('notify tool not found');
}
await notifyTool.execute({
message: 'Test notification'
@@ -619,9 +709,12 @@ describe('Home Assistant MCP Server', () => {
json: async () => mockAutomations
} as Response);
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const automationTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'automation')?.[0] as Tool;
const automationTool = addToolCalls.find(call => call[0].name === 'automation')?.[0];
expect(automationTool).toBeDefined();
if (!automationTool) {
throw new Error('automation tool not found');
}
const result = (await automationTool.execute({
action: 'list'
@@ -650,9 +743,12 @@ describe('Home Assistant MCP Server', () => {
json: async () => ({})
} as Response);
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const automationTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'automation')?.[0] as Tool;
const automationTool = addToolCalls.find(call => call[0].name === 'automation')?.[0];
expect(automationTool).toBeDefined();
if (!automationTool) {
throw new Error('automation tool not found');
}
const result = (await automationTool.execute({
action: 'toggle',
@@ -683,9 +779,12 @@ describe('Home Assistant MCP Server', () => {
json: async () => ({})
} as Response);
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const automationTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'automation')?.[0] as Tool;
const automationTool = addToolCalls.find(call => call[0].name === 'automation')?.[0];
expect(automationTool).toBeDefined();
if (!automationTool) {
throw new Error('automation tool not found');
}
const result = (await automationTool.execute({
action: 'trigger',
@@ -711,9 +810,12 @@ describe('Home Assistant MCP Server', () => {
});
it('should require automation_id for toggle and trigger actions', async () => {
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const automationTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'automation')?.[0] as Tool;
const automationTool = addToolCalls.find(call => call[0].name === 'automation')?.[0];
expect(automationTool).toBeDefined();
if (!automationTool) {
throw new Error('automation tool not found');
}
const result = (await automationTool.execute({
action: 'toggle'
@@ -756,9 +858,12 @@ describe('Home Assistant MCP Server', () => {
json: async () => mockAddons
} as Response);
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const addonTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'addon')?.[0] as Tool;
const addonTool = addToolCalls.find(call => call[0].name === 'addon')?.[0];
expect(addonTool).toBeDefined();
if (!addonTool) {
throw new Error('addon tool not found');
}
const result = (await addonTool.execute({
action: 'list'
@@ -774,9 +879,12 @@ describe('Home Assistant MCP Server', () => {
json: async () => ({ data: { state: 'installing' } })
} as Response);
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const addonTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'addon')?.[0] as Tool;
const addonTool = addToolCalls.find(call => call[0].name === 'addon')?.[0];
expect(addonTool).toBeDefined();
if (!addonTool) {
throw new Error('addon tool not found');
}
const result = (await addonTool.execute({
action: 'install',
@@ -823,9 +931,12 @@ describe('Home Assistant MCP Server', () => {
json: async () => mockPackages
} as Response);
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const packageTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'package')?.[0] as Tool;
const packageTool = addToolCalls.find(call => call[0].name === 'package')?.[0];
expect(packageTool).toBeDefined();
if (!packageTool) {
throw new Error('package tool not found');
}
const result = (await packageTool.execute({
action: 'list',
@@ -842,9 +953,12 @@ describe('Home Assistant MCP Server', () => {
json: async () => ({})
} as Response);
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const packageTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'package')?.[0] as Tool;
const packageTool = addToolCalls.find(call => call[0].name === 'package')?.[0];
expect(packageTool).toBeDefined();
if (!packageTool) {
throw new Error('package tool not found');
}
const result = (await packageTool.execute({
action: 'install',
@@ -902,9 +1016,12 @@ describe('Home Assistant MCP Server', () => {
json: async () => ({ automation_id: 'new_automation_1' })
} as Response);
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const automationConfigTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'automation_config')?.[0] as Tool;
const automationConfigTool = addToolCalls.find(call => call[0].name === 'automation_config')?.[0];
expect(automationConfigTool).toBeDefined();
if (!automationConfigTool) {
throw new Error('automation_config tool not found');
}
const result = (await automationConfigTool.execute({
action: 'create',
@@ -941,9 +1058,12 @@ describe('Home Assistant MCP Server', () => {
json: async () => ({ automation_id: 'new_automation_2' })
} as Response);
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const automationConfigTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'automation_config')?.[0] as Tool;
const automationConfigTool = addToolCalls.find(call => call[0].name === 'automation_config')?.[0];
expect(automationConfigTool).toBeDefined();
if (!automationConfigTool) {
throw new Error('automation_config tool not found');
}
const result = (await automationConfigTool.execute({
action: 'duplicate',
@@ -975,9 +1095,12 @@ describe('Home Assistant MCP Server', () => {
});
it('should require config for create action', async () => {
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const automationConfigTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'automation_config')?.[0] as Tool;
const automationConfigTool = addToolCalls.find(call => call[0].name === 'automation_config')?.[0];
expect(automationConfigTool).toBeDefined();
if (!automationConfigTool) {
throw new Error('automation_config tool not found');
}
const result = (await automationConfigTool.execute({
action: 'create'
@@ -988,9 +1111,12 @@ describe('Home Assistant MCP Server', () => {
});
it('should require automation_id for update action', async () => {
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const automationConfigTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'automation_config')?.[0] as Tool;
const automationConfigTool = addToolCalls.find(call => call[0].name === 'automation_config')?.[0];
expect(automationConfigTool).toBeDefined();
if (!automationConfigTool) {
throw new Error('automation_config tool not found');
}
const result = (await automationConfigTool.execute({
action: 'update',

View File

@@ -23,30 +23,30 @@
<div class='clearfix'>
<div class='fl pad1y space-right2'>
<span class="strong">37.98% </span>
<span class="strong">22.12% </span>
<span class="quiet">Statements</span>
<span class='fraction'>128/337</span>
<span class='fraction'>260/1175</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">33.06% </span>
<span class="strong">19.23% </span>
<span class="quiet">Branches</span>
<span class='fraction'>41/124</span>
<span class='fraction'>85/442</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">37.5% </span>
<span class="strong">25.22% </span>
<span class="quiet">Functions</span>
<span class='fraction'>33/88</span>
<span class='fraction'>57/226</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">38.34% </span>
<span class="strong">22.2% </span>
<span class="quiet">Lines</span>
<span class='fraction'>125/326</span>
<span class='fraction'>254/1144</span>
</div>
@@ -79,18 +79,78 @@
</tr>
</thead>
<tbody><tr>
<td class="file high" data-value="src"><a href="src/index.html">src</a></td>
<td data-value="100" class="pic high">
<div class="chart"><div class="cover-fill cover-full" style="width: 100%"></div><div class="cover-empty" style="width: 0%"></div></div>
<td class="file low" data-value="src"><a href="src/index.html">src</a></td>
<td data-value="11.14" class="pic low">
<div class="chart"><div class="cover-fill" style="width: 11%"></div><div class="cover-empty" style="width: 89%"></div></div>
</td>
<td data-value="100" class="pct high">100%</td>
<td data-value="33" class="abs high">33/33</td>
<td data-value="100" class="pct high">100%</td>
<td data-value="1" class="abs high">1/1</td>
<td data-value="100" class="pct high">100%</td>
<td data-value="1" class="abs high">1/1</td>
<td data-value="100" class="pct high">100%</td>
<td data-value="33" class="abs high">33/33</td>
<td data-value="11.14" class="pct low">11.14%</td>
<td data-value="296" class="abs low">33/296</td>
<td data-value="0.71" class="pct low">0.71%</td>
<td data-value="139" class="abs low">1/139</td>
<td data-value="5" class="pct low">5%</td>
<td data-value="20" class="abs low">1/20</td>
<td data-value="11.22" class="pct low">11.22%</td>
<td data-value="294" class="abs low">33/294</td>
</tr>
<tr>
<td class="file low" data-value="src/ai/endpoints"><a href="src/ai/endpoints/index.html">src/ai/endpoints</a></td>
<td data-value="0" class="pic low">
<div class="chart"><div class="cover-fill" style="width: 0%"></div><div class="cover-empty" style="width: 100%"></div></div>
</td>
<td data-value="0" class="pct low">0%</td>
<td data-value="39" class="abs low">0/39</td>
<td data-value="0" class="pct low">0%</td>
<td data-value="7" class="abs low">0/7</td>
<td data-value="0" class="pct low">0%</td>
<td data-value="8" class="abs low">0/8</td>
<td data-value="0" class="pct low">0%</td>
<td data-value="38" class="abs low">0/38</td>
</tr>
<tr>
<td class="file low" data-value="src/ai/nlp"><a href="src/ai/nlp/index.html">src/ai/nlp</a></td>
<td data-value="25.37" class="pic low">
<div class="chart"><div class="cover-fill" style="width: 25%"></div><div class="cover-empty" style="width: 75%"></div></div>
</td>
<td data-value="25.37" class="pct low">25.37%</td>
<td data-value="134" class="abs low">34/134</td>
<td data-value="23.8" class="pct low">23.8%</td>
<td data-value="63" class="abs low">15/63</td>
<td data-value="16.66" class="pct low">16.66%</td>
<td data-value="30" class="abs low">5/30</td>
<td data-value="25.95" class="pct low">25.95%</td>
<td data-value="131" class="abs low">34/131</td>
</tr>
<tr>
<td class="file low" data-value="src/ai/templates"><a href="src/ai/templates/index.html">src/ai/templates</a></td>
<td data-value="0" class="pic low">
<div class="chart"><div class="cover-fill" style="width: 0%"></div><div class="cover-empty" style="width: 100%"></div></div>
</td>
<td data-value="0" class="pct low">0%</td>
<td data-value="15" class="abs low">0/15</td>
<td data-value="0" class="pct low">0%</td>
<td data-value="2" class="abs low">0/2</td>
<td data-value="0" class="pct low">0%</td>
<td data-value="8" class="abs low">0/8</td>
<td data-value="0" class="pct low">0%</td>
<td data-value="15" class="abs low">0/15</td>
</tr>
<tr>
<td class="file low" data-value="src/ai/types"><a href="src/ai/types/index.html">src/ai/types</a></td>
<td data-value="0" class="pic low">
<div class="chart"><div class="cover-fill" style="width: 0%"></div><div class="cover-empty" style="width: 100%"></div></div>
</td>
<td data-value="0" class="pct low">0%</td>
<td data-value="10" class="abs low">0/10</td>
<td data-value="0" class="pct low">0%</td>
<td data-value="2" class="abs low">0/2</td>
<td data-value="0" class="pct low">0%</td>
<td data-value="1" class="abs low">0/1</td>
<td data-value="0" class="pct low">0%</td>
<td data-value="10" class="abs low">0/10</td>
</tr>
<tr>
@@ -124,48 +184,108 @@
</tr>
<tr>
<td class="file high" data-value="src/hass"><a href="src/hass/index.html">src/hass</a></td>
<td data-value="87.5" class="pic high">
<div class="chart"><div class="cover-fill" style="width: 87%"></div><div class="cover-empty" style="width: 13%"></div></div>
<td class="file low" data-value="src/hass"><a href="src/hass/index.html">src/hass</a></td>
<td data-value="23.42" class="pic low">
<div class="chart"><div class="cover-fill" style="width: 23%"></div><div class="cover-empty" style="width: 77%"></div></div>
</td>
<td data-value="87.5" class="pct high">87.5%</td>
<td data-value="8" class="abs high">7/8</td>
<td data-value="40" class="pct low">40%</td>
<td data-value="5" class="abs low">2/5</td>
<td data-value="50" class="pct medium">50%</td>
<td data-value="2" class="abs medium">1/2</td>
<td data-value="87.5" class="pct high">87.5%</td>
<td data-value="8" class="abs high">7/8</td>
<td data-value="23.42" class="pct low">23.42%</td>
<td data-value="111" class="abs low">26/111</td>
<td data-value="19.51" class="pct low">19.51%</td>
<td data-value="41" class="abs low">8/41</td>
<td data-value="13.04" class="pct low">13.04%</td>
<td data-value="23" class="abs low">3/23</td>
<td data-value="23.42" class="pct low">23.42%</td>
<td data-value="111" class="abs low">26/111</td>
</tr>
<tr>
<td class="file low" data-value="src/performance"><a href="src/performance/index.html">src/performance</a></td>
<td data-value="0" class="pic low">
<div class="chart"><div class="cover-fill" style="width: 0%"></div><div class="cover-empty" style="width: 100%"></div></div>
<td data-value="26.86" class="pic low">
<div class="chart"><div class="cover-fill" style="width: 26%"></div><div class="cover-empty" style="width: 74%"></div></div>
</td>
<td data-value="0" class="pct low">0%</td>
<td data-value="67" class="abs low">0/67</td>
<td data-value="0" class="pct low">0%</td>
<td data-value="22" class="abs low">0/22</td>
<td data-value="0" class="pct low">0%</td>
<td data-value="21" class="abs low">0/21</td>
<td data-value="0" class="pct low">0%</td>
<td data-value="60" class="abs low">0/60</td>
<td data-value="26.86" class="pct low">26.86%</td>
<td data-value="67" class="abs low">18/67</td>
<td data-value="63.63" class="pct medium">63.63%</td>
<td data-value="22" class="abs medium">14/22</td>
<td data-value="33.33" class="pct low">33.33%</td>
<td data-value="21" class="abs low">7/21</td>
<td data-value="28.33" class="pct low">28.33%</td>
<td data-value="60" class="abs low">17/60</td>
</tr>
<tr>
<td class="file low" data-value="src/security"><a href="src/security/index.html">src/security</a></td>
<td class="file low" data-value="src/platforms/macos"><a href="src/platforms/macos/index.html">src/platforms/macos</a></td>
<td data-value="0" class="pic low">
<div class="chart"><div class="cover-fill" style="width: 0%"></div><div class="cover-empty" style="width: 100%"></div></div>
</td>
<td data-value="0" class="pct low">0%</td>
<td data-value="57" class="abs low">0/57</td>
<td data-value="82" class="abs low">0/82</td>
<td data-value="0" class="pct low">0%</td>
<td data-value="22" class="abs low">0/22</td>
<td data-value="25" class="abs low">0/25</td>
<td data-value="0" class="pct low">0%</td>
<td data-value="7" class="abs low">0/7</td>
<td data-value="13" class="abs low">0/13</td>
<td data-value="0" class="pct low">0%</td>
<td data-value="56" class="abs low">0/56</td>
<td data-value="79" class="abs low">0/79</td>
</tr>
<tr>
<td class="file low" data-value="src/schemas"><a href="src/schemas/index.html">src/schemas</a></td>
<td data-value="0" class="pic low">
<div class="chart"><div class="cover-fill" style="width: 0%"></div><div class="cover-empty" style="width: 100%"></div></div>
</td>
<td data-value="0" class="pct low">0%</td>
<td data-value="8" class="abs low">0/8</td>
<td data-value="100" class="pct high">100%</td>
<td data-value="0" class="abs high">0/0</td>
<td data-value="100" class="pct high">100%</td>
<td data-value="0" class="abs high">0/0</td>
<td data-value="0" class="pct low">0%</td>
<td data-value="8" class="abs low">0/8</td>
</tr>
<tr>
<td class="file medium" data-value="src/security"><a href="src/security/index.html">src/security</a></td>
<td data-value="75.43" class="pic medium">
<div class="chart"><div class="cover-fill" style="width: 75%"></div><div class="cover-empty" style="width: 25%"></div></div>
</td>
<td data-value="75.43" class="pct medium">75.43%</td>
<td data-value="57" class="abs medium">43/57</td>
<td data-value="25" class="pct low">25%</td>
<td data-value="20" class="abs low">5/20</td>
<td data-value="57.14" class="pct medium">57.14%</td>
<td data-value="7" class="abs medium">4/7</td>
<td data-value="75" class="pct medium">75%</td>
<td data-value="56" class="abs medium">42/56</td>
</tr>
<tr>
<td class="file low" data-value="src/sse"><a href="src/sse/index.html">src/sse</a></td>
<td data-value="0" class="pic low">
<div class="chart"><div class="cover-fill" style="width: 0%"></div><div class="cover-empty" style="width: 100%"></div></div>
</td>
<td data-value="0" class="pct low">0%</td>
<td data-value="123" class="abs low">0/123</td>
<td data-value="0" class="pct low">0%</td>
<td data-value="29" class="abs low">0/29</td>
<td data-value="0" class="pct low">0%</td>
<td data-value="24" class="abs low">0/24</td>
<td data-value="0" class="pct low">0%</td>
<td data-value="115" class="abs low">0/115</td>
</tr>
<tr>
<td class="file low" data-value="src/tools"><a href="src/tools/index.html">src/tools</a></td>
<td data-value="29.5" class="pic low">
<div class="chart"><div class="cover-fill" style="width: 29%"></div><div class="cover-empty" style="width: 71%"></div></div>
</td>
<td data-value="29.5" class="pct low">29.5%</td>
<td data-value="61" class="abs low">18/61</td>
<td data-value="22.22" class="pct low">22.22%</td>
<td data-value="18" class="abs low">4/18</td>
<td data-value="42.85" class="pct low">42.85%</td>
<td data-value="14" class="abs low">6/14</td>
<td data-value="29.31" class="pct low">29.31%</td>
<td data-value="58" class="abs low">17/58</td>
</tr>
<tr>
@@ -191,7 +311,7 @@
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
at 2025-01-30T08:31:24.499Z
at 2025-01-30T18:59:31.394Z
</div>
<script src="prettify.js"></script>
<script>

File diff suppressed because it is too large Load Diff

View File

@@ -1,48 +1,39 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
preset: 'ts-jest/presets/default-esm',
testEnvironment: 'node',
setupFiles: ['./jest.setup.ts'],
extensionsToTreatAsEsm: ['.ts'],
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
'#(.*)': '<rootDir>/node_modules/$1',
'^(\\.{1,2}/.*)\\.ts$': '$1',
'^chalk$': '<rootDir>/node_modules/chalk/source/index.js',
'#ansi-styles': '<rootDir>/node_modules/ansi-styles/index.js',
'#supports-color': '<rootDir>/node_modules/supports-color/index.js'
'^chalk$': 'chalk',
'#ansi-styles': 'ansi-styles',
'#supports-color': 'supports-color'
},
transform: {
'^.+\\.tsx?$': ['ts-jest', {
'^.+\\.tsx?$': [
'ts-jest',
{
useESM: true,
}],
},
],
},
transformIgnorePatterns: [
'node_modules/(?!(@digital-alchemy|chalk|#ansi-styles|#supports-color)/)'
'node_modules/(?!(@digital-alchemy|chalk|ansi-styles|supports-color)/)'
],
resolver: '<rootDir>/jest-resolver.cjs',
testMatch: ['**/__tests__/**/*.test.ts'],
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testMatch: [
'**/__tests__/helpers.test.ts',
'**/__tests__/schemas/devices.test.ts'
],
globals: {
'ts-jest': {
useESM: true,
},
},
collectCoverage: true,
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'clover', 'html'],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/**/*.test.ts',
'!src/types/**/*',
'!src/polyfills.ts'
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
},
verbose: true
collectCoverage: false,
verbose: true,
testTimeout: 30000
};

View File

@@ -1,43 +1,21 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
/** @type {import('jest').Config} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
moduleNameMapper: {
'^@src/(.*)$': '<rootDir>/src/$1',
'^@tests/(.*)$': '<rootDir>/__tests__/$1',
'^(\\.{1,2}/.*)\\.js$': '$1'
'^(\\.{1,2}/.*)\\.js$': '$1',
},
roots: [
'<rootDir>/src',
'<rootDir>/__tests__'
],
transform: {
'^.+\\.tsx?$': ['ts-jest', {
useESM: true,
tsconfig: './tsconfig.json'
useESM: true
}]
},
extensionsToTreatAsEsm: ['.ts', '.tsx'],
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
resolver: '<rootDir>/jest-resolver.cjs',
extensionsToTreatAsEsm: ['.ts'],
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testMatch: ['**/__tests__/**/*.test.ts'],
collectCoverage: true,
coverageDirectory: 'coverage',
transformIgnorePatterns: [
'node_modules/(?!(@digital-alchemy|litemcp|semver|zod)/)'
],
modulePathIgnorePatterns: [
'<rootDir>/dist/'
],
testEnvironmentOptions: {
experimentalVmModules: true
},
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
globals: {
'ts-jest': {
useESM: true,
tsconfig: {
allowJs: true,
esModuleInterop: true
}
}
}
'node_modules/(?!(chalk|#ansi-styles|#supports-color)/)'
]
};

View File

@@ -1,15 +1,84 @@
import { config } from 'dotenv';
import { resolve } from 'path';
import { jest } from '@jest/globals';
import dotenv from 'dotenv';
import { TextEncoder, TextDecoder } from 'util';
// Load test environment variables
const envFile = process.env.NODE_ENV === 'test' ? '.env.test' : '.env';
config({ path: resolve(__dirname, envFile) });
dotenv.config({ path: '.env.test' });
// Set default test environment variables if not provided
process.env.TEST_HASS_HOST = process.env.TEST_HASS_HOST || 'http://localhost:8123';
process.env.TEST_HASS_TOKEN = process.env.TEST_HASS_TOKEN || 'test_token';
process.env.TEST_HASS_SOCKET_URL = process.env.TEST_HASS_SOCKET_URL || 'ws://localhost:8123/api/websocket';
process.env.TEST_PORT = process.env.TEST_PORT || '3001';
// Ensure test environment
// Set test environment
process.env.NODE_ENV = 'test';
process.env.HASS_URL = 'http://localhost:8123';
process.env.HASS_TOKEN = 'test_token';
process.env.CLAUDE_API_KEY = 'test_api_key';
process.env.CLAUDE_MODEL = 'test_model';
// Add TextEncoder and TextDecoder to global scope
Object.defineProperty(global, 'TextEncoder', {
value: TextEncoder,
writable: true
});
Object.defineProperty(global, 'TextDecoder', {
value: TextDecoder,
writable: true
});
// Configure console for tests
const originalConsole = { ...console };
global.console = {
...console,
log: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
};
// Increase test timeout
jest.setTimeout(30000);
// Mock WebSocket
jest.mock('ws', () => {
return {
WebSocket: jest.fn().mockImplementation(() => ({
on: jest.fn(),
send: jest.fn(),
close: jest.fn(),
removeAllListeners: jest.fn()
}))
};
});
// Mock chalk
jest.mock('chalk', () => ({
default: {
red: (text: string) => text,
green: (text: string) => text,
yellow: (text: string) => text,
blue: (text: string) => text,
magenta: (text: string) => text,
cyan: (text: string) => text,
white: (text: string) => text,
gray: (text: string) => text,
grey: (text: string) => text,
black: (text: string) => text,
bold: (text: string) => text,
dim: (text: string) => text,
italic: (text: string) => text,
underline: (text: string) => text,
inverse: (text: string) => text,
hidden: (text: string) => text,
strikethrough: (text: string) => text,
visible: (text: string) => text,
}
}));
// Reset mocks between tests
beforeEach(() => {
jest.clearAllMocks();
});
// Cleanup after tests
afterEach(() => {
jest.clearAllTimers();
});

View File

@@ -6,7 +6,7 @@
"main": "dist/index.js",
"scripts": {
"build": "npx tsc",
"start": "node dist/index.js",
"start": "node dist/src/index.js",
"dev": "tsx watch src/index.ts",
"test": "NODE_OPTIONS=--experimental-vm-modules jest --config=jest.config.cjs",
"test:coverage": "NODE_OPTIONS=--experimental-vm-modules jest --config=jest.config.cjs --coverage",
@@ -21,29 +21,30 @@
"dependencies": {
"@digital-alchemy/core": "^24.11.4",
"@digital-alchemy/hass": "^24.11.4",
"ajv": "^8.17.1",
"ajv": "^8.12.0",
"dotenv": "^16.3.1",
"express-rate-limit": "^7.5.0",
"helmet": "^8.0.0",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"litemcp": "^0.7.0",
"uuid": "^11.0.5",
"ws": "^8.18.0",
"uuid": "^9.0.1",
"ws": "^8.16.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/ajv": "^0.0.5",
"@types/express": "^5.0.0",
"@types/express-rate-limit": "^5.1.3",
"@types/helmet": "^0.0.48",
"@types/jest": "^28.1.8",
"@types/node": "^20.17.10",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.5.14",
"jest": "^28.1.3",
"semver": "^6.3.1",
"ts-jest": "^28.0.8",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
"@types/ajv": "^1.0.0",
"@types/express": "^4.17.21",
"@types/express-rate-limit": "^6.0.0",
"@types/helmet": "^4.0.0",
"@types/jest": "^29.5.14",
"@types/node": "^20.17.16",
"@types/uuid": "^9.0.8",
"@types/ws": "^8.5.10",
"jest": "^29.7.0",
"rimraf": "^5.0.5",
"ts-jest": "^29.1.2",
"tsx": "^4.7.0",
"typescript": "^5.3.3"
},
"author": "Jango Blockchained",
"license": "MIT"

View File

@@ -83,3 +83,4 @@ export interface HassEvent {
user_id?: string;
};
}

View File

@@ -13,6 +13,8 @@
"declaration": true,
"sourceMap": true,
"allowJs": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"types": [
"node",
"jest"
@@ -39,6 +41,7 @@
],
"exclude": [
"node_modules",
"dist"
"**/__tests__/**/*.ts",
"**/*.test.ts"
]
}