test: migrate test suite from Jest to Bun test framework
- Convert test files to use Bun's test framework and mocking utilities - Update import statements and test syntax - Add comprehensive test utilities and mock implementations - Create test migration guide documentation - Implement helper functions for consistent test setup and teardown - Add type definitions for improved type safety in tests
This commit is contained in:
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';
|
||||
|
||||
// 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 }],
|
||||
};
|
||||
};
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { formatToolCall } from "../src/utils/helpers";
|
||||
|
||||
describe('helpers', () => {
|
||||
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 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 result = formatToolCall(testObj, true);
|
||||
|
||||
@@ -35,7 +29,7 @@ describe('helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty objects', () => {
|
||||
test('should handle empty objects', () => {
|
||||
const 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 { LiteMCP } from 'litemcp';
|
||||
import { get_hass } from '../src/hass/index.js';
|
||||
import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test";
|
||||
import type { Mock } from "bun:test";
|
||||
import type { WebSocket } from 'ws';
|
||||
import type { LiteMCP } from 'litemcp';
|
||||
|
||||
// Load test environment variables with defaults
|
||||
const TEST_HASS_HOST = process.env.TEST_HASS_HOST || 'http://localhost:8123';
|
||||
const TEST_HASS_TOKEN = process.env.TEST_HASS_TOKEN || 'test_token';
|
||||
const TEST_HASS_SOCKET_URL = process.env.TEST_HASS_SOCKET_URL || 'ws://localhost:8123/api/websocket';
|
||||
// Extend the global scope
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var mockResponse: Response;
|
||||
}
|
||||
|
||||
// Set environment variables for testing
|
||||
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
|
||||
// Types
|
||||
interface Tool {
|
||||
name: string;
|
||||
description: string;
|
||||
@@ -44,30 +17,18 @@ interface Tool {
|
||||
execute: (params: Record<string, unknown>) => Promise<unknown>;
|
||||
}
|
||||
|
||||
type MockFunction<T = any> = jest.Mock<Promise<T>, any[]>;
|
||||
|
||||
interface MockLiteMCPInstance {
|
||||
addTool: ReturnType<typeof jest.fn>;
|
||||
start: ReturnType<typeof jest.fn>;
|
||||
addTool: Mock<(tool: Tool) => void>;
|
||||
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 {
|
||||
light: {
|
||||
turn_on: jest.Mock;
|
||||
turn_off: jest.Mock;
|
||||
turn_on: Mock<() => Promise<{ success: boolean }>>;
|
||||
turn_off: Mock<() => Promise<{ success: boolean }>>;
|
||||
};
|
||||
climate: {
|
||||
set_temperature: jest.Mock;
|
||||
set_temperature: Mock<() => Promise<{ success: boolean }>>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,21 +36,6 @@ interface MockHassInstance {
|
||||
services: MockServices;
|
||||
}
|
||||
|
||||
// Create mock services
|
||||
const mockServices: MockServices = {
|
||||
light: {
|
||||
turn_on: jest.fn().mockResolvedValue({ success: true }),
|
||||
turn_off: jest.fn().mockResolvedValue({ success: true })
|
||||
},
|
||||
climate: {
|
||||
set_temperature: jest.fn().mockResolvedValue({ success: true })
|
||||
}
|
||||
};
|
||||
|
||||
jest.unstable_mockModule('../src/hass/index.js', () => ({
|
||||
get_hass: jest.fn().mockResolvedValue({ services: mockServices })
|
||||
}));
|
||||
|
||||
interface TestResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
@@ -103,99 +49,212 @@ interface TestResponse {
|
||||
new_automation_id?: string;
|
||||
}
|
||||
|
||||
type WebSocketEventMap = {
|
||||
message: MessageEvent;
|
||||
open: Event;
|
||||
close: Event;
|
||||
error: Event;
|
||||
// Test configuration
|
||||
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;
|
||||
|
||||
// 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;
|
||||
type WebSocketMessageListener = (event: MessageEvent) => void;
|
||||
const mockServices: MockServices = {
|
||||
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 {
|
||||
addEventListener: jest.Mock;
|
||||
removeEventListener: jest.Mock;
|
||||
send: jest.Mock;
|
||||
close: jest.Mock;
|
||||
readyState: number;
|
||||
binaryType: 'blob' | 'arraybuffer';
|
||||
bufferedAmount: number;
|
||||
extensions: string;
|
||||
protocol: string;
|
||||
url: string;
|
||||
onopen: WebSocketEventListener | null;
|
||||
onerror: WebSocketEventListener | null;
|
||||
onclose: WebSocketEventListener | null;
|
||||
onmessage: WebSocketMessageListener | null;
|
||||
CONNECTING: number;
|
||||
OPEN: number;
|
||||
CLOSING: number;
|
||||
CLOSED: number;
|
||||
// Mock WebSocket
|
||||
class MockWebSocket implements Partial<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 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);
|
||||
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 => ({
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
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
|
||||
// Create fetch mock with implementation
|
||||
let mockFetch = mock(() => {
|
||||
return Promise.resolve(new Response());
|
||||
});
|
||||
|
||||
// Override globals
|
||||
globalThis.fetch = mockFetch;
|
||||
// Use type assertion to handle WebSocket compatibility
|
||||
globalThis.WebSocket = MockWebSocket as any;
|
||||
|
||||
describe('Home Assistant MCP Server', () => {
|
||||
let mockHass: MockHassInstance;
|
||||
let liteMcpInstance: MockLiteMCPInstance;
|
||||
let addToolCalls: Array<[Tool]>;
|
||||
let addToolCalls: Tool[];
|
||||
|
||||
beforeEach(async () => {
|
||||
mockHass = {
|
||||
services: mockServices
|
||||
};
|
||||
|
||||
// Reset all mocks
|
||||
jest.clearAllMocks();
|
||||
mockFetch.mockClear();
|
||||
// Reset mocks
|
||||
mockLiteMCPInstance.addTool.mock.calls = [];
|
||||
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
|
||||
await import('../src/index.js');
|
||||
|
||||
// Mock WebSocket
|
||||
const mockWs = createMockWebSocket();
|
||||
(global as any).WebSocket = jest.fn(() => mockWs);
|
||||
|
||||
// Get the mock instance
|
||||
liteMcpInstance = mockLiteMCPInstance;
|
||||
addToolCalls = liteMcpInstance.addTool.mock.calls as Array<[Tool]>;
|
||||
addToolCalls = mockLiteMCPInstance.addTool.mock.calls.map(call => call.args[0]);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const hass = await get_hass();
|
||||
expect(hass).toBeDefined();
|
||||
expect(hass.services).toBeDefined();
|
||||
expect(typeof hass.services.light.turn_on).toBe('function');
|
||||
test('should connect to Home Assistant', async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
// Verify connection
|
||||
expect(mockFetch.mock.calls.length).toBeGreaterThan(0);
|
||||
expect(mockLiteMCPInstance.start.mock.calls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should reuse the same instance on subsequent calls', async () => {
|
||||
const firstInstance = await get_hass();
|
||||
const secondInstance = await get_hass();
|
||||
expect(firstInstance).toBe(secondInstance);
|
||||
test('should handle connection errors', async () => {
|
||||
// Setup error response
|
||||
mockFetch = mock(() => Promise.reject(new Error('Connection failed')));
|
||||
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', () => {
|
||||
@@ -220,7 +279,7 @@ describe('Home Assistant MCP Server', () => {
|
||||
} as Response);
|
||||
|
||||
// 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();
|
||||
|
||||
if (!listDevicesTool) {
|
||||
@@ -251,7 +310,7 @@ describe('Home Assistant MCP Server', () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
// 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();
|
||||
|
||||
if (!listDevicesTool) {
|
||||
@@ -276,7 +335,7 @@ describe('Home Assistant MCP Server', () => {
|
||||
} as Response);
|
||||
|
||||
// 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();
|
||||
|
||||
if (!controlTool) {
|
||||
@@ -296,11 +355,11 @@ describe('Home Assistant MCP Server', () => {
|
||||
|
||||
// Verify the fetch call
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
`${TEST_HASS_HOST}/api/services/light/turn_on`,
|
||||
`${TEST_CONFIG.HASS_HOST}/api/services/light/turn_on`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${TEST_HASS_TOKEN}`,
|
||||
Authorization: `Bearer ${TEST_CONFIG.HASS_TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
@@ -313,7 +372,7 @@ describe('Home Assistant MCP Server', () => {
|
||||
|
||||
it('should handle unsupported domains', async () => {
|
||||
// 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();
|
||||
|
||||
if (!controlTool) {
|
||||
@@ -339,7 +398,7 @@ describe('Home Assistant MCP Server', () => {
|
||||
} as Response);
|
||||
|
||||
// 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();
|
||||
|
||||
if (!controlTool) {
|
||||
@@ -365,7 +424,7 @@ describe('Home Assistant MCP Server', () => {
|
||||
} as Response);
|
||||
|
||||
// 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();
|
||||
|
||||
if (!controlTool) {
|
||||
@@ -387,11 +446,11 @@ describe('Home Assistant MCP Server', () => {
|
||||
|
||||
// Verify the fetch call
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
`${TEST_HASS_HOST}/api/services/climate/set_temperature`,
|
||||
`${TEST_CONFIG.HASS_HOST}/api/services/climate/set_temperature`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${TEST_HASS_TOKEN}`,
|
||||
Authorization: `Bearer ${TEST_CONFIG.HASS_TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
@@ -412,7 +471,7 @@ describe('Home Assistant MCP Server', () => {
|
||||
} as Response);
|
||||
|
||||
// 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();
|
||||
|
||||
if (!controlTool) {
|
||||
@@ -432,11 +491,11 @@ describe('Home Assistant MCP Server', () => {
|
||||
|
||||
// Verify the fetch call
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
`${TEST_HASS_HOST}/api/services/cover/set_position`,
|
||||
`${TEST_CONFIG.HASS_HOST}/api/services/cover/set_position`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${TEST_HASS_TOKEN}`,
|
||||
Authorization: `Bearer ${TEST_CONFIG.HASS_TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
@@ -449,36 +508,29 @@ describe('Home Assistant MCP Server', () => {
|
||||
});
|
||||
|
||||
describe('get_history tool', () => {
|
||||
it('should successfully fetch history', async () => {
|
||||
test('should successfully fetch history', async () => {
|
||||
const mockHistory = [
|
||||
{
|
||||
entity_id: 'light.living_room',
|
||||
state: 'on',
|
||||
last_changed: '2024-01-01T00:00:00Z',
|
||||
attributes: { brightness: 255 }
|
||||
},
|
||||
{
|
||||
entity_id: 'light.living_room',
|
||||
state: 'off',
|
||||
last_changed: '2024-01-01T01:00:00Z',
|
||||
attributes: { brightness: 0 }
|
||||
}
|
||||
];
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockHistory
|
||||
} as Response);
|
||||
// Setup response for this test
|
||||
mockFetch = mock(() => Promise.resolve(new Response(
|
||||
JSON.stringify(mockHistory)
|
||||
)));
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
// Get the tool registration
|
||||
const historyTool = addToolCalls.find(call => call[0].name === 'get_history')?.[0];
|
||||
const historyTool = addToolCalls.find(call => call.name === 'get_history');
|
||||
expect(historyTool).toBeDefined();
|
||||
|
||||
if (!historyTool) {
|
||||
throw new Error('get_history tool not found');
|
||||
}
|
||||
|
||||
// Execute the tool
|
||||
const result = (await historyTool.execute({
|
||||
entity_id: 'light.living_room',
|
||||
start_time: '2024-01-01T00:00:00Z',
|
||||
@@ -491,29 +543,36 @@ describe('Home Assistant MCP Server', () => {
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.history).toEqual(mockHistory);
|
||||
|
||||
// Verify the fetch call
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/history/period/2024-01-01T00:00:00Z?'),
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
Authorization: `Bearer ${TEST_HASS_TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
);
|
||||
// Verify the fetch call was made with correct URL and parameters
|
||||
const calls = mockFetch.mock.calls;
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify query parameters
|
||||
const url = mockFetch.mock.calls[0][0] as string;
|
||||
const queryParams = new URL(url).searchParams;
|
||||
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 firstCall = calls[0];
|
||||
if (!firstCall?.args) {
|
||||
throw new Error('No fetch calls recorded');
|
||||
}
|
||||
|
||||
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 () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
||||
test('should handle fetch errors', async () => {
|
||||
// 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();
|
||||
|
||||
if (!historyTool) {
|
||||
@@ -555,7 +614,7 @@ describe('Home Assistant MCP Server', () => {
|
||||
json: async () => mockScenes
|
||||
} as Response);
|
||||
|
||||
const sceneTool = addToolCalls.find(call => call[0].name === 'scene')?.[0];
|
||||
const sceneTool = addToolCalls.find(call => call.name === 'scene');
|
||||
expect(sceneTool).toBeDefined();
|
||||
|
||||
if (!sceneTool) {
|
||||
@@ -587,7 +646,7 @@ describe('Home Assistant MCP Server', () => {
|
||||
json: async () => ({})
|
||||
} as Response);
|
||||
|
||||
const sceneTool = addToolCalls.find(call => call[0].name === 'scene')?.[0];
|
||||
const sceneTool = addToolCalls.find(call => call.name === 'scene');
|
||||
expect(sceneTool).toBeDefined();
|
||||
|
||||
if (!sceneTool) {
|
||||
@@ -603,11 +662,11 @@ describe('Home Assistant MCP Server', () => {
|
||||
expect(result.message).toBe('Successfully activated scene scene.movie_time');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
`${TEST_HASS_HOST}/api/services/scene/turn_on`,
|
||||
`${TEST_CONFIG.HASS_HOST}/api/services/scene/turn_on`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${TEST_HASS_TOKEN}`,
|
||||
Authorization: `Bearer ${TEST_CONFIG.HASS_TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
@@ -625,7 +684,7 @@ describe('Home Assistant MCP Server', () => {
|
||||
json: async () => ({})
|
||||
} as Response);
|
||||
|
||||
const notifyTool = addToolCalls.find(call => call[0].name === 'notify')?.[0];
|
||||
const notifyTool = addToolCalls.find(call => call.name === 'notify');
|
||||
expect(notifyTool).toBeDefined();
|
||||
|
||||
if (!notifyTool) {
|
||||
@@ -643,11 +702,11 @@ describe('Home Assistant MCP Server', () => {
|
||||
expect(result.message).toBe('Notification sent successfully');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
`${TEST_HASS_HOST}/api/services/notify/mobile_app_phone`,
|
||||
`${TEST_CONFIG.HASS_HOST}/api/services/notify/mobile_app_phone`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${TEST_HASS_TOKEN}`,
|
||||
Authorization: `Bearer ${TEST_CONFIG.HASS_TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
@@ -660,12 +719,13 @@ describe('Home Assistant MCP Server', () => {
|
||||
});
|
||||
|
||||
it('should use default notification service when no target is specified', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({})
|
||||
} as Response);
|
||||
// Setup response for this test
|
||||
mockFetch = mock(() => Promise.resolve(new Response(
|
||||
JSON.stringify({})
|
||||
)));
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
const notifyTool = addToolCalls.find(call => call[0].name === 'notify')?.[0];
|
||||
const notifyTool = addToolCalls.find(call => call.name === 'notify');
|
||||
expect(notifyTool).toBeDefined();
|
||||
|
||||
if (!notifyTool) {
|
||||
@@ -676,10 +736,11 @@ describe('Home Assistant MCP Server', () => {
|
||||
message: 'Test notification'
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
`${TEST_HASS_HOST}/api/services/notify/notify`,
|
||||
expect.any(Object)
|
||||
);
|
||||
const calls = mockFetch.mock.calls;
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
|
||||
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
|
||||
} as Response);
|
||||
|
||||
const automationTool = addToolCalls.find(call => call[0].name === 'automation')?.[0];
|
||||
const automationTool = addToolCalls.find(call => call.name === 'automation');
|
||||
expect(automationTool).toBeDefined();
|
||||
|
||||
if (!automationTool) {
|
||||
@@ -743,7 +804,7 @@ describe('Home Assistant MCP Server', () => {
|
||||
json: async () => ({})
|
||||
} as Response);
|
||||
|
||||
const automationTool = addToolCalls.find(call => call[0].name === 'automation')?.[0];
|
||||
const automationTool = addToolCalls.find(call => call.name === 'automation');
|
||||
expect(automationTool).toBeDefined();
|
||||
|
||||
if (!automationTool) {
|
||||
@@ -759,11 +820,11 @@ describe('Home Assistant MCP Server', () => {
|
||||
expect(result.message).toBe('Successfully toggled automation automation.morning_routine');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
`${TEST_HASS_HOST}/api/services/automation/toggle`,
|
||||
`${TEST_CONFIG.HASS_HOST}/api/services/automation/toggle`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${TEST_HASS_TOKEN}`,
|
||||
Authorization: `Bearer ${TEST_CONFIG.HASS_TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
@@ -779,7 +840,7 @@ describe('Home Assistant MCP Server', () => {
|
||||
json: async () => ({})
|
||||
} as Response);
|
||||
|
||||
const automationTool = addToolCalls.find(call => call[0].name === 'automation')?.[0];
|
||||
const automationTool = addToolCalls.find(call => call.name === 'automation');
|
||||
expect(automationTool).toBeDefined();
|
||||
|
||||
if (!automationTool) {
|
||||
@@ -795,11 +856,11 @@ describe('Home Assistant MCP Server', () => {
|
||||
expect(result.message).toBe('Successfully triggered automation automation.morning_routine');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
`${TEST_HASS_HOST}/api/services/automation/trigger`,
|
||||
`${TEST_CONFIG.HASS_HOST}/api/services/automation/trigger`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${TEST_HASS_TOKEN}`,
|
||||
Authorization: `Bearer ${TEST_CONFIG.HASS_TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
@@ -810,7 +871,7 @@ describe('Home Assistant MCP Server', () => {
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
if (!automationTool) {
|
||||
@@ -858,7 +919,7 @@ describe('Home Assistant MCP Server', () => {
|
||||
json: async () => mockAddons
|
||||
} as Response);
|
||||
|
||||
const addonTool = addToolCalls.find(call => call[0].name === 'addon')?.[0];
|
||||
const addonTool = addToolCalls.find(call => call.name === 'addon');
|
||||
expect(addonTool).toBeDefined();
|
||||
|
||||
if (!addonTool) {
|
||||
@@ -879,7 +940,7 @@ describe('Home Assistant MCP Server', () => {
|
||||
json: async () => ({ data: { state: 'installing' } })
|
||||
} as Response);
|
||||
|
||||
const addonTool = addToolCalls.find(call => call[0].name === 'addon')?.[0];
|
||||
const addonTool = addToolCalls.find(call => call.name === 'addon');
|
||||
expect(addonTool).toBeDefined();
|
||||
|
||||
if (!addonTool) {
|
||||
@@ -896,11 +957,11 @@ describe('Home Assistant MCP Server', () => {
|
||||
expect(result.message).toBe('Successfully installed add-on core_configurator');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
`${TEST_HASS_HOST}/api/hassio/addons/core_configurator/install`,
|
||||
`${TEST_CONFIG.HASS_HOST}/api/hassio/addons/core_configurator/install`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${TEST_HASS_TOKEN}`,
|
||||
Authorization: `Bearer ${TEST_CONFIG.HASS_TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ version: '5.6.0' })
|
||||
@@ -931,7 +992,7 @@ describe('Home Assistant MCP Server', () => {
|
||||
json: async () => mockPackages
|
||||
} as Response);
|
||||
|
||||
const packageTool = addToolCalls.find(call => call[0].name === 'package')?.[0];
|
||||
const packageTool = addToolCalls.find(call => call.name === 'package');
|
||||
expect(packageTool).toBeDefined();
|
||||
|
||||
if (!packageTool) {
|
||||
@@ -953,7 +1014,7 @@ describe('Home Assistant MCP Server', () => {
|
||||
json: async () => ({})
|
||||
} as Response);
|
||||
|
||||
const packageTool = addToolCalls.find(call => call[0].name === 'package')?.[0];
|
||||
const packageTool = addToolCalls.find(call => call.name === 'package');
|
||||
expect(packageTool).toBeDefined();
|
||||
|
||||
if (!packageTool) {
|
||||
@@ -971,11 +1032,11 @@ describe('Home Assistant MCP Server', () => {
|
||||
expect(result.message).toBe('Successfully installed package hacs/integration');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
`${TEST_HASS_HOST}/api/hacs/repository/install`,
|
||||
`${TEST_CONFIG.HASS_HOST}/api/hacs/repository/install`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${TEST_HASS_TOKEN}`,
|
||||
Authorization: `Bearer ${TEST_CONFIG.HASS_TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
@@ -1016,7 +1077,7 @@ describe('Home Assistant MCP Server', () => {
|
||||
json: async () => ({ automation_id: 'new_automation_1' })
|
||||
} 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();
|
||||
|
||||
if (!automationConfigTool) {
|
||||
@@ -1033,11 +1094,11 @@ describe('Home Assistant MCP Server', () => {
|
||||
expect(result.automation_id).toBe('new_automation_1');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
`${TEST_HASS_HOST}/api/config/automation/config`,
|
||||
`${TEST_CONFIG.HASS_HOST}/api/config/automation/config`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${TEST_HASS_TOKEN}`,
|
||||
Authorization: `Bearer ${TEST_CONFIG.HASS_TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(mockAutomationConfig)
|
||||
@@ -1058,7 +1119,7 @@ describe('Home Assistant MCP Server', () => {
|
||||
json: async () => ({ automation_id: 'new_automation_2' })
|
||||
} 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();
|
||||
|
||||
if (!automationConfigTool) {
|
||||
@@ -1076,17 +1137,17 @@ describe('Home Assistant MCP Server', () => {
|
||||
|
||||
// Verify both API calls
|
||||
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)
|
||||
);
|
||||
|
||||
const duplicateConfig = { ...mockAutomationConfig, alias: 'Test Automation (Copy)' };
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
`${TEST_HASS_HOST}/api/config/automation/config`,
|
||||
`${TEST_CONFIG.HASS_HOST}/api/config/automation/config`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${TEST_HASS_TOKEN}`,
|
||||
Authorization: `Bearer ${TEST_CONFIG.HASS_TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(duplicateConfig)
|
||||
@@ -1095,7 +1156,7 @@ describe('Home Assistant MCP Server', () => {
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
if (!automationConfigTool) {
|
||||
|
||||
@@ -1,61 +1,81 @@
|
||||
import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals';
|
||||
import express from 'express';
|
||||
import { LiteMCP } from 'litemcp';
|
||||
import { logger } from '../src/utils/logger.js';
|
||||
import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test";
|
||||
import type { Mock } from "bun:test";
|
||||
import type { Express, Application } from 'express';
|
||||
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
|
||||
jest.mock('express', () => {
|
||||
const mockApp = {
|
||||
use: jest.fn(),
|
||||
listen: jest.fn((port: number, callback: () => void) => {
|
||||
callback();
|
||||
return { close: jest.fn() };
|
||||
})
|
||||
};
|
||||
return jest.fn(() => mockApp);
|
||||
});
|
||||
const mockApp: MockApp = {
|
||||
use: mock(() => undefined),
|
||||
listen: mock((port: number, callback: () => void) => {
|
||||
callback();
|
||||
return { close: mock(() => undefined) };
|
||||
})
|
||||
};
|
||||
const mockExpress = mock(() => mockApp);
|
||||
|
||||
// Mock LiteMCP
|
||||
jest.mock('litemcp', () => ({
|
||||
LiteMCP: jest.fn(() => ({
|
||||
addTool: jest.fn(),
|
||||
start: jest.fn().mockImplementation(async () => { })
|
||||
}))
|
||||
}));
|
||||
// Mock LiteMCP instance
|
||||
const mockLiteMCPInstance: MockLiteMCPInstance = {
|
||||
addTool: mock(() => undefined),
|
||||
start: mock(() => Promise.resolve())
|
||||
};
|
||||
const mockLiteMCP = mock((name: string, version: string) => mockLiteMCPInstance);
|
||||
|
||||
// Mock logger
|
||||
jest.mock('../src/utils/logger.js', () => ({
|
||||
logger: {
|
||||
info: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn()
|
||||
}
|
||||
}));
|
||||
const mockLogger: MockLogger = {
|
||||
info: mock((message: string) => undefined),
|
||||
error: mock((message: string) => undefined),
|
||||
debug: mock((message: string) => undefined)
|
||||
};
|
||||
|
||||
describe('Server Initialization', () => {
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
let mockApp: ReturnType<typeof express>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Store original environment
|
||||
originalEnv = { ...process.env };
|
||||
|
||||
// Reset all mocks
|
||||
jest.clearAllMocks();
|
||||
// Setup mocks
|
||||
(globalThis as any).express = mockExpress;
|
||||
(globalThis as any).LiteMCP = mockLiteMCP;
|
||||
(globalThis as any).logger = mockLogger;
|
||||
|
||||
// Get the mock express app
|
||||
mockApp = express();
|
||||
// Reset all mocks
|
||||
mockApp.use.mockReset();
|
||||
mockApp.listen.mockReset();
|
||||
mockLogger.info.mockReset();
|
||||
mockLogger.error.mockReset();
|
||||
mockLogger.debug.mockReset();
|
||||
mockLiteMCP.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original environment
|
||||
process.env = originalEnv;
|
||||
|
||||
// Clear module cache to ensure fresh imports
|
||||
jest.resetModules();
|
||||
// Clean up mocks
|
||||
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
|
||||
process.env.PROCESSOR_TYPE = 'openai';
|
||||
|
||||
@@ -63,13 +83,15 @@ describe('Server Initialization', () => {
|
||||
await import('../src/index.js');
|
||||
|
||||
// Verify Express server was initialized
|
||||
expect(express).toHaveBeenCalled();
|
||||
expect(mockApp.use).toHaveBeenCalled();
|
||||
expect(mockApp.listen).toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Server is running on port'));
|
||||
expect(mockExpress.mock.calls.length).toBeGreaterThan(0);
|
||||
expect(mockApp.use.mock.calls.length).toBeGreaterThan(0);
|
||||
expect(mockApp.listen.mock.calls.length).toBeGreaterThan(0);
|
||||
|
||||
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
|
||||
process.env.PROCESSOR_TYPE = 'claude';
|
||||
|
||||
@@ -77,28 +99,38 @@ describe('Server Initialization', () => {
|
||||
await import('../src/index.js');
|
||||
|
||||
// Verify Express server was not initialized
|
||||
expect(express).not.toHaveBeenCalled();
|
||||
expect(mockApp.use).not.toHaveBeenCalled();
|
||||
expect(mockApp.listen).not.toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenCalledWith('Running in Claude mode - Express server disabled');
|
||||
expect(mockExpress.mock.calls.length).toBe(0);
|
||||
expect(mockApp.use.mock.calls.length).toBe(0);
|
||||
expect(mockApp.listen.mock.calls.length).toBe(0);
|
||||
|
||||
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
|
||||
process.env.PROCESSOR_TYPE = 'openai';
|
||||
await import('../src/index.js');
|
||||
expect(LiteMCP).toHaveBeenCalledWith('home-assistant', expect.any(String));
|
||||
|
||||
// Reset modules
|
||||
jest.resetModules();
|
||||
expect(mockLiteMCP.mock.calls.length).toBeGreaterThan(0);
|
||||
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
|
||||
process.env.PROCESSOR_TYPE = 'claude';
|
||||
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
|
||||
delete process.env.PROCESSOR_TYPE;
|
||||
|
||||
@@ -106,9 +138,11 @@ describe('Server Initialization', () => {
|
||||
await import('../src/index.js');
|
||||
|
||||
// Verify Express server was initialized (default behavior)
|
||||
expect(express).toHaveBeenCalled();
|
||||
expect(mockApp.use).toHaveBeenCalled();
|
||||
expect(mockApp.listen).toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Server is running on port'));
|
||||
expect(mockExpress.mock.calls.length).toBeGreaterThan(0);
|
||||
expect(mockApp.use.mock.calls.length).toBeGreaterThan(0);
|
||||
expect(mockApp.listen.mock.calls.length).toBeGreaterThan(0);
|
||||
|
||||
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;
|
||||
};
|
||||
Reference in New Issue
Block a user