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