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
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -65,3 +65,5 @@ package-lock.json
|
|||||||
yarn.lock
|
yarn.lock
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
bun.lockb
|
bun.lockb
|
||||||
|
|
||||||
|
coverage/
|
||||||
@@ -1,14 +1,36 @@
|
|||||||
|
import { jest, describe, beforeEach, it, expect } from '@jest/globals';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { DomainSchema } from '../../src/schemas.js';
|
import { DomainSchema } from '../../src/schemas.js';
|
||||||
|
|
||||||
|
type MockResponse = { success: true };
|
||||||
|
type MockFn = jest.Mock<Promise<MockResponse>, any[]>;
|
||||||
|
|
||||||
// Define types for tool and server
|
// Define types for tool and server
|
||||||
interface Tool {
|
interface Tool {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
execute: (params: any) => Promise<any>;
|
execute: (params: any) => Promise<MockResponse>;
|
||||||
parameters: z.ZodType<any>;
|
parameters: z.ZodType<any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// Mock LiteMCP class
|
||||||
class MockLiteMCP {
|
class MockLiteMCP {
|
||||||
private tools: Tool[] = [];
|
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
|
// Mock the Home Assistant instance
|
||||||
jest.mock('../../src/hass/index.js', () => ({
|
const mockHassServices: MockHassInstance = {
|
||||||
get_hass: jest.fn().mockResolvedValue({
|
services: {
|
||||||
services: {
|
light: {
|
||||||
light: {
|
turn_on: createMockFn(),
|
||||||
turn_on: jest.fn().mockResolvedValue(undefined),
|
turn_off: createMockFn(),
|
||||||
turn_off: jest.fn().mockResolvedValue(undefined),
|
|
||||||
},
|
|
||||||
climate: {
|
|
||||||
set_temperature: jest.fn().mockResolvedValue(undefined),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
climate: {
|
||||||
|
set_temperature: createMockFn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mock('../../src/hass/index.js', () => ({
|
||||||
|
get_hass: jest.fn().mockReturnValue(Promise.resolve(mockHassServices)),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('MCP Server Context and Tools', () => {
|
describe('MCP Server Context and Tools', () => {
|
||||||
@@ -48,156 +78,20 @@ describe('MCP Server Context and Tools', () => {
|
|||||||
// Add the control tool to the server
|
// Add the control tool to the server
|
||||||
server.addTool({
|
server.addTool({
|
||||||
name: 'control',
|
name: 'control',
|
||||||
description: 'Control Home Assistant devices and services',
|
description: 'Control Home Assistant devices',
|
||||||
execute: async (params: any) => {
|
parameters: DomainSchema,
|
||||||
const domain = params.entity_id.split('.')[0];
|
execute: createMockFn(),
|
||||||
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(),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
it('should initialize with correct name and version', () => {
|
||||||
jest.clearAllMocks();
|
expect(server.name).toBe('home-assistant');
|
||||||
|
expect(server.version).toBe('0.1.0');
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Custom Prompts', () => {
|
it('should add and retrieve tools', () => {
|
||||||
it('should handle natural language commands for lights', async () => {
|
const tools = server.getTools();
|
||||||
const tools = server.getTools();
|
expect(tools).toHaveLength(1);
|
||||||
const tool = tools.find(t => t.name === 'control');
|
expect(tools[0].name).toBe('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<any>;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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
|
// Define types
|
||||||
jest.mock('../../src/hass/index.js', () => {
|
interface MockResponse {
|
||||||
let mockInstance: any = null;
|
success: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
type MockFn = () => Promise<MockResponse>;
|
||||||
get_hass: jest.fn(async () => {
|
|
||||||
if (!mockInstance) {
|
interface MockService {
|
||||||
mockInstance = {
|
[key: string]: Mock<MockFn>;
|
||||||
services: {
|
}
|
||||||
light: {
|
|
||||||
turn_on: jest.fn().mockResolvedValue(undefined),
|
interface MockServices {
|
||||||
turn_off: jest.fn().mockResolvedValue(undefined),
|
light: {
|
||||||
},
|
turn_on: Mock<MockFn>;
|
||||||
climate: {
|
turn_off: Mock<MockFn>;
|
||||||
set_temperature: jest.fn().mockResolvedValue(undefined),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return mockInstance;
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
});
|
climate: {
|
||||||
|
set_temperature: Mock<MockFn>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MockHassInstance {
|
||||||
|
services: MockServices;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock instance
|
||||||
|
let mockInstance: MockHassInstance | null = null;
|
||||||
|
|
||||||
|
const createMockFn = (): Mock<MockFn> => {
|
||||||
|
return jest.fn<MockFn>().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', () => {
|
describe('Home Assistant Connection', () => {
|
||||||
// Backup the original environment
|
// Backup the original environment
|
||||||
@@ -31,7 +57,18 @@ describe('Home Assistant Connection', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Clear all mocks
|
// Clear all mocks
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
// Initialize mock instance
|
||||||
|
mockInstance = {
|
||||||
|
services: {
|
||||||
|
light: {
|
||||||
|
turn_on: createMockFn(),
|
||||||
|
turn_off: createMockFn(),
|
||||||
|
},
|
||||||
|
climate: {
|
||||||
|
set_temperature: createMockFn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
// Reset environment variables
|
// Reset environment variables
|
||||||
process.env = { ...originalEnv };
|
process.env = { ...originalEnv };
|
||||||
});
|
});
|
||||||
@@ -42,6 +79,7 @@ describe('Home Assistant Connection', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return a Home Assistant instance with services', async () => {
|
it('should return a Home Assistant instance with services', async () => {
|
||||||
|
const { get_hass } = await import('../../src/hass/index.js');
|
||||||
const hass = await get_hass();
|
const hass = await get_hass();
|
||||||
|
|
||||||
expect(hass).toBeDefined();
|
expect(hass).toBeDefined();
|
||||||
@@ -51,31 +89,11 @@ describe('Home Assistant Connection', () => {
|
|||||||
expect(typeof hass.services.climate.set_temperature).toBe('function');
|
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 firstInstance = await get_hass();
|
||||||
const secondInstance = await get_hass();
|
const secondInstance = await get_hass();
|
||||||
|
|
||||||
expect(firstInstance).toBe(secondInstance);
|
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');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
45
__tests__/helpers.test.ts
Normal file
45
__tests__/helpers.test.ts
Normal file
@@ -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
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
286
__tests__/index.test.ts
Normal file
286
__tests__/index.test.ts
Normal file
@@ -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<Response> => {
|
||||||
|
return {} as Response;
|
||||||
|
}
|
||||||
|
) as unknown as jest.MockedFunction<typeof fetch>;
|
||||||
|
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<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<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;
|
||||||
|
|
||||||
|
// 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<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;
|
||||||
|
|
||||||
|
// 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<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;
|
||||||
|
|
||||||
|
// 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<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;
|
||||||
|
|
||||||
|
// 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<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;
|
||||||
|
|
||||||
|
// 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<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;
|
||||||
|
|
||||||
|
// 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<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;
|
||||||
|
|
||||||
|
// 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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,30 +23,30 @@
|
|||||||
<div class='clearfix'>
|
<div class='clearfix'>
|
||||||
|
|
||||||
<div class='fl pad1y space-right2'>
|
<div class='fl pad1y space-right2'>
|
||||||
<span class="strong">100% </span>
|
<span class="strong">38.18% </span>
|
||||||
<span class="quiet">Statements</span>
|
<span class="quiet">Statements</span>
|
||||||
<span class='fraction'>32/32</span>
|
<span class='fraction'>42/110</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class='fl pad1y space-right2'>
|
<div class='fl pad1y space-right2'>
|
||||||
<span class="strong">100% </span>
|
<span class="strong">12.96% </span>
|
||||||
<span class="quiet">Branches</span>
|
<span class="quiet">Branches</span>
|
||||||
<span class='fraction'>0/0</span>
|
<span class='fraction'>7/54</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class='fl pad1y space-right2'>
|
<div class='fl pad1y space-right2'>
|
||||||
<span class="strong">100% </span>
|
<span class="strong">28.57% </span>
|
||||||
<span class="quiet">Functions</span>
|
<span class="quiet">Functions</span>
|
||||||
<span class='fraction'>0/0</span>
|
<span class='fraction'>2/7</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class='fl pad1y space-right2'>
|
<div class='fl pad1y space-right2'>
|
||||||
<span class="strong">100% </span>
|
<span class="strong">38.18% </span>
|
||||||
<span class="quiet">Lines</span>
|
<span class="quiet">Lines</span>
|
||||||
<span class='fraction'>32/32</span>
|
<span class='fraction'>42/110</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class='status-line high'></div>
|
<div class='status-line low'></div>
|
||||||
<div class="pad1">
|
<div class="pad1">
|
||||||
<table class="coverage-summary">
|
<table class="coverage-summary">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -79,18 +79,48 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody><tr>
|
<tbody><tr>
|
||||||
<td class="file high" data-value="schemas.ts"><a href="schemas.ts.html">schemas.ts</a></td>
|
<td class="file low" data-value="src"><a href="src/index.html">src</a></td>
|
||||||
|
<td data-value="33" class="pic low">
|
||||||
|
<div class="chart"><div class="cover-fill" style="width: 33%"></div><div class="cover-empty" style="width: 67%"></div></div>
|
||||||
|
</td>
|
||||||
|
<td data-value="33" class="pct low">33%</td>
|
||||||
|
<td data-value="100" class="abs low">33/100</td>
|
||||||
|
<td data-value="2.43" class="pct low">2.43%</td>
|
||||||
|
<td data-value="41" class="abs low">1/41</td>
|
||||||
|
<td data-value="20" class="pct low">20%</td>
|
||||||
|
<td data-value="5" class="abs low">1/5</td>
|
||||||
|
<td data-value="33" class="pct low">33%</td>
|
||||||
|
<td data-value="100" class="abs low">33/100</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td class="file high" data-value="src/config"><a href="src/config/index.html">src/config</a></td>
|
||||||
<td data-value="100" class="pic high">
|
<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>
|
<div class="chart"><div class="cover-fill cover-full" style="width: 100%"></div><div class="cover-empty" style="width: 0%"></div></div>
|
||||||
</td>
|
</td>
|
||||||
<td data-value="100" class="pct high">100%</td>
|
<td data-value="100" class="pct high">100%</td>
|
||||||
<td data-value="32" class="abs high">32/32</td>
|
<td data-value="2" class="abs high">2/2</td>
|
||||||
|
<td data-value="50" class="pct medium">50%</td>
|
||||||
|
<td data-value="8" class="abs medium">4/8</td>
|
||||||
<td data-value="100" class="pct high">100%</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="abs high">0/0</td>
|
||||||
<td data-value="100" class="pct high">100%</td>
|
<td data-value="100" class="pct high">100%</td>
|
||||||
<td data-value="0" class="abs high">0/0</td>
|
<td data-value="2" class="abs high">2/2</td>
|
||||||
<td data-value="100" class="pct high">100%</td>
|
</tr>
|
||||||
<td data-value="32" class="abs high">32/32</td>
|
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -101,7 +131,7 @@
|
|||||||
<div class='footer quiet pad2 space-top1 center small'>
|
<div class='footer quiet pad2 space-top1 center small'>
|
||||||
Code coverage generated by
|
Code coverage generated by
|
||||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||||
at 2024-12-17T12:09:28.125Z
|
at 2024-12-21T09:04:36.269Z
|
||||||
</div>
|
</div>
|
||||||
<script src="prettify.js"></script>
|
<script src="prettify.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -1,8 +1,145 @@
|
|||||||
TN:
|
TN:
|
||||||
|
SF:src/helpers.ts
|
||||||
|
FN:1,(anonymous_0)
|
||||||
|
FNF:1
|
||||||
|
FNH:1
|
||||||
|
FNDA:3,(anonymous_0)
|
||||||
|
DA:1,1
|
||||||
|
DA:2,3
|
||||||
|
LF:2
|
||||||
|
LH:2
|
||||||
|
BRDA:1,0,0,2
|
||||||
|
BRF:1
|
||||||
|
BRH:1
|
||||||
|
end_of_record
|
||||||
|
TN:
|
||||||
|
SF:src/index.ts
|
||||||
|
FN:49,main
|
||||||
|
FN:60,(anonymous_1)
|
||||||
|
FN:74,(anonymous_2)
|
||||||
|
FN:136,(anonymous_3)
|
||||||
|
FNF:4
|
||||||
|
FNH:0
|
||||||
|
FNDA:0,main
|
||||||
|
FNDA:0,(anonymous_1)
|
||||||
|
FNDA:0,(anonymous_2)
|
||||||
|
FNDA:0,(anonymous_3)
|
||||||
|
DA:8,0
|
||||||
|
DA:9,0
|
||||||
|
DA:32,0
|
||||||
|
DA:33,0
|
||||||
|
DA:34,0
|
||||||
|
DA:50,0
|
||||||
|
DA:53,0
|
||||||
|
DA:56,0
|
||||||
|
DA:61,0
|
||||||
|
DA:62,0
|
||||||
|
DA:69,0
|
||||||
|
DA:70,0
|
||||||
|
DA:73,0
|
||||||
|
DA:74,0
|
||||||
|
DA:75,0
|
||||||
|
DA:76,0
|
||||||
|
DA:77,0
|
||||||
|
DA:79,0
|
||||||
|
DA:84,0
|
||||||
|
DA:87,0
|
||||||
|
DA:92,0
|
||||||
|
DA:101,0
|
||||||
|
DA:137,0
|
||||||
|
DA:138,0
|
||||||
|
DA:140,0
|
||||||
|
DA:141,0
|
||||||
|
DA:144,0
|
||||||
|
DA:145,0
|
||||||
|
DA:150,0
|
||||||
|
DA:152,0
|
||||||
|
DA:153,0
|
||||||
|
DA:155,0
|
||||||
|
DA:156,0
|
||||||
|
DA:158,0
|
||||||
|
DA:159,0
|
||||||
|
DA:161,0
|
||||||
|
DA:164,0
|
||||||
|
DA:165,0
|
||||||
|
DA:167,0
|
||||||
|
DA:168,0
|
||||||
|
DA:170,0
|
||||||
|
DA:173,0
|
||||||
|
DA:174,0
|
||||||
|
DA:175,0
|
||||||
|
DA:177,0
|
||||||
|
DA:178,0
|
||||||
|
DA:180,0
|
||||||
|
DA:181,0
|
||||||
|
DA:184,0
|
||||||
|
DA:185,0
|
||||||
|
DA:187,0
|
||||||
|
DA:188,0
|
||||||
|
DA:190,0
|
||||||
|
DA:191,0
|
||||||
|
DA:193,0
|
||||||
|
DA:198,0
|
||||||
|
DA:201,0
|
||||||
|
DA:205,0
|
||||||
|
DA:206,0
|
||||||
|
DA:215,0
|
||||||
|
DA:216,0
|
||||||
|
DA:219,0
|
||||||
|
DA:224,0
|
||||||
|
DA:227,0
|
||||||
|
DA:236,0
|
||||||
|
DA:237,0
|
||||||
|
DA:240,0
|
||||||
|
LF:67
|
||||||
|
LH:0
|
||||||
|
BRDA:8,0,0,0
|
||||||
|
BRDA:8,0,1,0
|
||||||
|
BRDA:69,1,0,0
|
||||||
|
BRDA:76,2,0,0
|
||||||
|
BRDA:94,3,0,0
|
||||||
|
BRDA:94,3,1,0
|
||||||
|
BRDA:140,4,0,0
|
||||||
|
BRDA:150,5,0,0
|
||||||
|
BRDA:150,5,1,0
|
||||||
|
BRDA:150,5,2,0
|
||||||
|
BRDA:150,5,3,0
|
||||||
|
BRDA:150,5,4,0
|
||||||
|
BRDA:150,5,5,0
|
||||||
|
BRDA:152,6,0,0
|
||||||
|
BRDA:155,7,0,0
|
||||||
|
BRDA:158,8,0,0
|
||||||
|
BRDA:164,9,0,0
|
||||||
|
BRDA:164,10,0,0
|
||||||
|
BRDA:164,10,1,0
|
||||||
|
BRDA:167,11,0,0
|
||||||
|
BRDA:167,12,0,0
|
||||||
|
BRDA:167,12,1,0
|
||||||
|
BRDA:173,13,0,0
|
||||||
|
BRDA:174,14,0,0
|
||||||
|
BRDA:177,15,0,0
|
||||||
|
BRDA:180,16,0,0
|
||||||
|
BRDA:184,17,0,0
|
||||||
|
BRDA:184,18,0,0
|
||||||
|
BRDA:184,18,1,0
|
||||||
|
BRDA:187,19,0,0
|
||||||
|
BRDA:187,20,0,0
|
||||||
|
BRDA:187,20,1,0
|
||||||
|
BRDA:190,21,0,0
|
||||||
|
BRDA:190,22,0,0
|
||||||
|
BRDA:190,22,1,0
|
||||||
|
BRDA:215,23,0,0
|
||||||
|
BRDA:224,24,0,0
|
||||||
|
BRDA:224,24,1,0
|
||||||
|
BRDA:229,25,0,0
|
||||||
|
BRDA:229,25,1,0
|
||||||
|
BRF:40
|
||||||
|
BRH:0
|
||||||
|
end_of_record
|
||||||
|
TN:
|
||||||
SF:src/schemas.ts
|
SF:src/schemas.ts
|
||||||
FNF:0
|
FNF:0
|
||||||
FNH:0
|
FNH:0
|
||||||
DA:1,2
|
|
||||||
DA:4,2
|
DA:4,2
|
||||||
DA:22,2
|
DA:22,2
|
||||||
DA:30,2
|
DA:30,2
|
||||||
@@ -34,8 +171,53 @@ DA:215,2
|
|||||||
DA:219,2
|
DA:219,2
|
||||||
DA:223,2
|
DA:223,2
|
||||||
DA:227,2
|
DA:227,2
|
||||||
LF:32
|
LF:31
|
||||||
LH:32
|
LH:31
|
||||||
BRF:0
|
BRF:0
|
||||||
BRH:0
|
BRH:0
|
||||||
end_of_record
|
end_of_record
|
||||||
|
TN:
|
||||||
|
SF:src/config/hass.config.ts
|
||||||
|
FNF:0
|
||||||
|
FNH:0
|
||||||
|
DA:4,1
|
||||||
|
DA:6,1
|
||||||
|
LF:2
|
||||||
|
LH:2
|
||||||
|
BRDA:7,0,0,1
|
||||||
|
BRDA:7,0,1,0
|
||||||
|
BRDA:8,1,0,1
|
||||||
|
BRDA:8,1,1,0
|
||||||
|
BRDA:9,2,0,1
|
||||||
|
BRDA:9,2,1,0
|
||||||
|
BRDA:10,3,0,1
|
||||||
|
BRDA:10,3,1,0
|
||||||
|
BRF:8
|
||||||
|
BRH:4
|
||||||
|
end_of_record
|
||||||
|
TN:
|
||||||
|
SF:src/hass/index.ts
|
||||||
|
FN:66,(anonymous_0)
|
||||||
|
FN:107,get_hass
|
||||||
|
FNF:2
|
||||||
|
FNH:1
|
||||||
|
FNDA:0,(anonymous_0)
|
||||||
|
FNDA:3,get_hass
|
||||||
|
DA:56,1
|
||||||
|
DA:68,0
|
||||||
|
DA:105,1
|
||||||
|
DA:108,3
|
||||||
|
DA:110,1
|
||||||
|
DA:112,1
|
||||||
|
DA:113,1
|
||||||
|
DA:115,3
|
||||||
|
LF:8
|
||||||
|
LH:7
|
||||||
|
BRDA:68,0,0,0
|
||||||
|
BRDA:68,0,1,0
|
||||||
|
BRDA:108,1,0,1
|
||||||
|
BRDA:110,2,0,1
|
||||||
|
BRDA:110,2,1,0
|
||||||
|
BRF:5
|
||||||
|
BRH:2
|
||||||
|
end_of_record
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
module.exports = (path, options) => {
|
const path = require('path');
|
||||||
|
|
||||||
|
module.exports = (request, options) => {
|
||||||
|
// Handle chalk and related packages
|
||||||
|
if (request === 'chalk' || request === '#ansi-styles' || request === '#supports-color') {
|
||||||
|
return path.resolve(__dirname, 'node_modules', request.replace('#', ''));
|
||||||
|
}
|
||||||
|
|
||||||
// Call the default resolver
|
// Call the default resolver
|
||||||
return options.defaultResolver(path, {
|
return options.defaultResolver(request, {
|
||||||
...options,
|
...options,
|
||||||
// Force node to resolve modules as CommonJS
|
// Force node to resolve modules as CommonJS
|
||||||
packageFilter: pkg => {
|
packageFilter: pkg => {
|
||||||
|
|||||||
@@ -1,43 +1,53 @@
|
|||||||
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
preset: 'ts-jest',
|
preset: 'ts-jest/presets/default-esm',
|
||||||
testEnvironment: 'node',
|
testEnvironment: 'node',
|
||||||
moduleNameMapper: {
|
|
||||||
'^@src/(.*)$': '<rootDir>/src/$1',
|
|
||||||
'^@tests/(.*)$': '<rootDir>/__tests__/$1',
|
|
||||||
'^(\\.{1,2}/.*)\\.js$': '$1'
|
|
||||||
},
|
|
||||||
roots: [
|
|
||||||
'<rootDir>/src',
|
|
||||||
'<rootDir>/__tests__'
|
|
||||||
],
|
|
||||||
transform: {
|
|
||||||
'^.+\\.tsx?$': ['ts-jest', {
|
|
||||||
useESM: true,
|
|
||||||
tsconfig: './tsconfig.json'
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
extensionsToTreatAsEsm: ['.ts', '.tsx'],
|
|
||||||
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$',
|
|
||||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
|
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
|
||||||
resolver: '<rootDir>/jest-resolver.cjs',
|
extensionsToTreatAsEsm: ['.ts', '.tsx'],
|
||||||
transformIgnorePatterns: [
|
moduleNameMapper: {
|
||||||
'node_modules/(?!(@digital-alchemy|litemcp|semver|zod)/)'
|
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||||
],
|
'#(.*)': '<rootDir>/node_modules/$1',
|
||||||
modulePathIgnorePatterns: [
|
'^(\\.{1,2}/.*)\\.ts$': '$1',
|
||||||
'<rootDir>/dist/'
|
'^chalk$': '<rootDir>/node_modules/chalk/source/index.js',
|
||||||
],
|
'#ansi-styles': '<rootDir>/node_modules/ansi-styles/index.js',
|
||||||
testEnvironmentOptions: {
|
'#supports-color': '<rootDir>/node_modules/supports-color/index.js'
|
||||||
experimentalVmModules: true
|
|
||||||
},
|
},
|
||||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
transform: {
|
||||||
|
'^.+\\.(ts|tsx|js|jsx)$': [
|
||||||
|
'ts-jest',
|
||||||
|
{
|
||||||
|
useESM: true,
|
||||||
|
tsconfig: 'tsconfig.json'
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
transformIgnorePatterns: [
|
||||||
|
'node_modules/(?!(@digital-alchemy|chalk|#ansi-styles|#supports-color)/)'
|
||||||
|
],
|
||||||
|
resolver: '<rootDir>/jest-resolver.cjs',
|
||||||
|
testMatch: ['**/__tests__/**/*.test.ts'],
|
||||||
|
setupFilesAfterEnv: ['<rootDir>/jest.setup.cjs'],
|
||||||
globals: {
|
globals: {
|
||||||
'ts-jest': {
|
'ts-jest': {
|
||||||
useESM: true,
|
useESM: true,
|
||||||
tsconfig: {
|
},
|
||||||
allowJs: true,
|
},
|
||||||
esModuleInterop: true
|
collectCoverage: true,
|
||||||
}
|
coverageDirectory: 'coverage',
|
||||||
|
coverageReporters: ['text', 'lcov', 'clover', 'html'],
|
||||||
|
collectCoverageFrom: [
|
||||||
|
'src/**/*.{ts,tsx}',
|
||||||
|
'!src/**/*.d.ts',
|
||||||
|
'!src/**/*.test.{ts,tsx}',
|
||||||
|
'!src/types/**/*',
|
||||||
|
'!src/polyfills.ts'
|
||||||
|
],
|
||||||
|
coverageThreshold: {
|
||||||
|
global: {
|
||||||
|
branches: 80,
|
||||||
|
functions: 80,
|
||||||
|
lines: 80,
|
||||||
|
statements: 80
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
32
jest.setup.cjs
Normal file
32
jest.setup.cjs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// Mock chalk module
|
||||||
|
jest.mock('chalk', () => ({
|
||||||
|
default: {
|
||||||
|
red: (text) => text,
|
||||||
|
green: (text) => text,
|
||||||
|
yellow: (text) => text,
|
||||||
|
blue: (text) => text,
|
||||||
|
magenta: (text) => text,
|
||||||
|
cyan: (text) => text,
|
||||||
|
white: (text) => text,
|
||||||
|
gray: (text) => text,
|
||||||
|
grey: (text) => text,
|
||||||
|
black: (text) => text,
|
||||||
|
bold: (text) => text,
|
||||||
|
dim: (text) => text,
|
||||||
|
italic: (text) => text,
|
||||||
|
underline: (text) => text,
|
||||||
|
inverse: (text) => text,
|
||||||
|
hidden: (text) => text,
|
||||||
|
strikethrough: (text) => text,
|
||||||
|
visible: (text) => text,
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock environment variables
|
||||||
|
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';
|
||||||
|
|
||||||
|
// Global Jest settings
|
||||||
|
jest.setTimeout(30000); // 30 seconds timeout
|
||||||
@@ -1,19 +1,31 @@
|
|||||||
const jestGlobals = require('@jest/globals');
|
import { jest } from '@jest/globals';
|
||||||
|
|
||||||
|
// Mock environment variables
|
||||||
|
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';
|
||||||
|
|
||||||
|
// Global Jest settings
|
||||||
|
jest.setTimeout(30000); // 30 seconds timeout
|
||||||
|
|
||||||
// Mock semver to avoid the SemVer constructor issue
|
// Mock semver to avoid the SemVer constructor issue
|
||||||
jestGlobals.jest.mock('semver', () => {
|
jest.mock('semver', () => ({
|
||||||
const actual = jestGlobals.jest.requireActual('semver');
|
default: class SemVer {
|
||||||
return {
|
constructor(version) {
|
||||||
...actual,
|
this.version = version;
|
||||||
parse: jestGlobals.jest.fn((version) => ({ version })),
|
}
|
||||||
valid: jestGlobals.jest.fn(() => true),
|
toString() {
|
||||||
satisfies: jestGlobals.jest.fn(() => true),
|
return this.version;
|
||||||
gt: jestGlobals.jest.fn(() => true),
|
}
|
||||||
gte: jestGlobals.jest.fn(() => true),
|
},
|
||||||
lt: jestGlobals.jest.fn(() => false),
|
valid: (v) => v,
|
||||||
lte: jestGlobals.jest.fn(() => false),
|
clean: (v) => v,
|
||||||
eq: jestGlobals.jest.fn(() => true),
|
satisfies: () => true,
|
||||||
neq: jestGlobals.jest.fn(() => false),
|
gt: () => false,
|
||||||
SemVer: jestGlobals.jest.fn((version) => ({ version }))
|
gte: () => true,
|
||||||
};
|
lt: () => false,
|
||||||
});
|
lte: () => true,
|
||||||
|
eq: () => true,
|
||||||
|
neq: () => false,
|
||||||
|
}));
|
||||||
@@ -8,7 +8,9 @@
|
|||||||
"build": "npx tsc",
|
"build": "npx tsc",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"test": "jest --config=jest.config.js",
|
"test": "NODE_OPTIONS=--experimental-vm-modules jest --config=jest.config.cjs",
|
||||||
|
"test:coverage": "NODE_OPTIONS=--experimental-vm-modules jest --config=jest.config.cjs --coverage",
|
||||||
|
"test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --config=jest.config.cjs --watch",
|
||||||
"lint": "eslint src --ext .ts",
|
"lint": "eslint src --ext .ts",
|
||||||
"lint:fix": "eslint src --ext .ts --fix",
|
"lint:fix": "eslint src --ext .ts --fix",
|
||||||
"prepare": "npm run build",
|
"prepare": "npm run build",
|
||||||
@@ -19,11 +21,13 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@digital-alchemy/core": "^24.11.4",
|
"@digital-alchemy/core": "^24.11.4",
|
||||||
"@digital-alchemy/hass": "^24.11.4",
|
"@digital-alchemy/hass": "^24.11.4",
|
||||||
|
"ajv": "^8.17.1",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"litemcp": "^0.7.0",
|
"litemcp": "^0.7.0",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/ajv": "^0.0.5",
|
||||||
"@types/jest": "^28.1.8",
|
"@types/jest": "^28.1.8",
|
||||||
"@types/node": "^20.17.10",
|
"@types/node": "^20.17.10",
|
||||||
"jest": "^28.1.3",
|
"jest": "^28.1.3",
|
||||||
|
|||||||
186
src/schemas/hass.ts
Normal file
186
src/schemas/hass.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { JSONSchemaType } from 'ajv';
|
||||||
|
import type HomeAssistant from '../types/hass.js';
|
||||||
|
|
||||||
|
export const entitySchema: JSONSchemaType<HomeAssistant.Entity> = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
entity_id: { type: 'string' },
|
||||||
|
state: { type: 'string' },
|
||||||
|
attributes: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: true
|
||||||
|
},
|
||||||
|
last_changed: { type: 'string' },
|
||||||
|
last_updated: { type: 'string' },
|
||||||
|
context: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
parent_id: { type: 'string', nullable: true },
|
||||||
|
user_id: { type: 'string', nullable: true }
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
additionalProperties: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['entity_id', 'state', 'attributes', 'last_changed', 'last_updated', 'context'],
|
||||||
|
additionalProperties: false
|
||||||
|
};
|
||||||
|
|
||||||
|
export const serviceSchema: JSONSchemaType<HomeAssistant.Service> = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
domain: { type: 'string' },
|
||||||
|
service: { type: 'string' },
|
||||||
|
target: {
|
||||||
|
type: 'object',
|
||||||
|
nullable: true,
|
||||||
|
properties: {
|
||||||
|
entity_id: {
|
||||||
|
anyOf: [
|
||||||
|
{ type: 'string' },
|
||||||
|
{ type: 'array', items: { type: 'string' } }
|
||||||
|
],
|
||||||
|
nullable: true
|
||||||
|
},
|
||||||
|
device_id: {
|
||||||
|
anyOf: [
|
||||||
|
{ type: 'string' },
|
||||||
|
{ type: 'array', items: { type: 'string' } }
|
||||||
|
],
|
||||||
|
nullable: true
|
||||||
|
},
|
||||||
|
area_id: {
|
||||||
|
anyOf: [
|
||||||
|
{ type: 'string' },
|
||||||
|
{ type: 'array', items: { type: 'string' } }
|
||||||
|
],
|
||||||
|
nullable: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
additionalProperties: false
|
||||||
|
},
|
||||||
|
service_data: {
|
||||||
|
type: 'object',
|
||||||
|
nullable: true,
|
||||||
|
additionalProperties: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['domain', 'service'],
|
||||||
|
additionalProperties: false
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stateChangedEventSchema: JSONSchemaType<HomeAssistant.StateChangedEvent> = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
event_type: { type: 'string', const: 'state_changed' },
|
||||||
|
data: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
entity_id: { type: 'string' },
|
||||||
|
new_state: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
entity_id: { type: 'string' },
|
||||||
|
state: { type: 'string' },
|
||||||
|
attributes: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: true
|
||||||
|
},
|
||||||
|
last_changed: { type: 'string' },
|
||||||
|
last_updated: { type: 'string' },
|
||||||
|
context: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
parent_id: { type: 'string', nullable: true },
|
||||||
|
user_id: { type: 'string', nullable: true }
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
additionalProperties: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['entity_id', 'state', 'attributes', 'last_changed', 'last_updated', 'context'],
|
||||||
|
additionalProperties: false
|
||||||
|
},
|
||||||
|
{ type: 'null' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
old_state: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
entity_id: { type: 'string' },
|
||||||
|
state: { type: 'string' },
|
||||||
|
attributes: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: true
|
||||||
|
},
|
||||||
|
last_changed: { type: 'string' },
|
||||||
|
last_updated: { type: 'string' },
|
||||||
|
context: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
parent_id: { type: 'string', nullable: true },
|
||||||
|
user_id: { type: 'string', nullable: true }
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
additionalProperties: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['entity_id', 'state', 'attributes', 'last_changed', 'last_updated', 'context'],
|
||||||
|
additionalProperties: false
|
||||||
|
},
|
||||||
|
{ type: 'null' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['entity_id', 'new_state', 'old_state'],
|
||||||
|
additionalProperties: false
|
||||||
|
},
|
||||||
|
origin: { type: 'string' },
|
||||||
|
time_fired: { type: 'string' },
|
||||||
|
context: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
parent_id: { type: 'string', nullable: true },
|
||||||
|
user_id: { type: 'string', nullable: true }
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
additionalProperties: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['event_type', 'data', 'origin', 'time_fired', 'context'],
|
||||||
|
additionalProperties: false
|
||||||
|
};
|
||||||
|
|
||||||
|
export const configSchema: JSONSchemaType<HomeAssistant.Config> = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
latitude: { type: 'number' },
|
||||||
|
longitude: { type: 'number' },
|
||||||
|
elevation: { type: 'number' },
|
||||||
|
unit_system: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
length: { type: 'string' },
|
||||||
|
mass: { type: 'string' },
|
||||||
|
temperature: { type: 'string' },
|
||||||
|
volume: { type: 'string' }
|
||||||
|
},
|
||||||
|
required: ['length', 'mass', 'temperature', 'volume'],
|
||||||
|
additionalProperties: false
|
||||||
|
},
|
||||||
|
location_name: { type: 'string' },
|
||||||
|
time_zone: { type: 'string' },
|
||||||
|
components: { type: 'array', items: { type: 'string' } },
|
||||||
|
version: { type: 'string' }
|
||||||
|
},
|
||||||
|
required: ['latitude', 'longitude', 'elevation', 'unit_system', 'location_name', 'time_zone', 'components', 'version'],
|
||||||
|
additionalProperties: false
|
||||||
|
};
|
||||||
81
src/types/hass.d.ts
vendored
Normal file
81
src/types/hass.d.ts
vendored
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
declare namespace HomeAssistant {
|
||||||
|
interface Entity {
|
||||||
|
entity_id: string;
|
||||||
|
state: string;
|
||||||
|
attributes: Record<string, any>;
|
||||||
|
last_changed: string;
|
||||||
|
last_updated: string;
|
||||||
|
context: {
|
||||||
|
id: string;
|
||||||
|
parent_id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Service {
|
||||||
|
domain: string;
|
||||||
|
service: string;
|
||||||
|
target?: {
|
||||||
|
entity_id?: string | string[];
|
||||||
|
device_id?: string | string[];
|
||||||
|
area_id?: string | string[];
|
||||||
|
};
|
||||||
|
service_data?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebsocketMessage {
|
||||||
|
type: string;
|
||||||
|
id?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthMessage extends WebsocketMessage {
|
||||||
|
type: 'auth';
|
||||||
|
access_token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubscribeEventsMessage extends WebsocketMessage {
|
||||||
|
type: 'subscribe_events';
|
||||||
|
event_type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StateChangedEvent {
|
||||||
|
event_type: 'state_changed';
|
||||||
|
data: {
|
||||||
|
entity_id: string;
|
||||||
|
new_state: Entity | null;
|
||||||
|
old_state: Entity | null;
|
||||||
|
};
|
||||||
|
origin: string;
|
||||||
|
time_fired: string;
|
||||||
|
context: {
|
||||||
|
id: string;
|
||||||
|
parent_id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Config {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
elevation: number;
|
||||||
|
unit_system: {
|
||||||
|
length: string;
|
||||||
|
mass: string;
|
||||||
|
temperature: string;
|
||||||
|
volume: string;
|
||||||
|
};
|
||||||
|
location_name: string;
|
||||||
|
time_zone: string;
|
||||||
|
components: string[];
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiError {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
details?: Record<string, any>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export = HomeAssistant;
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
"module": "NodeNext",
|
"module": "NodeNext",
|
||||||
"moduleResolution": "NodeNext",
|
"moduleResolution": "NodeNext",
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src",
|
"rootDir": ".",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
@@ -22,17 +22,23 @@
|
|||||||
],
|
],
|
||||||
"lib": [
|
"lib": [
|
||||||
"ES2022"
|
"ES2022"
|
||||||
]
|
],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@src/*": [
|
||||||
|
"src/*"
|
||||||
|
],
|
||||||
|
"@tests/*": [
|
||||||
|
"__tests__/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src/**/*"
|
"src/**/*",
|
||||||
|
"__tests__/**/*"
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"node_modules",
|
"node_modules",
|
||||||
"dist",
|
"dist"
|
||||||
"**/*.test.ts",
|
|
||||||
"**/*.spec.ts",
|
|
||||||
"jest.config.js",
|
|
||||||
"jest.setup.js"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user