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:
jango-blockchained
2025-01-30 09:04:07 +01:00
parent f908d83cbf
commit d7e5fcf764
15 changed files with 1077 additions and 282 deletions

2
.gitignore vendored
View File

@@ -65,3 +65,5 @@ package-lock.json
yarn.lock yarn.lock
pnpm-lock.yaml pnpm-lock.yaml
bun.lockb bun.lockb
coverage/

View File

@@ -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;
}
});
});
}); });
}); });

View File

@@ -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
View 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
View 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
})
}
);
});
});
});

View File

@@ -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>

View File

@@ -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

View File

@@ -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 => {

View File

@@ -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
View 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

View File

@@ -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,
}));

View File

@@ -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
View 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
View 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;

View File

@@ -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"
] ]
} }