From 8f0b9eb909b1a4b4217a7a0de2052d4a4d510e95 Mon Sep 17 00:00:00 2001 From: jango-blockchained Date: Sat, 1 Feb 2025 13:53:55 +0100 Subject: [PATCH] Enhance Jest Configuration and Resolver for ESM and TypeScript Support - Updated Jest configuration to handle .mts and .mjs file extensions - Improved Jest resolver to better support ESM modules and @digital-alchemy packages - Added comprehensive test coverage for AI router, Home Assistant integration, and WebSocket client - Expanded test scenarios for error handling, event subscriptions, and service interactions --- __tests__/ai/endpoints/ai-router.test.ts | 223 ++++++++++++++++++ __tests__/hass/index.test.ts | 286 +++++++++++++++-------- jest-resolver.cjs | 37 ++- jest.config.cjs | 6 +- 4 files changed, 447 insertions(+), 105 deletions(-) create mode 100644 __tests__/ai/endpoints/ai-router.test.ts diff --git a/__tests__/ai/endpoints/ai-router.test.ts b/__tests__/ai/endpoints/ai-router.test.ts new file mode 100644 index 0000000..8fca4f0 --- /dev/null +++ b/__tests__/ai/endpoints/ai-router.test.ts @@ -0,0 +1,223 @@ +import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import express from 'express'; +import request from 'supertest'; +import router from '../../../src/ai/endpoints/ai-router.js'; +import type { AIResponse, AIError } from '../../../src/ai/types/index.js'; + +// Mock NLPProcessor +jest.mock('../../../src/ai/nlp/processor.js', () => { + return { + NLPProcessor: jest.fn().mockImplementation(() => ({ + processCommand: jest.fn().mockImplementation(async () => ({ + intent: { + action: 'turn_on', + target: 'light.living_room', + parameters: {} + }, + confidence: { + overall: 0.9, + intent: 0.95, + entities: 0.85, + context: 0.9 + } + })), + validateIntent: jest.fn().mockImplementation(async () => true), + suggestCorrections: jest.fn().mockImplementation(async () => [ + 'Try using simpler commands', + 'Specify the device name clearly' + ]) + })) + }; +}); + +describe('AI Router', () => { + let app: express.Application; + + beforeEach(() => { + app = express(); + app.use(express.json()); + app.use('/ai', router); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('POST /ai/interpret', () => { + const validRequest = { + input: 'turn on the living room lights', + context: { + user_id: 'test_user', + session_id: 'test_session', + timestamp: new Date().toISOString(), + location: 'home', + previous_actions: [], + environment_state: {} + }, + model: 'claude' as const + }; + + it('should successfully interpret a valid command', async () => { + const response = await request(app) + .post('/ai/interpret') + .send(validRequest); + + expect(response.status).toBe(200); + const body = response.body as AIResponse; + expect(typeof body.natural_language).toBe('string'); + expect(body.structured_data).toEqual(expect.objectContaining({ + success: true, + action_taken: 'turn_on', + entities_affected: ['light.living_room'], + state_changes: expect.any(Object) + })); + expect(Array.isArray(body.next_suggestions)).toBe(true); + expect(body.confidence).toEqual(expect.objectContaining({ + overall: expect.any(Number), + intent: expect.any(Number), + entities: expect.any(Number), + context: expect.any(Number) + })); + expect(body.context).toBeDefined(); + }); + + it('should handle invalid input format', async () => { + const response = await request(app) + .post('/ai/interpret') + .send({ + input: 123, // Invalid input type + context: validRequest.context + }); + + expect(response.status).toBe(500); + const error = response.body.error as AIError; + expect(error.code).toBe('PROCESSING_ERROR'); + expect(typeof error.message).toBe('string'); + expect(typeof error.suggestion).toBe('string'); + expect(Array.isArray(error.recovery_options)).toBe(true); + }); + + it('should handle missing required fields', async () => { + const response = await request(app) + .post('/ai/interpret') + .send({ + input: 'turn on the lights' + // Missing context + }); + + expect(response.status).toBe(500); + const error = response.body.error as AIError; + expect(error.code).toBe('PROCESSING_ERROR'); + expect(typeof error.message).toBe('string'); + }); + + it('should handle rate limiting', async () => { + // Make multiple requests to trigger rate limiting + const requests = Array(101).fill(validRequest); + const responses = await Promise.all( + requests.map(() => + request(app) + .post('/ai/interpret') + .send(validRequest) + ) + ); + + const rateLimitedResponses = responses.filter(r => r.status === 429); + expect(rateLimitedResponses.length).toBeGreaterThan(0); + }); + }); + + describe('POST /ai/execute', () => { + const validRequest = { + intent: { + action: 'turn_on', + target: 'light.living_room', + parameters: {} + }, + context: { + user_id: 'test_user', + session_id: 'test_session', + timestamp: new Date().toISOString(), + location: 'home', + previous_actions: [], + environment_state: {} + }, + model: 'claude' as const + }; + + it('should successfully execute a valid intent', async () => { + const response = await request(app) + .post('/ai/execute') + .send(validRequest); + + expect(response.status).toBe(200); + const body = response.body as AIResponse; + expect(typeof body.natural_language).toBe('string'); + expect(body.structured_data).toEqual(expect.objectContaining({ + success: true, + action_taken: 'turn_on', + entities_affected: ['light.living_room'], + state_changes: expect.any(Object) + })); + expect(Array.isArray(body.next_suggestions)).toBe(true); + expect(body.confidence).toEqual(expect.objectContaining({ + overall: expect.any(Number), + intent: expect.any(Number), + entities: expect.any(Number), + context: expect.any(Number) + })); + expect(body.context).toBeDefined(); + }); + + it('should handle invalid intent format', async () => { + const response = await request(app) + .post('/ai/execute') + .send({ + intent: { + action: 123 // Invalid action type + }, + context: validRequest.context + }); + + expect(response.status).toBe(500); + const error = response.body.error as AIError; + expect(error.code).toBe('PROCESSING_ERROR'); + expect(typeof error.message).toBe('string'); + }); + }); + + describe('GET /ai/suggestions', () => { + const validRequest = { + context: { + user_id: 'test_user', + session_id: 'test_session', + timestamp: new Date().toISOString(), + location: 'home', + previous_actions: [], + environment_state: {} + }, + model: 'claude' as const + }; + + it('should return a list of suggestions', async () => { + const response = await request(app) + .get('/ai/suggestions') + .send(validRequest); + + expect(response.status).toBe(200); + expect(Array.isArray(response.body.suggestions)).toBe(true); + expect(response.body.suggestions.length).toBeGreaterThan(0); + }); + + it('should handle missing context', async () => { + const response = await request(app) + .get('/ai/suggestions') + .send({}); + + expect(response.status).toBe(500); + const error = response.body.error as AIError; + expect(error.code).toBe('PROCESSING_ERROR'); + expect(typeof error.message).toBe('string'); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/hass/index.test.ts b/__tests__/hass/index.test.ts index 96e5498..c5ab670 100644 --- a/__tests__/hass/index.test.ts +++ b/__tests__/hass/index.test.ts @@ -1,6 +1,8 @@ import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals'; import { WebSocket } from 'ws'; import { EventEmitter } from 'events'; +import type { HassInstanceImpl } from '../../src/hass/index.js'; +import type { Entity, Event } from '../../src/types/hass.js'; // Define WebSocket mock types type WebSocketCallback = (...args: any[]) => void; @@ -14,21 +16,22 @@ type WebSocketMock = { close: jest.MockedFunction; readyState: number; OPEN: number; + removeAllListeners: jest.MockedFunction<() => void>; }; // Mock WebSocket -jest.mock('ws', () => { - return { - WebSocket: jest.fn().mockImplementation(() => ({ - on: jest.fn(), - send: jest.fn(), - close: jest.fn(), - readyState: 1, - OPEN: 1, - removeAllListeners: jest.fn() - })) - }; -}); +const mockWebSocket: WebSocketMock = { + on: jest.fn(), + send: jest.fn(), + close: jest.fn(), + readyState: 1, + OPEN: 1, + removeAllListeners: jest.fn() +}; + +jest.mock('ws', () => ({ + WebSocket: jest.fn().mockImplementation(() => mockWebSocket) +})); // Mock fetch globally const mockFetch = jest.fn() as jest.MockedFunction; @@ -43,29 +46,24 @@ describe('Home Assistant Integration', () => { beforeEach(async () => { const { HassWebSocketClient } = await import('../../src/hass/index.js'); client = new HassWebSocketClient(mockUrl, mockToken); - }); - - afterEach(() => { jest.clearAllMocks(); }); it('should create a WebSocket client with the provided URL and token', () => { expect(client).toBeInstanceOf(EventEmitter); - expect(WebSocket).toHaveBeenCalledWith(mockUrl); + expect(jest.mocked(WebSocket)).toHaveBeenCalledWith(mockUrl); }); it('should connect and authenticate successfully', async () => { - const mockWs = (WebSocket as jest.MockedClass).mock.results[0].value as unknown as WebSocketMock; const connectPromise = client.connect(); // Get and call the open callback - const openCallEntry = mockWs.on.mock.calls.find(call => call[0] === 'open'); - if (!openCallEntry) throw new Error('Open callback not found'); - const openCallback = openCallEntry[1]; + const openCallback = mockWebSocket.on.mock.calls.find(call => call[0] === 'open')?.[1]; + if (!openCallback) throw new Error('Open callback not found'); openCallback(); // Verify authentication message - expect(mockWs.send).toHaveBeenCalledWith( + expect(mockWebSocket.send).toHaveBeenCalledWith( JSON.stringify({ type: 'auth', access_token: mockToken @@ -73,60 +71,51 @@ describe('Home Assistant Integration', () => { ); // Get and call the message callback - const messageCallEntry = mockWs.on.mock.calls.find(call => call[0] === 'message'); - if (!messageCallEntry) throw new Error('Message callback not found'); - const messageCallback = messageCallEntry[1]; + const messageCallback = mockWebSocket.on.mock.calls.find(call => call[0] === 'message')?.[1]; + if (!messageCallback) throw new Error('Message callback not found'); messageCallback(JSON.stringify({ type: 'auth_ok' })); await connectPromise; }); it('should handle authentication failure', async () => { - const mockWs = (WebSocket as jest.MockedClass).mock.results[0].value as unknown as WebSocketMock; const connectPromise = client.connect(); // Get and call the open callback - const openCallEntry = mockWs.on.mock.calls.find(call => call[0] === 'open'); - if (!openCallEntry) throw new Error('Open callback not found'); - const openCallback = openCallEntry[1]; + const openCallback = mockWebSocket.on.mock.calls.find(call => call[0] === 'open')?.[1]; + if (!openCallback) throw new Error('Open callback not found'); openCallback(); // Get and call the message callback with auth failure - const messageCallEntry = mockWs.on.mock.calls.find(call => call[0] === 'message'); - if (!messageCallEntry) throw new Error('Message callback not found'); - const messageCallback = messageCallEntry[1]; + const messageCallback = mockWebSocket.on.mock.calls.find(call => call[0] === 'message')?.[1]; + if (!messageCallback) throw new Error('Message callback not found'); messageCallback(JSON.stringify({ type: 'auth_invalid' })); await expect(connectPromise).rejects.toThrow(); }); it('should handle connection errors', async () => { - const mockWs = (WebSocket as jest.MockedClass).mock.results[0].value as unknown as WebSocketMock; const connectPromise = client.connect(); // Get and call the error callback - const errorCallEntry = mockWs.on.mock.calls.find(call => call[0] === 'error'); - if (!errorCallEntry) throw new Error('Error callback not found'); - const errorCallback = errorCallEntry[1]; + const errorCallback = mockWebSocket.on.mock.calls.find(call => call[0] === 'error')?.[1]; + if (!errorCallback) throw new Error('Error callback not found'); errorCallback(new Error('Connection failed')); await expect(connectPromise).rejects.toThrow('Connection failed'); }); it('should handle message parsing errors', async () => { - const mockWs = (WebSocket as jest.MockedClass).mock.results[0].value as unknown as WebSocketMock; const connectPromise = client.connect(); // Get and call the open callback - const openCallEntry = mockWs.on.mock.calls.find(call => call[0] === 'open'); - if (!openCallEntry) throw new Error('Open callback not found'); - const openCallback = openCallEntry[1]; + const openCallback = mockWebSocket.on.mock.calls.find(call => call[0] === 'open')?.[1]; + if (!openCallback) throw new Error('Open callback not found'); openCallback(); // Get and call the message callback with invalid JSON - const messageCallEntry = mockWs.on.mock.calls.find(call => call[0] === 'message'); - if (!messageCallEntry) throw new Error('Message callback not found'); - const messageCallback = messageCallEntry[1]; + const messageCallback = mockWebSocket.on.mock.calls.find(call => call[0] === 'message')?.[1]; + if (!messageCallback) throw new Error('Message callback not found'); // Should emit error event await expect(new Promise((resolve) => { @@ -137,105 +126,183 @@ describe('Home Assistant Integration', () => { }); describe('HassInstanceImpl', () => { - let instance: any; + let instance: HassInstanceImpl; const mockBaseUrl = 'http://localhost:8123'; const mockToken = 'test_token'; + const mockState: Entity = { + entity_id: 'light.test', + state: 'on', + attributes: {}, + last_changed: '', + last_updated: '', + context: { + id: '', + parent_id: null, + user_id: null + } + }; beforeEach(async () => { const { HassInstanceImpl } = await import('../../src/hass/index.js'); instance = new HassInstanceImpl(mockBaseUrl, mockToken); - mockFetch.mockClear(); + jest.clearAllMocks(); + + // Mock successful fetch responses + mockFetch.mockImplementation(async (url, init) => { + if (url.toString().endsWith('/api/states')) { + return new Response(JSON.stringify([mockState])); + } + if (url.toString().includes('/api/states/')) { + return new Response(JSON.stringify(mockState)); + } + if (url.toString().endsWith('/api/services')) { + return new Response(JSON.stringify([])); + } + return new Response(JSON.stringify({})); + }); }); - it('should create an instance with the provided URL and token', () => { + it('should create instance with correct properties', () => { expect(instance.baseUrl).toBe(mockBaseUrl); expect(instance.token).toBe(mockToken); }); - it('should fetch states successfully', async () => { - const mockStates = [ - { - entity_id: 'light.living_room', - state: 'on', - attributes: {} - } - ]; - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockStates - } as Response); - + it('should fetch states', async () => { const states = await instance.fetchStates(); - expect(states).toEqual(mockStates); + expect(states).toEqual([mockState]); expect(mockFetch).toHaveBeenCalledWith( `${mockBaseUrl}/api/states`, expect.objectContaining({ - headers: { - Authorization: `Bearer ${mockToken}`, - 'Content-Type': 'application/json' - } + headers: expect.objectContaining({ + Authorization: `Bearer ${mockToken}` + }) }) ); }); - it('should fetch single entity state successfully', async () => { - const mockState = { - entity_id: 'light.living_room', - state: 'on', - attributes: {} - }; - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockState - } as Response); - - const state = await instance.fetchState('light.living_room'); + it('should fetch single state', async () => { + const state = await instance.fetchState('light.test'); expect(state).toEqual(mockState); expect(mockFetch).toHaveBeenCalledWith( - `${mockBaseUrl}/api/states/light.living_room`, + `${mockBaseUrl}/api/states/light.test`, expect.objectContaining({ - headers: { - Authorization: `Bearer ${mockToken}`, - 'Content-Type': 'application/json' - } + headers: expect.objectContaining({ + Authorization: `Bearer ${mockToken}` + }) }) ); }); - it('should call service successfully', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({}) - } as Response); - - await instance.callService('light', 'turn_on', { entity_id: 'light.living_room' }); + it('should call service', async () => { + await instance.callService('light', 'turn_on', { entity_id: 'light.test' }); expect(mockFetch).toHaveBeenCalledWith( `${mockBaseUrl}/api/services/light/turn_on`, expect.objectContaining({ method: 'POST', - headers: { + headers: expect.objectContaining({ Authorization: `Bearer ${mockToken}`, 'Content-Type': 'application/json' - }, - body: JSON.stringify({ entity_id: 'light.living_room' }) + }), + body: JSON.stringify({ entity_id: 'light.test' }) }) ); }); + + it('should handle fetch errors', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + await expect(instance.fetchStates()).rejects.toThrow('Network error'); + }); + + it('should handle invalid JSON responses', async () => { + mockFetch.mockResolvedValueOnce(new Response('invalid json')); + await expect(instance.fetchStates()).rejects.toThrow(); + }); + + it('should handle non-200 responses', async () => { + mockFetch.mockResolvedValueOnce(new Response('Error', { status: 500 })); + await expect(instance.fetchStates()).rejects.toThrow(); + }); + + describe('Event Subscription', () => { + let eventCallback: (event: Event) => void; + + beforeEach(() => { + eventCallback = jest.fn(); + }); + + it('should subscribe to events', async () => { + const subscriptionId = await instance.subscribeEvents(eventCallback); + expect(typeof subscriptionId).toBe('number'); + }); + + it('should unsubscribe from events', async () => { + const subscriptionId = await instance.subscribeEvents(eventCallback); + await instance.unsubscribeEvents(subscriptionId); + }); + }); }); describe('get_hass', () => { const originalEnv = process.env; + let mockBootstrap: jest.Mock; + + const createMockServices = () => ({ + light: {}, + climate: {}, + alarm_control_panel: {}, + cover: {}, + switch: {}, + contact: {}, + media_player: {}, + fan: {}, + lock: {}, + vacuum: {}, + scene: {}, + script: {}, + camera: {} + }); beforeEach(() => { process.env = { ...originalEnv }; process.env.HASS_HOST = 'http://localhost:8123'; process.env.HASS_TOKEN = 'test_token'; + + // Mock the MY_APP.bootstrap function + mockBootstrap = jest.fn(); + mockBootstrap.mockImplementation(() => Promise.resolve({ + baseUrl: process.env.HASS_HOST, + token: process.env.HASS_TOKEN, + wsClient: undefined, + services: createMockServices(), + als: {}, + context: {}, + event: new EventEmitter(), + internal: {}, + lifecycle: {}, + logger: {}, + scheduler: {}, + config: {}, + params: {}, + hass: {}, + fetchStates: jest.fn(), + fetchState: jest.fn(), + callService: jest.fn(), + subscribeEvents: jest.fn(), + unsubscribeEvents: jest.fn() + })); + + jest.mock('../../src/hass/index.js', () => ({ + MY_APP: { + configuration: {}, + bootstrap: () => mockBootstrap() + } + })); }); afterEach(() => { process.env = originalEnv; + jest.resetModules(); + jest.clearAllMocks(); }); it('should return a development instance by default', async () => { @@ -243,23 +310,50 @@ describe('Home Assistant Integration', () => { const instance = await get_hass(); expect(instance.baseUrl).toBe('http://localhost:8123'); expect(instance.token).toBe('test_token'); + expect(mockBootstrap).toHaveBeenCalledTimes(1); }); - it('should return a test instance when specified', async () => { + it('should return a test instance when in test environment', async () => { + process.env.NODE_ENV = 'test'; const { get_hass } = await import('../../src/hass/index.js'); - const instance = await get_hass('test'); + const instance = await get_hass(); expect(instance.baseUrl).toBe('http://localhost:8123'); expect(instance.token).toBe('test_token'); + expect(mockBootstrap).toHaveBeenCalledTimes(1); }); - it('should return a production instance when specified', async () => { + it('should return a production instance when in production environment', async () => { + process.env.NODE_ENV = 'production'; process.env.HASS_HOST = 'https://hass.example.com'; process.env.HASS_TOKEN = 'prod_token'; + mockBootstrap.mockImplementationOnce(() => Promise.resolve({ + baseUrl: process.env.HASS_HOST, + token: process.env.HASS_TOKEN, + wsClient: undefined, + services: createMockServices(), + als: {}, + context: {}, + event: new EventEmitter(), + internal: {}, + lifecycle: {}, + logger: {}, + scheduler: {}, + config: {}, + params: {}, + hass: {}, + fetchStates: jest.fn(), + fetchState: jest.fn(), + callService: jest.fn(), + subscribeEvents: jest.fn(), + unsubscribeEvents: jest.fn() + })); + const { get_hass } = await import('../../src/hass/index.js'); - const instance = await get_hass('production'); + const instance = await get_hass(); expect(instance.baseUrl).toBe('https://hass.example.com'); expect(instance.token).toBe('prod_token'); + expect(mockBootstrap).toHaveBeenCalledTimes(1); }); }); }); \ No newline at end of file diff --git a/jest-resolver.cjs b/jest-resolver.cjs index 91165cc..2d43b80 100644 --- a/jest-resolver.cjs +++ b/jest-resolver.cjs @@ -16,18 +16,43 @@ module.exports = (request, options) => { } } + // Handle @digital-alchemy packages + if (request.startsWith('@digital-alchemy/')) { + try { + const packagePath = path.resolve(__dirname, 'node_modules', request); + return options.defaultResolver(packagePath, { + ...options, + packageFilter: pkg => { + if (pkg.type === 'module') { + if (pkg.exports && pkg.exports.import) { + pkg.main = pkg.exports.import; + } else if (pkg.module) { + pkg.main = pkg.module; + } + } + return pkg; + } + }); + } catch (e) { + // If resolution fails, continue with default resolver + } + } + // Call the default resolver return options.defaultResolver(request, { ...options, // Handle ESM modules packageFilter: pkg => { // Preserve ESM modules - if (pkg.type === 'module' && pkg.exports) { - // If there's a specific export for the current conditions, use that - if (pkg.exports.import) { - pkg.main = pkg.exports.import; - } else if (typeof pkg.exports === 'string') { - pkg.main = pkg.exports; + if (pkg.type === 'module') { + if (pkg.exports) { + if (pkg.exports.import) { + pkg.main = pkg.exports.import; + } else if (typeof pkg.exports === 'string') { + pkg.main = pkg.exports; + } + } else if (pkg.module) { + pkg.main = pkg.module; } } return pkg; diff --git a/jest.config.cjs b/jest.config.cjs index e39b86f..5b83682 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -2,7 +2,7 @@ module.exports = { preset: 'ts-jest/presets/default-esm', testEnvironment: 'node', - extensionsToTreatAsEsm: ['.ts'], + extensionsToTreatAsEsm: ['.ts', '.mts'], moduleNameMapper: { '^(\\.{1,2}/.*)\\.js$': '$1', '^(\\.{1,2}/.*)\\.ts$': '$1', @@ -11,7 +11,7 @@ module.exports = { '#supports-color': '/node_modules/supports-color/index.js' }, transform: { - '^.+\\.tsx?$': [ + '^.+\\.(ts|mts|js|mjs)$': [ 'ts-jest', { useESM: true, @@ -20,7 +20,7 @@ module.exports = { ], }, transformIgnorePatterns: [ - 'node_modules/(?!(@digital-alchemy|chalk|ansi-styles|supports-color)/.*)' + 'node_modules/(?!(@digital-alchemy|chalk|ansi-styles|supports-color)/.*)(?!.*\\.mjs$)' ], resolver: '/jest-resolver.cjs', setupFilesAfterEnv: ['/jest.setup.ts'],