diff --git a/.gitignore b/.gitignore index eaee28a62..f28d152d9 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,5 @@ core/test_results.html coverage .yarn .yarnrc +test_results.html *.tsbuildinfo diff --git a/core/jest.config.js b/core/jest.config.js index 6c805f1c9..2f652dd39 100644 --- a/core/jest.config.js +++ b/core/jest.config.js @@ -1,6 +1,7 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', + collectCoverageFrom: ['src/**/*.{ts,tsx}'], moduleNameMapper: { '@/(.*)': '/src/$1', }, diff --git a/core/src/browser/extension.test.ts b/core/src/browser/extension.test.ts index 6c1cd8579..2db14a24e 100644 --- a/core/src/browser/extension.test.ts +++ b/core/src/browser/extension.test.ts @@ -1,4 +1,9 @@ import { BaseExtension } from './extension' +import { SettingComponentProps } from '../types' +import { getJanDataFolderPath, joinPath } from './core' +import { fs } from './fs' +jest.mock('./core') +jest.mock('./fs') class TestBaseExtension extends BaseExtension { onLoad(): void {} @@ -44,3 +49,103 @@ describe('BaseExtension', () => { // Add your assertions here }) }) + +describe('BaseExtension', () => { + class TestBaseExtension extends BaseExtension { + onLoad(): void {} + onUnload(): void {} + } + + let baseExtension: TestBaseExtension + + beforeEach(() => { + baseExtension = new TestBaseExtension('https://example.com', 'TestExtension') + }) + + afterEach(() => { + jest.resetAllMocks() + }) + + it('should have the correct properties', () => { + expect(baseExtension.name).toBe('TestExtension') + expect(baseExtension.productName).toBeUndefined() + expect(baseExtension.url).toBe('https://example.com') + expect(baseExtension.active).toBeUndefined() + expect(baseExtension.description).toBeUndefined() + expect(baseExtension.version).toBeUndefined() + }) + + it('should return undefined for type()', () => { + expect(baseExtension.type()).toBeUndefined() + }) + + it('should have abstract methods onLoad() and onUnload()', () => { + expect(baseExtension.onLoad).toBeDefined() + expect(baseExtension.onUnload).toBeDefined() + }) + + it('should have installationState() return "NotRequired"', async () => { + const installationState = await baseExtension.installationState() + expect(installationState).toBe('NotRequired') + }) + + it('should install the extension', async () => { + await baseExtension.install() + // Add your assertions here + }) + + it('should register settings', async () => { + const settings: SettingComponentProps[] = [ + { key: 'setting1', controllerProps: { value: 'value1' } } as any, + { key: 'setting2', controllerProps: { value: 'value2' } } as any, + ] + + ;(getJanDataFolderPath as jest.Mock).mockResolvedValue('/data') + ;(joinPath as jest.Mock).mockResolvedValue('/data/settings/TestExtension') + ;(fs.existsSync as jest.Mock).mockResolvedValue(false) + ;(fs.mkdir as jest.Mock).mockResolvedValue(undefined) + ;(fs.writeFileSync as jest.Mock).mockResolvedValue(undefined) + + await baseExtension.registerSettings(settings) + + expect(fs.mkdir).toHaveBeenCalledWith('/data/settings/TestExtension') + expect(fs.writeFileSync).toHaveBeenCalledWith( + '/data/settings/TestExtension', + JSON.stringify(settings, null, 2) + ) + }) + + it('should get setting with default value', async () => { + const settings: SettingComponentProps[] = [ + { key: 'setting1', controllerProps: { value: 'value1' } } as any, + ] + + jest.spyOn(baseExtension, 'getSettings').mockResolvedValue(settings) + + const value = await baseExtension.getSetting('setting1', 'defaultValue') + expect(value).toBe('value1') + + const defaultValue = await baseExtension.getSetting('setting2', 'defaultValue') + expect(defaultValue).toBe('defaultValue') + }) + + it('should update settings', async () => { + const settings: SettingComponentProps[] = [ + { key: 'setting1', controllerProps: { value: 'value1' } } as any, + ] + + jest.spyOn(baseExtension, 'getSettings').mockResolvedValue(settings) + ;(getJanDataFolderPath as jest.Mock).mockResolvedValue('/data') + ;(joinPath as jest.Mock).mockResolvedValue('/data/settings/TestExtension/settings.json') + ;(fs.writeFileSync as jest.Mock).mockResolvedValue(undefined) + + await baseExtension.updateSettings([ + { key: 'setting1', controllerProps: { value: 'newValue' } } as any, + ]) + + expect(fs.writeFileSync).toHaveBeenCalledWith( + '/data/settings/TestExtension/settings.json', + JSON.stringify([{ key: 'setting1', controllerProps: { value: 'newValue' } }], null, 2) + ) + }) +}) diff --git a/core/src/browser/extensions/engines/AIEngine.test.ts b/core/src/browser/extensions/engines/AIEngine.test.ts new file mode 100644 index 000000000..59dad280f --- /dev/null +++ b/core/src/browser/extensions/engines/AIEngine.test.ts @@ -0,0 +1,59 @@ +import { AIEngine } from './AIEngine' +import { events } from '../../events' +import { ModelEvent, Model, ModelFile, InferenceEngine } from '../../../types' +import { EngineManager } from './EngineManager' +import { fs } from '../../fs' + +jest.mock('../../events') +jest.mock('./EngineManager') +jest.mock('../../fs') + +class TestAIEngine extends AIEngine { + onUnload(): void {} + provider = 'test-provider' + + inference(data: any) {} + + stopInference() {} +} + +describe('AIEngine', () => { + let engine: TestAIEngine + + beforeEach(() => { + engine = new TestAIEngine('', '') + jest.clearAllMocks() + }) + + it('should load model if provider matches', async () => { + const model: ModelFile = { id: 'model1', engine: 'test-provider' } as any + + await engine.loadModel(model) + + expect(events.emit).toHaveBeenCalledWith(ModelEvent.OnModelReady, model) + }) + + it('should not load model if provider does not match', async () => { + const model: ModelFile = { id: 'model1', engine: 'other-provider' } as any + + await engine.loadModel(model) + + expect(events.emit).not.toHaveBeenCalledWith(ModelEvent.OnModelReady, model) + }) + + it('should unload model if provider matches', async () => { + const model: Model = { id: 'model1', version: '1.0', engine: 'test-provider' } as any + + await engine.unloadModel(model) + + expect(events.emit).toHaveBeenCalledWith(ModelEvent.OnModelStopped, model) + }) + + it('should not unload model if provider does not match', async () => { + const model: Model = { id: 'model1', version: '1.0', engine: 'other-provider' } as any + + await engine.unloadModel(model) + + expect(events.emit).not.toHaveBeenCalledWith(ModelEvent.OnModelStopped, model) + }) +}) diff --git a/core/src/browser/extensions/engines/EngineManager.test.ts b/core/src/browser/extensions/engines/EngineManager.test.ts new file mode 100644 index 000000000..c1f1fcb71 --- /dev/null +++ b/core/src/browser/extensions/engines/EngineManager.test.ts @@ -0,0 +1,43 @@ +/** + * @jest-environment jsdom + */ +import { EngineManager } from './EngineManager' +import { AIEngine } from './AIEngine' + +// @ts-ignore +class MockAIEngine implements AIEngine { + provider: string + constructor(provider: string) { + this.provider = provider + } +} + +describe('EngineManager', () => { + let engineManager: EngineManager + + beforeEach(() => { + engineManager = new EngineManager() + }) + + test('should register an engine', () => { + const engine = new MockAIEngine('testProvider') + // @ts-ignore + engineManager.register(engine) + expect(engineManager.engines.get('testProvider')).toBe(engine) + }) + + test('should retrieve a registered engine by provider', () => { + const engine = new MockAIEngine('testProvider') + // @ts-ignore + engineManager.register(engine) + // @ts-ignore + const retrievedEngine = engineManager.get('testProvider') + expect(retrievedEngine).toBe(engine) + }) + + test('should return undefined for an unregistered provider', () => { + // @ts-ignore + const retrievedEngine = engineManager.get('nonExistentProvider') + expect(retrievedEngine).toBeUndefined() + }) +}) diff --git a/core/src/browser/extensions/engines/LocalOAIEngine.test.ts b/core/src/browser/extensions/engines/LocalOAIEngine.test.ts new file mode 100644 index 000000000..4ae81496f --- /dev/null +++ b/core/src/browser/extensions/engines/LocalOAIEngine.test.ts @@ -0,0 +1,100 @@ +/** + * @jest-environment jsdom + */ +import { LocalOAIEngine } from './LocalOAIEngine' +import { events } from '../../events' +import { ModelEvent, ModelFile, Model } from '../../../types' +import { executeOnMain, systemInformation, dirName } from '../../core' + +jest.mock('../../core', () => ({ + executeOnMain: jest.fn(), + systemInformation: jest.fn(), + dirName: jest.fn(), +})) + +jest.mock('../../events', () => ({ + events: { + on: jest.fn(), + emit: jest.fn(), + }, +})) + +class TestLocalOAIEngine extends LocalOAIEngine { + inferenceUrl = '' + nodeModule = 'testNodeModule' + provider = 'testProvider' +} + +describe('LocalOAIEngine', () => { + let engine: TestLocalOAIEngine + + beforeEach(() => { + engine = new TestLocalOAIEngine('', '') + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('should subscribe to events on load', () => { + engine.onLoad() + expect(events.on).toHaveBeenCalledWith(ModelEvent.OnModelInit, expect.any(Function)) + expect(events.on).toHaveBeenCalledWith(ModelEvent.OnModelStop, expect.any(Function)) + }) + + it('should load model correctly', async () => { + const model: ModelFile = { engine: 'testProvider', file_path: 'path/to/model' } as any + const modelFolder = 'path/to' + const systemInfo = { os: 'testOS' } + const res = { error: null } + + ;(dirName as jest.Mock).mockResolvedValue(modelFolder) + ;(systemInformation as jest.Mock).mockResolvedValue(systemInfo) + ;(executeOnMain as jest.Mock).mockResolvedValue(res) + + await engine.loadModel(model) + + expect(dirName).toHaveBeenCalledWith(model.file_path) + expect(systemInformation).toHaveBeenCalled() + expect(executeOnMain).toHaveBeenCalledWith( + engine.nodeModule, + engine.loadModelFunctionName, + { modelFolder, model }, + systemInfo + ) + expect(events.emit).toHaveBeenCalledWith(ModelEvent.OnModelReady, model) + }) + + it('should handle load model error', async () => { + const model: ModelFile = { engine: 'testProvider', file_path: 'path/to/model' } as any + const modelFolder = 'path/to' + const systemInfo = { os: 'testOS' } + const res = { error: 'load error' } + + ;(dirName as jest.Mock).mockResolvedValue(modelFolder) + ;(systemInformation as jest.Mock).mockResolvedValue(systemInfo) + ;(executeOnMain as jest.Mock).mockResolvedValue(res) + + await expect(engine.loadModel(model)).rejects.toEqual('load error') + + expect(events.emit).toHaveBeenCalledWith(ModelEvent.OnModelFail, { error: res.error }) + }) + + it('should unload model correctly', async () => { + const model: Model = { engine: 'testProvider' } as any + + await engine.unloadModel(model) + + expect(executeOnMain).toHaveBeenCalledWith(engine.nodeModule, engine.unloadModelFunctionName) + expect(events.emit).toHaveBeenCalledWith(ModelEvent.OnModelStopped, {}) + }) + + it('should not unload model if engine does not match', async () => { + const model: Model = { engine: 'otherProvider' } as any + + await engine.unloadModel(model) + + expect(executeOnMain).not.toHaveBeenCalled() + expect(events.emit).not.toHaveBeenCalledWith(ModelEvent.OnModelStopped, {}) + }) +}) diff --git a/core/src/browser/extensions/engines/OAIEngine.test.ts b/core/src/browser/extensions/engines/OAIEngine.test.ts new file mode 100644 index 000000000..81348786c --- /dev/null +++ b/core/src/browser/extensions/engines/OAIEngine.test.ts @@ -0,0 +1,119 @@ +/** + * @jest-environment jsdom + */ +import { OAIEngine } from './OAIEngine' +import { events } from '../../events' +import { + MessageEvent, + InferenceEvent, + MessageRequest, + MessageRequestType, + MessageStatus, + ChatCompletionRole, + ContentType, +} from '../../../types' +import { requestInference } from './helpers/sse' +import { ulid } from 'ulidx' + +jest.mock('./helpers/sse') +jest.mock('ulidx') +jest.mock('../../events') + +class TestOAIEngine extends OAIEngine { + inferenceUrl = 'http://test-inference-url' + provider = 'test-provider' + + async headers() { + return { Authorization: 'Bearer test-token' } + } +} + +describe('OAIEngine', () => { + let engine: TestOAIEngine + + beforeEach(() => { + engine = new TestOAIEngine('', '') + jest.clearAllMocks() + }) + + it('should subscribe to events on load', () => { + engine.onLoad() + expect(events.on).toHaveBeenCalledWith(MessageEvent.OnMessageSent, expect.any(Function)) + expect(events.on).toHaveBeenCalledWith(InferenceEvent.OnInferenceStopped, expect.any(Function)) + }) + + it('should handle inference request', async () => { + const data: MessageRequest = { + model: { engine: 'test-provider', id: 'test-model' } as any, + threadId: 'test-thread', + type: MessageRequestType.Thread, + assistantId: 'test-assistant', + messages: [{ role: ChatCompletionRole.User, content: 'Hello' }], + } + + ;(ulid as jest.Mock).mockReturnValue('test-id') + ;(requestInference as jest.Mock).mockReturnValue({ + subscribe: ({ next, complete }: any) => { + next('test response') + complete() + }, + }) + + await engine.inference(data) + + expect(requestInference).toHaveBeenCalledWith( + 'http://test-inference-url', + expect.objectContaining({ model: 'test-model' }), + expect.any(Object), + expect.any(AbortController), + { Authorization: 'Bearer test-token' }, + undefined + ) + + expect(events.emit).toHaveBeenCalledWith( + MessageEvent.OnMessageResponse, + expect.objectContaining({ id: 'test-id' }) + ) + expect(events.emit).toHaveBeenCalledWith( + MessageEvent.OnMessageUpdate, + expect.objectContaining({ + content: [{ type: ContentType.Text, text: { value: 'test response', annotations: [] } }], + status: MessageStatus.Ready, + }) + ) + }) + + it('should handle inference error', async () => { + const data: MessageRequest = { + model: { engine: 'test-provider', id: 'test-model' } as any, + threadId: 'test-thread', + type: MessageRequestType.Thread, + assistantId: 'test-assistant', + messages: [{ role: ChatCompletionRole.User, content: 'Hello' }], + } + + ;(ulid as jest.Mock).mockReturnValue('test-id') + ;(requestInference as jest.Mock).mockReturnValue({ + subscribe: ({ error }: any) => { + error({ message: 'test error', code: 500 }) + }, + }) + + await engine.inference(data) + + expect(events.emit).toHaveBeenCalledWith( + MessageEvent.OnMessageUpdate, + expect.objectContaining({ + content: [{ type: ContentType.Text, text: { value: 'test error', annotations: [] } }], + status: MessageStatus.Error, + error_code: 500, + }) + ) + }) + + it('should stop inference', () => { + engine.stopInference() + expect(engine.isCancelled).toBe(true) + expect(engine.controller.signal.aborted).toBe(true) + }) +}) diff --git a/core/src/browser/extensions/engines/RemoteOAIEngine.test.ts b/core/src/browser/extensions/engines/RemoteOAIEngine.test.ts new file mode 100644 index 000000000..871499f45 --- /dev/null +++ b/core/src/browser/extensions/engines/RemoteOAIEngine.test.ts @@ -0,0 +1,43 @@ +/** + * @jest-environment jsdom + */ +import { RemoteOAIEngine } from './' + +class TestRemoteOAIEngine extends RemoteOAIEngine { + inferenceUrl: string = '' + provider: string = 'TestRemoteOAIEngine' +} + +describe('RemoteOAIEngine', () => { + let engine: TestRemoteOAIEngine + + beforeEach(() => { + engine = new TestRemoteOAIEngine('', '') + }) + + test('should call onLoad and super.onLoad', () => { + const onLoadSpy = jest.spyOn(engine, 'onLoad') + const superOnLoadSpy = jest.spyOn(Object.getPrototypeOf(RemoteOAIEngine.prototype), 'onLoad') + engine.onLoad() + + expect(onLoadSpy).toHaveBeenCalled() + expect(superOnLoadSpy).toHaveBeenCalled() + }) + + test('should return headers with apiKey', async () => { + engine.apiKey = 'test-api-key' + const headers = await engine.headers() + + expect(headers).toEqual({ + 'Authorization': 'Bearer test-api-key', + 'api-key': 'test-api-key', + }) + }) + + test('should return empty headers when apiKey is not set', async () => { + engine.apiKey = undefined + const headers = await engine.headers() + + expect(headers).toEqual({}) + }) +}) diff --git a/core/src/browser/extensions/engines/helpers/sse.test.ts b/core/src/browser/extensions/engines/helpers/sse.test.ts index cff5b93b3..0b78aa9b5 100644 --- a/core/src/browser/extensions/engines/helpers/sse.test.ts +++ b/core/src/browser/extensions/engines/helpers/sse.test.ts @@ -1,6 +1,7 @@ import { lastValueFrom, Observable } from 'rxjs' import { requestInference } from './sse' +import { ReadableStream } from 'stream/web'; describe('requestInference', () => { it('should send a request to the inference server and return an Observable', () => { // Mock the fetch function @@ -58,3 +59,66 @@ describe('requestInference', () => { expect(lastValueFrom(result)).rejects.toEqual({ message: 'Wrong API Key', code: 'invalid_api_key' }) }) }) + + it('should handle a successful response with a transformResponse function', () => { + // Mock the fetch function + const mockFetch: any = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ choices: [{ message: { content: 'Generated response' } }] }), + headers: new Headers(), + redirected: false, + status: 200, + statusText: 'OK', + }) + ) + jest.spyOn(global, 'fetch').mockImplementation(mockFetch) + + // Define the test inputs + const inferenceUrl = 'https://inference-server.com' + const requestBody = { message: 'Hello' } + const model = { id: 'model-id', parameters: { stream: false } } + const transformResponse = (data: any) => data.choices[0].message.content.toUpperCase() + + // Call the function + const result = requestInference(inferenceUrl, requestBody, model, undefined, undefined, transformResponse) + + // Assert the expected behavior + expect(result).toBeInstanceOf(Observable) + expect(lastValueFrom(result)).resolves.toEqual('GENERATED RESPONSE') + }) + + + it('should handle a successful response with streaming enabled', () => { + // Mock the fetch function + const mockFetch: any = jest.fn(() => + Promise.resolve({ + ok: true, + body: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('data: {"choices": [{"delta": {"content": "Streamed"}}]}')); + controller.enqueue(new TextEncoder().encode('data: [DONE]')); + controller.close(); + } + }), + headers: new Headers(), + redirected: false, + status: 200, + statusText: 'OK', + }) + ); + jest.spyOn(global, 'fetch').mockImplementation(mockFetch); + + // Define the test inputs + const inferenceUrl = 'https://inference-server.com'; + const requestBody = { message: 'Hello' }; + const model = { id: 'model-id', parameters: { stream: true } }; + + // Call the function + const result = requestInference(inferenceUrl, requestBody, model); + + // Assert the expected behavior + expect(result).toBeInstanceOf(Observable); + expect(lastValueFrom(result)).resolves.toEqual('Streamed'); + }); + diff --git a/core/src/browser/extensions/engines/index.test.ts b/core/src/browser/extensions/engines/index.test.ts new file mode 100644 index 000000000..4c0ef11d8 --- /dev/null +++ b/core/src/browser/extensions/engines/index.test.ts @@ -0,0 +1,6 @@ + +import { expect } from '@jest/globals'; + +it('should re-export all exports from ./AIEngine', () => { + expect(require('./index')).toHaveProperty('AIEngine'); +}); diff --git a/core/src/browser/extensions/index.test.ts b/core/src/browser/extensions/index.test.ts new file mode 100644 index 000000000..26cbda8c5 --- /dev/null +++ b/core/src/browser/extensions/index.test.ts @@ -0,0 +1,32 @@ +import { ConversationalExtension } from './index'; +import { InferenceExtension } from './index'; +import { MonitoringExtension } from './index'; +import { AssistantExtension } from './index'; +import { ModelExtension } from './index'; +import * as Engines from './index'; + +describe('index.ts exports', () => { + test('should export ConversationalExtension', () => { + expect(ConversationalExtension).toBeDefined(); + }); + + test('should export InferenceExtension', () => { + expect(InferenceExtension).toBeDefined(); + }); + + test('should export MonitoringExtension', () => { + expect(MonitoringExtension).toBeDefined(); + }); + + test('should export AssistantExtension', () => { + expect(AssistantExtension).toBeDefined(); + }); + + test('should export ModelExtension', () => { + expect(ModelExtension).toBeDefined(); + }); + + test('should export Engines', () => { + expect(Engines).toBeDefined(); + }); +}); \ No newline at end of file diff --git a/core/src/browser/fs.test.ts b/core/src/browser/fs.test.ts new file mode 100644 index 000000000..21da54874 --- /dev/null +++ b/core/src/browser/fs.test.ts @@ -0,0 +1,97 @@ +import { fs } from './fs' + +describe('fs module', () => { + beforeEach(() => { + globalThis.core = { + api: { + writeFileSync: jest.fn(), + writeBlob: jest.fn(), + readFileSync: jest.fn(), + existsSync: jest.fn(), + readdirSync: jest.fn(), + mkdir: jest.fn(), + rm: jest.fn(), + unlinkSync: jest.fn(), + appendFileSync: jest.fn(), + copyFile: jest.fn(), + getGgufFiles: jest.fn(), + fileStat: jest.fn(), + }, + } + }) + + it('should call writeFileSync with correct arguments', () => { + const args = ['path/to/file', 'data'] + fs.writeFileSync(...args) + expect(globalThis.core.api.writeFileSync).toHaveBeenCalledWith(...args) + }) + + it('should call writeBlob with correct arguments', async () => { + const path = 'path/to/file' + const data = 'blob data' + await fs.writeBlob(path, data) + expect(globalThis.core.api.writeBlob).toHaveBeenCalledWith(path, data) + }) + + it('should call readFileSync with correct arguments', () => { + const args = ['path/to/file'] + fs.readFileSync(...args) + expect(globalThis.core.api.readFileSync).toHaveBeenCalledWith(...args) + }) + + it('should call existsSync with correct arguments', () => { + const args = ['path/to/file'] + fs.existsSync(...args) + expect(globalThis.core.api.existsSync).toHaveBeenCalledWith(...args) + }) + + it('should call readdirSync with correct arguments', () => { + const args = ['path/to/directory'] + fs.readdirSync(...args) + expect(globalThis.core.api.readdirSync).toHaveBeenCalledWith(...args) + }) + + it('should call mkdir with correct arguments', () => { + const args = ['path/to/directory'] + fs.mkdir(...args) + expect(globalThis.core.api.mkdir).toHaveBeenCalledWith(...args) + }) + + it('should call rm with correct arguments', () => { + const args = ['path/to/directory'] + fs.rm(...args) + expect(globalThis.core.api.rm).toHaveBeenCalledWith(...args, { recursive: true, force: true }) + }) + + it('should call unlinkSync with correct arguments', () => { + const args = ['path/to/file'] + fs.unlinkSync(...args) + expect(globalThis.core.api.unlinkSync).toHaveBeenCalledWith(...args) + }) + + it('should call appendFileSync with correct arguments', () => { + const args = ['path/to/file', 'data'] + fs.appendFileSync(...args) + expect(globalThis.core.api.appendFileSync).toHaveBeenCalledWith(...args) + }) + + it('should call copyFile with correct arguments', async () => { + const src = 'path/to/src' + const dest = 'path/to/dest' + await fs.copyFile(src, dest) + expect(globalThis.core.api.copyFile).toHaveBeenCalledWith(src, dest) + }) + + it('should call getGgufFiles with correct arguments', async () => { + const paths = ['path/to/file1', 'path/to/file2'] + await fs.getGgufFiles(paths) + expect(globalThis.core.api.getGgufFiles).toHaveBeenCalledWith(paths) + }) + + it('should call fileStat with correct arguments', async () => { + const path = 'path/to/file' + const outsideJanDataFolder = true + await fs.fileStat(path, outsideJanDataFolder) + expect(globalThis.core.api.fileStat).toHaveBeenCalledWith(path, outsideJanDataFolder) + }) +}) diff --git a/core/src/browser/tools/tool.test.ts b/core/src/browser/tools/tool.test.ts new file mode 100644 index 000000000..ba918a3cb --- /dev/null +++ b/core/src/browser/tools/tool.test.ts @@ -0,0 +1,55 @@ +import { ToolManager } from '../../browser/tools/manager' +import { InferenceTool } from '../../browser/tools/tool' +import { AssistantTool, MessageRequest } from '../../types' + +class MockInferenceTool implements InferenceTool { + name = 'mockTool' + process(request: MessageRequest, tool: AssistantTool): Promise { + return Promise.resolve(request) + } +} + +it('should register a tool', () => { + const manager = new ToolManager() + const tool = new MockInferenceTool() + manager.register(tool) + expect(manager.get(tool.name)).toBe(tool) +}) + +it('should retrieve a tool by its name', () => { + const manager = new ToolManager() + const tool = new MockInferenceTool() + manager.register(tool) + const retrievedTool = manager.get(tool.name) + expect(retrievedTool).toBe(tool) +}) + +it('should return undefined for a non-existent tool', () => { + const manager = new ToolManager() + const retrievedTool = manager.get('nonExistentTool') + expect(retrievedTool).toBeUndefined() +}) + +it('should process the message request with enabled tools', async () => { + const manager = new ToolManager() + const tool = new MockInferenceTool() + manager.register(tool) + + const request: MessageRequest = { message: 'test' } as any + const tools: AssistantTool[] = [{ type: 'mockTool', enabled: true }] as any + + const result = await manager.process(request, tools) + expect(result).toBe(request) +}) + +it('should skip processing for disabled tools', async () => { + const manager = new ToolManager() + const tool = new MockInferenceTool() + manager.register(tool) + + const request: MessageRequest = { message: 'test' } as any + const tools: AssistantTool[] = [{ type: 'mockTool', enabled: false }] as any + + const result = await manager.process(request, tools) + expect(result).toBe(request) +}) \ No newline at end of file diff --git a/core/src/node/api/processors/processor.test.ts b/core/src/node/api/processors/processor.test.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/core/src/node/api/restful/helper/builder.test.ts b/core/src/node/api/restful/helper/builder.test.ts new file mode 100644 index 000000000..fef40c70a --- /dev/null +++ b/core/src/node/api/restful/helper/builder.test.ts @@ -0,0 +1,264 @@ +import { + existsSync, + readdirSync, + readFileSync, + writeFileSync, + mkdirSync, + appendFileSync, + rmdirSync, +} from 'fs' +import { join } from 'path' +import { + getBuilder, + retrieveBuilder, + deleteBuilder, + getMessages, + retrieveMessage, + createThread, + updateThread, + createMessage, + downloadModel, + chatCompletions, +} from './builder' +import { RouteConfiguration } from './configuration' + +jest.mock('fs') +jest.mock('path') +jest.mock('../../../helper', () => ({ + getEngineConfiguration: jest.fn(), + getJanDataFolderPath: jest.fn().mockReturnValue('/mock/path'), +})) +jest.mock('request') +jest.mock('request-progress') +jest.mock('node-fetch') + +describe('builder helper functions', () => { + const mockConfiguration: RouteConfiguration = { + dirName: 'mockDir', + metadataFileName: 'metadata.json', + delete: { + object: 'mockObject', + }, + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('getBuilder', () => { + it('should return an empty array if directory does not exist', async () => { + ;(existsSync as jest.Mock).mockReturnValue(false) + const result = await getBuilder(mockConfiguration) + expect(result).toEqual([]) + }) + + it('should return model data if directory exists', async () => { + ;(existsSync as jest.Mock).mockReturnValue(true) + ;(readdirSync as jest.Mock).mockReturnValue(['file1']) + ;(readFileSync as jest.Mock).mockReturnValue(JSON.stringify({ id: 'model1' })) + + const result = await getBuilder(mockConfiguration) + expect(result).toEqual([{ id: 'model1' }]) + }) + }) + + describe('retrieveBuilder', () => { + it('should return undefined if no data matches the id', async () => { + ;(existsSync as jest.Mock).mockReturnValue(true) + ;(readdirSync as jest.Mock).mockReturnValue(['file1']) + ;(readFileSync as jest.Mock).mockReturnValue(JSON.stringify({ id: 'model1' })) + + const result = await retrieveBuilder(mockConfiguration, 'nonexistentId') + expect(result).toBeUndefined() + }) + + it('should return the matching data', async () => { + ;(existsSync as jest.Mock).mockReturnValue(true) + ;(readdirSync as jest.Mock).mockReturnValue(['file1']) + ;(readFileSync as jest.Mock).mockReturnValue(JSON.stringify({ id: 'model1' })) + + const result = await retrieveBuilder(mockConfiguration, 'model1') + expect(result).toEqual({ id: 'model1' }) + }) + }) + + describe('deleteBuilder', () => { + it('should return a message if trying to delete Jan assistant', async () => { + const result = await deleteBuilder({ ...mockConfiguration, dirName: 'assistants' }, 'jan') + expect(result).toEqual({ message: 'Cannot delete Jan assistant' }) + }) + + it('should return a message if data is not found', async () => { + ;(existsSync as jest.Mock).mockReturnValue(true) + ;(readdirSync as jest.Mock).mockReturnValue(['file1']) + ;(readFileSync as jest.Mock).mockReturnValue(JSON.stringify({ id: 'model1' })) + + const result = await deleteBuilder(mockConfiguration, 'nonexistentId') + expect(result).toEqual({ message: 'Not found' }) + }) + + it('should delete the directory and return success message', async () => { + ;(existsSync as jest.Mock).mockReturnValue(true) + ;(readdirSync as jest.Mock).mockReturnValue(['file1']) + ;(readFileSync as jest.Mock).mockReturnValue(JSON.stringify({ id: 'model1' })) + + const result = await deleteBuilder(mockConfiguration, 'model1') + expect(rmdirSync).toHaveBeenCalledWith(join('/mock/path', 'mockDir', 'model1'), { + recursive: true, + }) + expect(result).toEqual({ id: 'model1', object: 'mockObject', deleted: true }) + }) + }) + + describe('getMessages', () => { + it('should return an empty array if message file does not exist', async () => { + ;(existsSync as jest.Mock).mockReturnValue(false) + + const result = await getMessages('thread1') + expect(result).toEqual([]) + }) + + it('should return messages if message file exists', async () => { + ;(existsSync as jest.Mock).mockReturnValue(true) + ;(readdirSync as jest.Mock).mockReturnValue(['messages.jsonl']) + ;(readFileSync as jest.Mock).mockReturnValue('{"id":"msg1"}\n{"id":"msg2"}\n') + + const result = await getMessages('thread1') + expect(result).toEqual([{ id: 'msg1' }, { id: 'msg2' }]) + }) + }) + + describe('retrieveMessage', () => { + it('should return a message if no messages match the id', async () => { + ;(existsSync as jest.Mock).mockReturnValue(true) + ;(readdirSync as jest.Mock).mockReturnValue(['messages.jsonl']) + ;(readFileSync as jest.Mock).mockReturnValue('{"id":"msg1"}\n') + + const result = await retrieveMessage('thread1', 'nonexistentId') + expect(result).toEqual({ message: 'Not found' }) + }) + + it('should return the matching message', async () => { + ;(existsSync as jest.Mock).mockReturnValue(true) + ;(readdirSync as jest.Mock).mockReturnValue(['messages.jsonl']) + ;(readFileSync as jest.Mock).mockReturnValue('{"id":"msg1"}\n') + + const result = await retrieveMessage('thread1', 'msg1') + expect(result).toEqual({ id: 'msg1' }) + }) + }) + + describe('createThread', () => { + it('should return a message if thread has no assistants', async () => { + const result = await createThread({}) + expect(result).toEqual({ message: 'Thread must have at least one assistant' }) + }) + + it('should create a thread and return the updated thread', async () => { + ;(existsSync as jest.Mock).mockReturnValue(false) + + const thread = { assistants: [{ assistant_id: 'assistant1' }] } + const result = await createThread(thread) + expect(mkdirSync).toHaveBeenCalled() + expect(writeFileSync).toHaveBeenCalled() + expect(result.id).toBeDefined() + }) + }) + + describe('updateThread', () => { + it('should return a message if thread is not found', async () => { + ;(existsSync as jest.Mock).mockReturnValue(true) + ;(readdirSync as jest.Mock).mockReturnValue(['file1']) + ;(readFileSync as jest.Mock).mockReturnValue(JSON.stringify({ id: 'model1' })) + + const result = await updateThread('nonexistentId', {}) + expect(result).toEqual({ message: 'Thread not found' }) + }) + + it('should update the thread and return the updated thread', async () => { + ;(existsSync as jest.Mock).mockReturnValue(true) + ;(readdirSync as jest.Mock).mockReturnValue(['file1']) + ;(readFileSync as jest.Mock).mockReturnValue(JSON.stringify({ id: 'model1' })) + + const result = await updateThread('model1', { name: 'updatedName' }) + expect(writeFileSync).toHaveBeenCalled() + expect(result.name).toEqual('updatedName') + }) + }) + + describe('createMessage', () => { + it('should create a message and return the created message', async () => { + ;(existsSync as jest.Mock).mockReturnValue(false) + const message = { role: 'user', content: 'Hello' } + + const result = (await createMessage('thread1', message)) as any + expect(mkdirSync).toHaveBeenCalled() + expect(appendFileSync).toHaveBeenCalled() + expect(result.id).toBeDefined() + }) + }) + + describe('downloadModel', () => { + it('should return a message if model is not found', async () => { + ;(existsSync as jest.Mock).mockReturnValue(true) + ;(readdirSync as jest.Mock).mockReturnValue(['file1']) + ;(readFileSync as jest.Mock).mockReturnValue(JSON.stringify({ id: 'model1' })) + + const result = await downloadModel('nonexistentId') + expect(result).toEqual({ message: 'Model not found' }) + }) + + it('should start downloading the model', async () => { + ;(existsSync as jest.Mock).mockReturnValue(true) + ;(readdirSync as jest.Mock).mockReturnValue(['file1']) + ;(readFileSync as jest.Mock).mockReturnValue( + JSON.stringify({ id: 'model1', object: 'model', sources: ['http://example.com'] }) + ) + const result = await downloadModel('model1') + expect(result).toEqual({ message: 'Starting download model1' }) + }) + }) + + describe('chatCompletions', () => { + it('should return an error if model is not found', async () => { + const request = { body: { model: 'nonexistentModel' } } + const reply = { code: jest.fn().mockReturnThis(), send: jest.fn() } + + await chatCompletions(request, reply) + expect(reply.code).toHaveBeenCalledWith(404) + expect(reply.send).toHaveBeenCalledWith({ + error: { + message: 'The model nonexistentModel does not exist', + type: 'invalid_request_error', + param: null, + code: 'model_not_found', + }, + }) + }) + + it('should return the chat completions', async () => { + const request = { body: { model: 'model1' } } + const reply = { + code: jest.fn().mockReturnThis(), + send: jest.fn(), + raw: { writeHead: jest.fn(), pipe: jest.fn() }, + } + + ;(existsSync as jest.Mock).mockReturnValue(true) + ;(readdirSync as jest.Mock).mockReturnValue(['file1']) + ;(readFileSync as jest.Mock).mockReturnValue( + JSON.stringify({ id: 'model1', engine: 'openai' }) + ) + + // Mock fetch + const fetch = require('node-fetch') + fetch.mockResolvedValue({ + status: 200, + body: { pipe: jest.fn() }, + json: jest.fn().mockResolvedValue({ completions: ['completion1'] }), + }) + await chatCompletions(request, reply) + expect(reply.raw.writeHead).toHaveBeenCalledWith(200, expect.any(Object)) + }) + }) +}) diff --git a/core/src/node/api/restful/helper/builder.ts b/core/src/node/api/restful/helper/builder.ts index 08da0ff33..1a8120918 100644 --- a/core/src/node/api/restful/helper/builder.ts +++ b/core/src/node/api/restful/helper/builder.ts @@ -280,13 +280,13 @@ export const downloadModel = async ( for (const source of model.sources) { const rq = request({ url: source, strictSSL, proxy }) progress(rq, {}) - .on('progress', function (state: any) { + ?.on('progress', function (state: any) { console.debug('progress', JSON.stringify(state, null, 2)) }) - .on('error', function (err: Error) { + ?.on('error', function (err: Error) { console.error('error', err) }) - .on('end', function () { + ?.on('end', function () { console.debug('end') }) .pipe(createWriteStream(modelBinaryPath)) diff --git a/core/src/node/api/restful/helper/startStopModel.test.ts b/core/src/node/api/restful/helper/startStopModel.test.ts new file mode 100644 index 000000000..a5475cc28 --- /dev/null +++ b/core/src/node/api/restful/helper/startStopModel.test.ts @@ -0,0 +1,16 @@ + + + import { startModel } from './startStopModel' + + describe('startModel', () => { + it('test_startModel_error', async () => { + const modelId = 'testModelId' + const settingParams = undefined + + const result = await startModel(modelId, settingParams) + + expect(result).toEqual({ + error: expect.any(Error), + }) + }) + }) diff --git a/core/src/node/extension/index.test.ts b/core/src/node/extension/index.test.ts new file mode 100644 index 000000000..ce9cb0d0a --- /dev/null +++ b/core/src/node/extension/index.test.ts @@ -0,0 +1,7 @@ + + + import { useExtensions } from './index' + + test('testUseExtensionsMissingPath', () => { + expect(() => useExtensions(undefined as any)).toThrowError('A path to the extensions folder is required to use extensions') + }) diff --git a/core/src/types/api/index.test.ts b/core/src/types/api/index.test.ts new file mode 100644 index 000000000..6f2f2dcdb --- /dev/null +++ b/core/src/types/api/index.test.ts @@ -0,0 +1,24 @@ + + +import { NativeRoute } from '../index'; + +test('testNativeRouteEnum', () => { + expect(NativeRoute.openExternalUrl).toBe('openExternalUrl'); + expect(NativeRoute.openAppDirectory).toBe('openAppDirectory'); + expect(NativeRoute.openFileExplore).toBe('openFileExplorer'); + expect(NativeRoute.selectDirectory).toBe('selectDirectory'); + expect(NativeRoute.selectFiles).toBe('selectFiles'); + expect(NativeRoute.relaunch).toBe('relaunch'); + expect(NativeRoute.setNativeThemeLight).toBe('setNativeThemeLight'); + expect(NativeRoute.setNativeThemeDark).toBe('setNativeThemeDark'); + expect(NativeRoute.setMinimizeApp).toBe('setMinimizeApp'); + expect(NativeRoute.setCloseApp).toBe('setCloseApp'); + expect(NativeRoute.setMaximizeApp).toBe('setMaximizeApp'); + expect(NativeRoute.showOpenMenu).toBe('showOpenMenu'); + expect(NativeRoute.hideQuickAskWindow).toBe('hideQuickAskWindow'); + expect(NativeRoute.sendQuickAskInput).toBe('sendQuickAskInput'); + expect(NativeRoute.hideMainWindow).toBe('hideMainWindow'); + expect(NativeRoute.showMainWindow).toBe('showMainWindow'); + expect(NativeRoute.quickAskSizeUpdated).toBe('quickAskSizeUpdated'); + expect(NativeRoute.ackDeepLink).toBe('ackDeepLink'); +}); diff --git a/core/src/types/config/appConfigEvent.test.ts b/core/src/types/config/appConfigEvent.test.ts new file mode 100644 index 000000000..6000156c7 --- /dev/null +++ b/core/src/types/config/appConfigEvent.test.ts @@ -0,0 +1,9 @@ + + + import { AppConfigurationEventName } from './appConfigEvent'; + + describe('AppConfigurationEventName', () => { + it('should have the correct value for OnConfigurationUpdate', () => { + expect(AppConfigurationEventName.OnConfigurationUpdate).toBe('OnConfigurationUpdate'); + }); + }); diff --git a/core/src/types/huggingface/huggingfaceEntity.test.ts b/core/src/types/huggingface/huggingfaceEntity.test.ts new file mode 100644 index 000000000..d57b484be --- /dev/null +++ b/core/src/types/huggingface/huggingfaceEntity.test.ts @@ -0,0 +1,28 @@ + + + import { AllQuantizations } from './huggingfaceEntity'; + + test('testAllQuantizationsArray', () => { + expect(AllQuantizations).toEqual([ + 'Q3_K_S', + 'Q3_K_M', + 'Q3_K_L', + 'Q4_K_S', + 'Q4_K_M', + 'Q5_K_S', + 'Q5_K_M', + 'Q4_0', + 'Q4_1', + 'Q5_0', + 'Q5_1', + 'IQ2_XXS', + 'IQ2_XS', + 'Q2_K', + 'Q2_K_S', + 'Q6_K', + 'Q8_0', + 'F16', + 'F32', + 'COPY', + ]); + }); diff --git a/core/src/types/huggingface/index.test.ts b/core/src/types/huggingface/index.test.ts new file mode 100644 index 000000000..9cb80a08f --- /dev/null +++ b/core/src/types/huggingface/index.test.ts @@ -0,0 +1,8 @@ + + + import * as huggingfaceEntity from './huggingfaceEntity'; + import * as index from './index'; + + test('test_exports_from_huggingfaceEntity', () => { + expect(index).toEqual(huggingfaceEntity); + }); diff --git a/core/src/types/index.test.ts b/core/src/types/index.test.ts new file mode 100644 index 000000000..9dc001c4d --- /dev/null +++ b/core/src/types/index.test.ts @@ -0,0 +1,28 @@ + +import * as assistant from './assistant'; +import * as model from './model'; +import * as thread from './thread'; +import * as message from './message'; +import * as inference from './inference'; +import * as monitoring from './monitoring'; +import * as file from './file'; +import * as config from './config'; +import * as huggingface from './huggingface'; +import * as miscellaneous from './miscellaneous'; +import * as api from './api'; +import * as setting from './setting'; + + test('test_module_exports', () => { + expect(assistant).toBeDefined(); + expect(model).toBeDefined(); + expect(thread).toBeDefined(); + expect(message).toBeDefined(); + expect(inference).toBeDefined(); + expect(monitoring).toBeDefined(); + expect(file).toBeDefined(); + expect(config).toBeDefined(); + expect(huggingface).toBeDefined(); + expect(miscellaneous).toBeDefined(); + expect(api).toBeDefined(); + expect(setting).toBeDefined(); + }); diff --git a/core/src/types/inference/inferenceEntity.test.ts b/core/src/types/inference/inferenceEntity.test.ts new file mode 100644 index 000000000..a2c06e32b --- /dev/null +++ b/core/src/types/inference/inferenceEntity.test.ts @@ -0,0 +1,13 @@ + + + import { ChatCompletionMessage, ChatCompletionRole } from './inferenceEntity'; + + test('test_chatCompletionMessage_withStringContent_andSystemRole', () => { + const message: ChatCompletionMessage = { + content: 'Hello, world!', + role: ChatCompletionRole.System, + }; + + expect(message.content).toBe('Hello, world!'); + expect(message.role).toBe(ChatCompletionRole.System); + }); diff --git a/core/src/types/inference/inferenceEvent.test.ts b/core/src/types/inference/inferenceEvent.test.ts new file mode 100644 index 000000000..1cb44fdbb --- /dev/null +++ b/core/src/types/inference/inferenceEvent.test.ts @@ -0,0 +1,7 @@ + + + import { InferenceEvent } from './inferenceEvent'; + + test('testInferenceEventEnumContainsOnInferenceStopped', () => { + expect(InferenceEvent.OnInferenceStopped).toBe('OnInferenceStopped'); + }); diff --git a/core/src/types/message/messageEvent.test.ts b/core/src/types/message/messageEvent.test.ts new file mode 100644 index 000000000..80a943bb1 --- /dev/null +++ b/core/src/types/message/messageEvent.test.ts @@ -0,0 +1,7 @@ + + + import { MessageEvent } from './messageEvent'; + + test('testOnMessageSentValue', () => { + expect(MessageEvent.OnMessageSent).toBe('OnMessageSent'); + }); diff --git a/core/src/types/message/messageRequestType.test.ts b/core/src/types/message/messageRequestType.test.ts new file mode 100644 index 000000000..41f53b2e0 --- /dev/null +++ b/core/src/types/message/messageRequestType.test.ts @@ -0,0 +1,7 @@ + + + import { MessageRequestType } from './messageRequestType'; + + test('testMessageRequestTypeEnumContainsThread', () => { + expect(MessageRequestType.Thread).toBe('Thread'); + }); diff --git a/core/src/types/model/modelEntity.test.ts b/core/src/types/model/modelEntity.test.ts new file mode 100644 index 000000000..306316ac4 --- /dev/null +++ b/core/src/types/model/modelEntity.test.ts @@ -0,0 +1,30 @@ + + + import { Model, ModelSettingParams, ModelRuntimeParams, InferenceEngine } from '../model' + + test('testValidModelCreation', () => { + const model: Model = { + object: 'model', + version: '1.0', + format: 'format1', + sources: [{ filename: 'model.bin', url: 'http://example.com/model.bin' }], + id: 'model1', + name: 'Test Model', + created: Date.now(), + description: 'A cool model from Huggingface', + settings: { ctx_len: 100, ngl: 50, embedding: true }, + parameters: { temperature: 0.5, token_limit: 100, top_k: 10 }, + metadata: { author: 'Author', tags: ['tag1', 'tag2'], size: 100 }, + engine: InferenceEngine.anthropic + }; + + expect(model).toBeDefined(); + expect(model.object).toBe('model'); + expect(model.version).toBe('1.0'); + expect(model.sources).toHaveLength(1); + expect(model.sources[0].filename).toBe('model.bin'); + expect(model.settings).toBeDefined(); + expect(model.parameters).toBeDefined(); + expect(model.metadata).toBeDefined(); + expect(model.engine).toBe(InferenceEngine.anthropic); + }); diff --git a/core/src/types/model/modelEvent.test.ts b/core/src/types/model/modelEvent.test.ts new file mode 100644 index 000000000..f9fa8cc6a --- /dev/null +++ b/core/src/types/model/modelEvent.test.ts @@ -0,0 +1,7 @@ + + + import { ModelEvent } from './modelEvent'; + + test('testOnModelInit', () => { + expect(ModelEvent.OnModelInit).toBe('OnModelInit'); + }); diff --git a/joi/jest.config.js b/joi/jest.config.js index 8543f24e3..676042491 100644 --- a/joi/jest.config.js +++ b/joi/jest.config.js @@ -3,6 +3,7 @@ module.exports = { testEnvironment: 'node', roots: ['/src'], testMatch: ['**/*.test.*'], + collectCoverageFrom: ['src/**/*.{ts,tsx}'], setupFilesAfterEnv: ['/jest.setup.js'], testEnvironment: 'jsdom', } diff --git a/package.json b/package.json index 2785ee3b5..255dda6c7 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "scripts": { "lint": "yarn workspace jan lint && yarn workspace @janhq/web lint", "test:unit": "jest", - "test:coverage": "jest --coverage --collectCoverageFrom='src/**/*.{ts,tsx}'", + "test:coverage": "jest --coverage", "test": "yarn workspace jan test:e2e", "test-local": "yarn lint && yarn build:test && yarn test", "pre-install:darwin": "find extensions -type f -path \"**/*.tgz\" -exec cp {} pre-install \\;", diff --git a/testRunner.js b/testRunner.js new file mode 100644 index 000000000..1067f05a3 --- /dev/null +++ b/testRunner.js @@ -0,0 +1,19 @@ +const jestRunner = require('jest-runner') + +class EmptyTestFileRunner extends jestRunner.default { + async runTests(tests, watcher, onStart, onResult, onFailure, options) { + const nonEmptyTests = tests.filter( + (test) => test.context.hasteFS.getSize(test.path) > 0 + ) + return super.runTests( + nonEmptyTests, + watcher, + onStart, + onResult, + onFailure, + options + ) + } +} + +module.exports = EmptyTestFileRunner diff --git a/web/containers/Loader/Loader.test.tsx b/web/containers/Loader/Loader.test.tsx new file mode 100644 index 000000000..007d0eeba --- /dev/null +++ b/web/containers/Loader/Loader.test.tsx @@ -0,0 +1,23 @@ +// Loader.test.tsx +import '@testing-library/jest-dom'; +import React from 'react' +import { render, screen } from '@testing-library/react' +import Loader from './index' + +describe('Loader Component', () => { + it('renders without crashing', () => { + render() + }) + + it('displays the correct description', () => { + const descriptionText = 'Loading...' + render() + expect(screen.getByText(descriptionText)).toBeInTheDocument() + }) + + it('renders the correct number of loader elements', () => { + const { container } = render() + const loaderElements = container.querySelectorAll('label') + expect(loaderElements).toHaveLength(6) + }) +}) diff --git a/web/extension/Extension.test.ts b/web/extension/Extension.test.ts new file mode 100644 index 000000000..d7b4a1805 --- /dev/null +++ b/web/extension/Extension.test.ts @@ -0,0 +1,19 @@ +import Extension from "./Extension"; + +test('should create an Extension instance with all properties', () => { + const url = 'https://example.com'; + const name = 'Test Extension'; + const productName = 'Test Product'; + const active = true; + const description = 'Test Description'; + const version = '1.0.0'; + + const extension = new Extension(url, name, productName, active, description, version); + + expect(extension.url).toBe(url); + expect(extension.name).toBe(name); + expect(extension.productName).toBe(productName); + expect(extension.active).toBe(active); + expect(extension.description).toBe(description); + expect(extension.version).toBe(version); +}); diff --git a/web/extension/Extension.ts b/web/extension/Extension.ts index 9438238ca..7dfb72b43 100644 --- a/web/extension/Extension.ts +++ b/web/extension/Extension.ts @@ -12,13 +12,13 @@ class Extension { url: string /** @type {boolean} Whether the extension is activated or not. */ - active + active?: boolean /** @type {string} Extension's description. */ - description + description?: string /** @type {string} Extension's version. */ - version + version?: string constructor( url: string, diff --git a/web/extension/ExtensionManager.test.ts b/web/extension/ExtensionManager.test.ts new file mode 100644 index 000000000..58f784b07 --- /dev/null +++ b/web/extension/ExtensionManager.test.ts @@ -0,0 +1,131 @@ +// ExtensionManager.test.ts +import { AIEngine, BaseExtension, ExtensionTypeEnum } from '@janhq/core' +import { ExtensionManager } from './ExtensionManager' +import Extension from './Extension' + +class TestExtension extends BaseExtension { + onLoad(): void {} + onUnload(): void {} +} +class TestEngine extends AIEngine { + provider: string = 'testEngine' + onUnload(): void {} +} + +describe('ExtensionManager', () => { + let manager: ExtensionManager + + beforeEach(() => { + manager = new ExtensionManager() + }) + + it('should register an extension', () => { + const extension = new TestExtension('', '') + manager.register('testExtension', extension) + expect(manager.getByName('testExtension')).toBe(extension) + }) + + it('should register an AI engine', () => { + const extension = { provider: 'testEngine' } as unknown as BaseExtension + manager.register('testExtension', extension) + expect(manager.getEngine('testEngine')).toBe(extension) + }) + + it('should retrieve an extension by type', () => { + const extension = new TestExtension('', '') + jest.spyOn(extension, 'type').mockReturnValue(ExtensionTypeEnum.Assistant) + manager.register('testExtension', extension) + expect(manager.get(ExtensionTypeEnum.Assistant)).toBe(extension) + }) + + it('should retrieve an extension by name', () => { + const extension = new TestExtension('', '') + manager.register('testExtension', extension) + expect(manager.getByName('testExtension')).toBe(extension) + }) + + it('should retrieve all extensions', () => { + const extension1 = new TestExtension('', '') + const extension2 = new TestExtension('', '') + manager.register('testExtension1', extension1) + manager.register('testExtension2', extension2) + expect(manager.getAll()).toEqual([extension1, extension2]) + }) + + it('should retrieve an engine by name', () => { + const engine = new TestEngine('', '') + manager.register('anything', engine) + expect(manager.getEngine('testEngine')).toBe(engine) + }) + + it('should load all extensions', () => { + const extension = new TestExtension('', '') + jest.spyOn(extension, 'onLoad') + manager.register('testExtension', extension) + manager.load() + expect(extension.onLoad).toHaveBeenCalled() + }) + + it('should unload all extensions', () => { + const extension = new TestExtension('', '') + jest.spyOn(extension, 'onUnload') + manager.register('testExtension', extension) + manager.unload() + expect(extension.onUnload).toHaveBeenCalled() + }) + + it('should list all extensions', () => { + const extension1 = new TestExtension('', '') + const extension2 = new TestExtension('', '') + manager.register('testExtension1', extension1) + manager.register('testExtension2', extension2) + expect(manager.listExtensions()).toEqual([extension1, extension2]) + }) + + it('should retrieve active extensions', async () => { + const extension = new Extension( + 'url', + 'name', + 'productName', + true, + 'description', + 'version' + ) + window.core = { + api: { + getActiveExtensions: jest.fn(), + }, + } + jest + .spyOn(window.core.api, 'getActiveExtensions') + .mockResolvedValue([extension]) + const activeExtensions = await manager.getActive() + expect(activeExtensions).toEqual([extension]) + }) + + it('should register all active extensions', async () => { + const extension = new Extension( + 'url', + 'name', + 'productName', + true, + 'description', + 'version' + ) + jest.spyOn(manager, 'getActive').mockResolvedValue([extension]) + jest.spyOn(manager, 'activateExtension').mockResolvedValue() + await manager.registerActive() + expect(manager.activateExtension).toHaveBeenCalledWith(extension) + }) + + it('should uninstall extensions', async () => { + window.core = { + api: { + uninstallExtension: jest.fn(), + }, + } + jest.spyOn(window.core.api, 'uninstallExtension').mockResolvedValue(true) + const result = await manager.uninstall(['testExtension']) + expect(result).toBe(true) + }) +}) diff --git a/web/extension/index.test.ts b/web/extension/index.test.ts new file mode 100644 index 000000000..50b6b59db --- /dev/null +++ b/web/extension/index.test.ts @@ -0,0 +1,9 @@ + + +import { extensionManager } from './index'; + +describe('index', () => { + it('should export extensionManager from ExtensionManager', () => { + expect(extensionManager).toBeDefined(); + }); +}); diff --git a/web/helpers/atoms/ApiServer.atom.test.ts b/web/helpers/atoms/ApiServer.atom.test.ts new file mode 100644 index 000000000..4c5d7fca4 --- /dev/null +++ b/web/helpers/atoms/ApiServer.atom.test.ts @@ -0,0 +1,9 @@ + +import { hostOptions } from './ApiServer.atom'; + +test('hostOptions correct values', () => { + expect(hostOptions).toEqual([ + { name: '127.0.0.1', value: '127.0.0.1' }, + { name: '0.0.0.0', value: '0.0.0.0' }, + ]); +}); diff --git a/web/helpers/atoms/App.atom.test.ts b/web/helpers/atoms/App.atom.test.ts new file mode 100644 index 000000000..f3d58dfc1 --- /dev/null +++ b/web/helpers/atoms/App.atom.test.ts @@ -0,0 +1,8 @@ + +import { mainViewStateAtom } from './App.atom'; +import { MainViewState } from '@/constants/screens'; + +test('mainViewStateAtom initializes with Thread', () => { + const result = mainViewStateAtom.init; + expect(result).toBe(MainViewState.Thread); +}); diff --git a/web/helpers/atoms/AppConfig.atom.test.ts b/web/helpers/atoms/AppConfig.atom.test.ts new file mode 100644 index 000000000..28f085e53 --- /dev/null +++ b/web/helpers/atoms/AppConfig.atom.test.ts @@ -0,0 +1,7 @@ + +import { hostAtom } from './AppConfig.atom'; + +test('hostAtom default value', () => { + const result = hostAtom.init; + expect(result).toBe('http://localhost:1337/'); +}); diff --git a/web/helpers/atoms/Assistant.atom.test.ts b/web/helpers/atoms/Assistant.atom.test.ts new file mode 100644 index 000000000..a5073d293 --- /dev/null +++ b/web/helpers/atoms/Assistant.atom.test.ts @@ -0,0 +1,8 @@ + +import { assistantsAtom } from './Assistant.atom'; + +test('assistantsAtom initializes as an empty array', () => { + const initialValue = assistantsAtom.init; + expect(Array.isArray(initialValue)).toBe(true); + expect(initialValue).toHaveLength(0); +}); diff --git a/web/helpers/atoms/ChatMessage.atom.test.ts b/web/helpers/atoms/ChatMessage.atom.test.ts new file mode 100644 index 000000000..6acf4283e --- /dev/null +++ b/web/helpers/atoms/ChatMessage.atom.test.ts @@ -0,0 +1,32 @@ + +import { getCurrentChatMessagesAtom } from './ChatMessage.atom'; +import { setConvoMessagesAtom, chatMessages, readyThreadsMessagesAtom } from './ChatMessage.atom'; + +test('getCurrentChatMessagesAtom returns empty array when no active thread ID', () => { + const getMock = jest.fn().mockReturnValue(undefined); + expect(getCurrentChatMessagesAtom.read(getMock)).toEqual([]); +}); + + +test('getCurrentChatMessagesAtom returns empty array when activeThreadId is undefined', () => { + const getMock = jest.fn().mockReturnValue({ + activeThreadId: undefined, + chatMessages: { + threadId: [{ id: 1, content: 'message' }], + }, + }); + expect(getCurrentChatMessagesAtom.read(getMock)).toEqual([]); +}); + +test('setConvoMessagesAtom updates chatMessages and readyThreadsMessagesAtom', () => { + const getMock = jest.fn().mockReturnValue({}); + const setMock = jest.fn(); + const threadId = 'thread1'; + const messages = [{ id: '1', content: 'Hello' }]; + + setConvoMessagesAtom.write(getMock, setMock, threadId, messages); + + expect(setMock).toHaveBeenCalledWith(chatMessages, { [threadId]: messages }); + expect(setMock).toHaveBeenCalledWith(readyThreadsMessagesAtom, { [threadId]: true }); +}); + diff --git a/web/helpers/atoms/HuggingFace.atom.test.ts b/web/helpers/atoms/HuggingFace.atom.test.ts new file mode 100644 index 000000000..134d19947 --- /dev/null +++ b/web/helpers/atoms/HuggingFace.atom.test.ts @@ -0,0 +1,14 @@ + +import { importHuggingFaceModelStageAtom } from './HuggingFace.atom'; +import { importingHuggingFaceRepoDataAtom } from './HuggingFace.atom'; + +test('importHuggingFaceModelStageAtom should have initial value of NONE', () => { + const result = importHuggingFaceModelStageAtom.init; + expect(result).toBe('NONE'); +}); + + +test('importingHuggingFaceRepoDataAtom should have initial value of undefined', () => { + const result = importingHuggingFaceRepoDataAtom.init; + expect(result).toBeUndefined(); +}); diff --git a/web/helpers/atoms/LocalServer.atom.test.ts b/web/helpers/atoms/LocalServer.atom.test.ts new file mode 100644 index 000000000..b3c53ec07 --- /dev/null +++ b/web/helpers/atoms/LocalServer.atom.test.ts @@ -0,0 +1,7 @@ + +import { serverEnabledAtom } from './LocalServer.atom'; + +test('serverEnabledAtom_initialValue', () => { + const result = serverEnabledAtom.init; + expect(result).toBe(false); +}); diff --git a/web/helpers/atoms/Setting.atom.test.ts b/web/helpers/atoms/Setting.atom.test.ts new file mode 100644 index 000000000..7c5d7ce94 --- /dev/null +++ b/web/helpers/atoms/Setting.atom.test.ts @@ -0,0 +1,7 @@ + +import { selectedSettingAtom } from './Setting.atom'; + +test('selectedSettingAtom has correct initial value', () => { + const result = selectedSettingAtom.init; + expect(result).toBe('My Models'); +}); diff --git a/web/helpers/atoms/ThreadRightPanel.atom.test.ts b/web/helpers/atoms/ThreadRightPanel.atom.test.ts new file mode 100644 index 000000000..162b059fd --- /dev/null +++ b/web/helpers/atoms/ThreadRightPanel.atom.test.ts @@ -0,0 +1,6 @@ + +import { activeTabThreadRightPanelAtom } from './ThreadRightPanel.atom'; + +test('activeTabThreadRightPanelAtom can be imported', () => { + expect(activeTabThreadRightPanelAtom).toBeDefined(); +}); diff --git a/web/hooks/useDownloadState.test.ts b/web/hooks/useDownloadState.test.ts new file mode 100644 index 000000000..893649e26 --- /dev/null +++ b/web/hooks/useDownloadState.test.ts @@ -0,0 +1,109 @@ +import { + setDownloadStateAtom, + modelDownloadStateAtom, +} from './useDownloadState' + +// Mock dependencies +jest.mock('jotai', () => ({ + atom: jest.fn(), + useAtom: jest.fn(), +})) +jest.mock('@/containers/Toast', () => ({ + toaster: jest.fn(), +})) +jest.mock('@/helpers/atoms/Model.atom', () => ({ + configuredModelsAtom: jest.fn(), + downloadedModelsAtom: jest.fn(), + removeDownloadingModelAtom: jest.fn(), +})) + +describe('setDownloadStateAtom', () => { + let get: jest.Mock + let set: jest.Mock + + beforeEach(() => { + get = jest.fn() + set = jest.fn() + }) + + it('should handle download completion', () => { + const state = { + downloadState: 'end', + modelId: 'model1', + fileName: 'file1', + children: [], + } + const currentState = { + model1: { + children: [state], + }, + } + get.mockReturnValueOnce(currentState) + get.mockReturnValueOnce([{ id: 'model1' }]) + + set(setDownloadStateAtom, state) + + expect(set).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ modelId: expect.stringContaining('model1') }) + ) + }) + + it('should handle download error', () => { + const state = { + downloadState: 'error', + modelId: 'model1', + error: 'some error', + } + const currentState = { + model1: {}, + } + get.mockReturnValueOnce(currentState) + + set(setDownloadStateAtom, state) + + expect(set).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ modelId: 'model1' }) + ) + }) + + it('should handle download error with certificate issue', () => { + const state = { + downloadState: 'error', + modelId: 'model1', + error: 'certificate error', + } + const currentState = { + model1: {}, + } + get.mockReturnValueOnce(currentState) + + set(setDownloadStateAtom, state) + + expect(set).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ modelId: 'model1' }) + ) + }) + + it('should handle download in progress', () => { + const state = { + downloadState: 'progress', + modelId: 'model1', + fileName: 'file1', + size: { total: 100, transferred: 50 }, + } + const currentState = { + model1: { + children: [], + size: { total: 0, transferred: 0 }, + }, + } + get.mockReturnValueOnce(currentState) + + set(setDownloadStateAtom, state) + + expect(set).toHaveBeenCalledWith(modelDownloadStateAtom, expect.any(Object)) + }) +}) diff --git a/web/jest.config.js b/web/jest.config.js index 7601f1e43..8b2683e78 100644 --- a/web/jest.config.js +++ b/web/jest.config.js @@ -5,7 +5,6 @@ const createJestConfig = nextJest({}) // Add any custom config to be passed to Jest const config = { - coverageProvider: 'v8', testEnvironment: 'jsdom', transform: { '^.+\\.(ts|tsx)$': 'ts-jest', @@ -17,6 +16,8 @@ const config = { }, // Add more setup options before each test is run // setupFilesAfterEnv: ['/jest.setup.ts'], + runner: './testRunner.js', + collectCoverageFrom: ['./**/*.{ts,tsx}'], } // https://stackoverflow.com/a/72926763/5078746 diff --git a/web/screens/Settings/Advanced/index.test.tsx b/web/screens/Settings/Advanced/index.test.tsx index 10ea810b1..e34626f6e 100644 --- a/web/screens/Settings/Advanced/index.test.tsx +++ b/web/screens/Settings/Advanced/index.test.tsx @@ -1,3 +1,7 @@ +/** + * @jest-environment jsdom + */ + import React from 'react' import { render, screen, fireEvent, waitFor } from '@testing-library/react' import '@testing-library/jest-dom' @@ -10,7 +14,6 @@ class ResizeObserverMock { } global.ResizeObserver = ResizeObserverMock -// @ts-ignore global.window.core = { api: { getAppConfigurations: () => jest.fn(), diff --git a/web/services/appService.test.ts b/web/services/appService.test.ts new file mode 100644 index 000000000..37053f930 --- /dev/null +++ b/web/services/appService.test.ts @@ -0,0 +1,30 @@ + +import { ExtensionTypeEnum, extensionManager } from '@/extension'; +import { appService } from './appService'; + +test('should return correct system information when monitoring extension is found', async () => { + const mockGpuSetting = { name: 'NVIDIA GeForce GTX 1080', memory: 8192 }; + const mockOsInfo = { platform: 'win32', release: '10.0.19041' }; + const mockMonitoringExtension = { + getGpuSetting: jest.fn().mockResolvedValue(mockGpuSetting), + getOsInfo: jest.fn().mockResolvedValue(mockOsInfo), + }; + extensionManager.get = jest.fn().mockReturnValue(mockMonitoringExtension); + + const result = await appService.systemInformation(); + + expect(mockMonitoringExtension.getGpuSetting).toHaveBeenCalled(); + expect(mockMonitoringExtension.getOsInfo).toHaveBeenCalled(); + expect(result).toEqual({ gpuSetting: mockGpuSetting, osInfo: mockOsInfo }); +}); + + +test('should log a warning when monitoring extension is not found', async () => { + const consoleWarnMock = jest.spyOn(console, 'warn').mockImplementation(() => {}); + extensionManager.get = jest.fn().mockReturnValue(undefined); + + await appService.systemInformation(); + + expect(consoleWarnMock).toHaveBeenCalledWith('System monitoring extension not found'); + consoleWarnMock.mockRestore(); +}); diff --git a/web/services/eventsService.test.ts b/web/services/eventsService.test.ts new file mode 100644 index 000000000..78b95167a --- /dev/null +++ b/web/services/eventsService.test.ts @@ -0,0 +1,47 @@ + +import { EventEmitter } from './eventsService'; + +test('should do nothing when no handlers for event', () => { + const emitter = new EventEmitter(); + + expect(() => { + emitter.emit('nonExistentEvent', 'test data'); + }).not.toThrow(); +}); + + +test('should call all handlers for event', () => { + const emitter = new EventEmitter(); + const handler1 = jest.fn(); + const handler2 = jest.fn(); + + emitter.on('testEvent', handler1); + emitter.on('testEvent', handler2); + + emitter.emit('testEvent', 'test data'); + + expect(handler1).toHaveBeenCalledWith('test data'); + expect(handler2).toHaveBeenCalledWith('test data'); +}); + + +test('should remove handler for event', () => { + const emitter = new EventEmitter(); + const handler = jest.fn(); + + emitter.on('testEvent', handler); + emitter.off('testEvent', handler); + + expect(emitter['handlers'].get('testEvent')).not.toContain(handler); +}); + + +test('should add handler for event', () => { + const emitter = new EventEmitter(); + const handler = jest.fn(); + + emitter.on('testEvent', handler); + + expect(emitter['handlers'].has('testEvent')).toBe(true); + expect(emitter['handlers'].get('testEvent')).toContain(handler); +}); diff --git a/web/services/extensionService.test.ts b/web/services/extensionService.test.ts new file mode 100644 index 000000000..75bd4f78a --- /dev/null +++ b/web/services/extensionService.test.ts @@ -0,0 +1,35 @@ + +import { extensionManager } from '@/extension/ExtensionManager'; +import { ExtensionTypeEnum } from '@janhq/core'; +import { isCoreExtensionInstalled } from './extensionService'; + +test('isCoreExtensionInstalled returns true when both extensions are installed', () => { + jest.spyOn(extensionManager, 'get').mockImplementation((type) => { + if (type === ExtensionTypeEnum.Conversational || type === ExtensionTypeEnum.Model) return {}; + return undefined; + }); + + expect(isCoreExtensionInstalled()).toBe(true); +}); + + +test('isCoreExtensionInstalled returns false when Model extension is not installed', () => { + jest.spyOn(extensionManager, 'get').mockImplementation((type) => { + if (type === ExtensionTypeEnum.Conversational) return {}; + if (type === ExtensionTypeEnum.Model) return undefined; + return undefined; + }); + + expect(isCoreExtensionInstalled()).toBe(false); +}); + + +test('isCoreExtensionInstalled returns false when Conversational extension is not installed', () => { + jest.spyOn(extensionManager, 'get').mockImplementation((type) => { + if (type === ExtensionTypeEnum.Conversational) return undefined; + if (type === ExtensionTypeEnum.Model) return {}; + return undefined; + }); + + expect(isCoreExtensionInstalled()).toBe(false); +}); diff --git a/web/services/restService.test.ts b/web/services/restService.test.ts new file mode 100644 index 000000000..7782e7816 --- /dev/null +++ b/web/services/restService.test.ts @@ -0,0 +1,15 @@ + + +test('restAPI.baseApiUrl set correctly', () => { + const originalEnv = process.env.API_BASE_URL; + process.env.API_BASE_URL = 'http://test-api.com'; + + // Re-import to get the updated value + jest.resetModules(); + const { restAPI } = require('./restService'); + + expect(restAPI.baseApiUrl).toBe('http://test-api.com'); + + // Clean up + process.env.API_BASE_URL = originalEnv; +}); diff --git a/web/utils/json.test.ts b/web/utils/json.test.ts new file mode 100644 index 000000000..47a37d5fd --- /dev/null +++ b/web/utils/json.test.ts @@ -0,0 +1,22 @@ +// json.test.ts +import { safeJsonParse } from './json'; + +describe('safeJsonParse', () => { + it('should correctly parse a valid JSON string', () => { + const jsonString = '{"name": "John", "age": 30}'; + const result = safeJsonParse<{ name: string; age: number }>(jsonString); + expect(result).toEqual({ name: 'John', age: 30 }); + }); + + it('should return undefined for an invalid JSON string', () => { + const jsonString = '{"name": "John", "age": 30'; + const result = safeJsonParse<{ name: string; age: number }>(jsonString); + expect(result).toBeUndefined(); + }); + + it('should return undefined for an empty string', () => { + const jsonString = ''; + const result = safeJsonParse(jsonString); + expect(result).toBeUndefined(); + }); +}); \ No newline at end of file diff --git a/web/utils/modelParam.test.ts b/web/utils/modelParam.test.ts index f1b858955..994a5bd57 100644 --- a/web/utils/modelParam.test.ts +++ b/web/utils/modelParam.test.ts @@ -149,6 +149,14 @@ describe('validationRules', () => { }) }) + + it('should normalize invalid values for keys not listed in validationRules', () => { + expect(normalizeValue('invalid_key', 'invalid')).toBe('invalid') + expect(normalizeValue('invalid_key', 123)).toBe(123) + expect(normalizeValue('invalid_key', true)).toBe(true) + expect(normalizeValue('invalid_key', false)).toBe(false) + }) + describe('normalizeValue', () => { it('should normalize ctx_len correctly', () => { expect(normalizeValue('ctx_len', 100.5)).toBe(100) diff --git a/web/utils/threadMessageBuilder.test.ts b/web/utils/threadMessageBuilder.test.ts new file mode 100644 index 000000000..d938a2e03 --- /dev/null +++ b/web/utils/threadMessageBuilder.test.ts @@ -0,0 +1,27 @@ + +import { ChatCompletionRole, MessageStatus } from '@janhq/core' + + import { ThreadMessageBuilder } from './threadMessageBuilder' + import { MessageRequestBuilder } from './messageRequestBuilder' + + describe('ThreadMessageBuilder', () => { + it('testBuildMethod', () => { + const msgRequest = new MessageRequestBuilder( + 'type', + { model: 'model' }, + { id: 'thread-id' }, + [] + ) + const builder = new ThreadMessageBuilder(msgRequest) + const result = builder.build() + + expect(result.id).toBe(msgRequest.msgId) + expect(result.thread_id).toBe(msgRequest.thread.id) + expect(result.role).toBe(ChatCompletionRole.User) + expect(result.status).toBe(MessageStatus.Ready) + expect(result.created).toBeDefined() + expect(result.updated).toBeDefined() + expect(result.object).toBe('thread.message') + expect(result.content).toEqual([]) + }) + })