diff --git a/.github/ISSUE_TEMPLATE/documentation-request.md b/.github/ISSUE_TEMPLATE/documentation-request.md deleted file mode 100644 index 4d4dcdb0e..000000000 --- a/.github/ISSUE_TEMPLATE/documentation-request.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -name: "📖 Documentation request" -about: Documentation requests -title: 'docs: TITLE' -labels: 'type: documentation' -assignees: '' - ---- - -**Pages** -- Page(s) that need to be done - -**Success Criteria** -Content that should be covered - -**Additional context** -Examples, reference pages, resources diff --git a/.github/ISSUE_TEMPLATE/epic-request.md b/.github/ISSUE_TEMPLATE/epic-request.md deleted file mode 100644 index f86f379fa..000000000 --- a/.github/ISSUE_TEMPLATE/epic-request.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -name: "💥 Epic request" -about: Suggest an idea for this project -title: 'epic: [DESCRIPTION]' -labels: 'type: epic' -assignees: '' - ---- - -## Motivation -- - -## Specs -- - -## Designs -[Figma](link) - -## Tasklist -- [ ] - -## Not in Scope -- - -## Appendix diff --git a/.github/workflows/jan-electron-linter-and-test.yml b/.github/workflows/jan-electron-linter-and-test.yml index 3a95e804e..a151d6497 100644 --- a/.github/workflows/jan-electron-linter-and-test.yml +++ b/.github/workflows/jan-electron-linter-and-test.yml @@ -37,6 +37,33 @@ on: - '!README.md' jobs: + + base_branch_cov: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v3 + with: + ref: ${{ github.base_ref }} + - name: Use Node.js v20.9.0 + uses: actions/setup-node@v3 + with: + node-version: v20.9.0 + + - name: Install dependencies + run: | + yarn + yarn build:core + + - name: Run test coverage + run: yarn test:coverage + + - name: Upload code coverage for ref branch + uses: actions/upload-artifact@v3 + with: + name: ref-lcov.info + path: ./coverage/lcov.info + test-on-macos: if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || github.event_name == 'push' || github.event_name == 'workflow_dispatch' runs-on: [self-hosted, macOS, macos-desktop] @@ -292,6 +319,56 @@ jobs: TURBO_TEAM: 'linux' TURBO_TOKEN: '${{ secrets.TURBO_TOKEN }}' + coverage-check: + runs-on: [self-hosted, Linux, ubuntu-desktop] + needs: base_branch_cov + if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || github.event_name == 'push' || github.event_name == 'workflow_dispatch' + steps: + - name: Getting the repo + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Installing node + uses: actions/setup-node@v3 + with: + node-version: 20 + + - name: 'Cleanup cache' + continue-on-error: true + run: | + rm -rf ~/jan + make clean + + - name: Download code coverage report from base branch + uses: actions/download-artifact@v3 + with: + name: ref-lcov.info + + - name: Linter and test coverage + run: | + export DISPLAY=$(w -h | awk 'NR==1 {print $2}') + echo -e "Display ID: $DISPLAY" + npm config set registry ${{ secrets.NPM_PROXY }} --global + yarn config set registry ${{ secrets.NPM_PROXY }} --global + make lint + yarn build:test + yarn test:coverage + env: + TURBO_API: '${{ secrets.TURBO_API }}' + TURBO_TEAM: 'linux' + TURBO_TOKEN: '${{ secrets.TURBO_TOKEN }}' + + - name: Generate Code Coverage report + id: code-coverage + uses: barecheck/code-coverage-action@v1 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + lcov-file: "./coverage/lcov.info" + base-lcov-file: "./lcov.info" + send-summary-comment: true + show-annotations: "warning" + test-on-ubuntu-pr-target: runs-on: [self-hosted, Linux, ubuntu-desktop] if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository diff --git a/.gitignore b/.gitignore index d3b50445e..f9b1dab66 100644 --- a/.gitignore +++ b/.gitignore @@ -20,7 +20,7 @@ electron/themes electron/playwright-report server/pre-install package-lock.json - +coverage *.log core/lib/** @@ -41,3 +41,5 @@ extensions/*-extension/bin/vulkaninfo .turbo electron/test-data electron/test-results +core/test_results.html +coverage diff --git a/Makefile b/Makefile index 1687f8bbe..0228c52d7 100644 --- a/Makefile +++ b/Makefile @@ -104,7 +104,7 @@ endif # Testing test: lint yarn build:test - yarn test:unit + yarn test:coverage yarn test # Builds and publishes the app diff --git a/core/jest.config.js b/core/jest.config.js index c18f55091..6c805f1c9 100644 --- a/core/jest.config.js +++ b/core/jest.config.js @@ -4,4 +4,5 @@ module.exports = { moduleNameMapper: { '@/(.*)': '/src/$1', }, + runner: './testRunner.js', } diff --git a/core/package.json b/core/package.json index 9e4d8d69a..ac3305014 100644 --- a/core/package.json +++ b/core/package.json @@ -46,6 +46,8 @@ "eslint": "8.57.0", "eslint-plugin-jest": "^27.9.0", "jest": "^29.7.0", + "jest-junit": "^16.0.0", + "jest-runner": "^29.7.0", "rimraf": "^3.0.2", "rollup": "^2.38.5", "rollup-plugin-commonjs": "^9.1.8", @@ -53,7 +55,7 @@ "rollup-plugin-node-resolve": "^5.2.0", "rollup-plugin-sourcemaps": "^0.6.3", "rollup-plugin-typescript2": "^0.36.0", - "ts-jest": "^29.1.2", + "ts-jest": "^29.2.5", "tslib": "^2.6.2", "typescript": "^5.3.3" }, diff --git a/core/src/browser/core.test.ts b/core/src/browser/core.test.ts new file mode 100644 index 000000000..84250888e --- /dev/null +++ b/core/src/browser/core.test.ts @@ -0,0 +1,98 @@ +import { openExternalUrl } from './core'; +import { joinPath } from './core'; +import { openFileExplorer } from './core'; +import { getJanDataFolderPath } from './core'; +import { abortDownload } from './core'; +import { getFileSize } from './core'; +import { executeOnMain } from './core'; + +it('should open external url', async () => { + const url = 'http://example.com'; + globalThis.core = { + api: { + openExternalUrl: jest.fn().mockResolvedValue('opened') + } + }; + const result = await openExternalUrl(url); + expect(globalThis.core.api.openExternalUrl).toHaveBeenCalledWith(url); + expect(result).toBe('opened'); +}); + + +it('should join paths', async () => { + const paths = ['/path/one', '/path/two']; + globalThis.core = { + api: { + joinPath: jest.fn().mockResolvedValue('/path/one/path/two') + } + }; + const result = await joinPath(paths); + expect(globalThis.core.api.joinPath).toHaveBeenCalledWith(paths); + expect(result).toBe('/path/one/path/two'); +}); + + +it('should open file explorer', async () => { + const path = '/path/to/open'; + globalThis.core = { + api: { + openFileExplorer: jest.fn().mockResolvedValue('opened') + } + }; + const result = await openFileExplorer(path); + expect(globalThis.core.api.openFileExplorer).toHaveBeenCalledWith(path); + expect(result).toBe('opened'); +}); + + +it('should get jan data folder path', async () => { + globalThis.core = { + api: { + getJanDataFolderPath: jest.fn().mockResolvedValue('/path/to/jan/data') + } + }; + const result = await getJanDataFolderPath(); + expect(globalThis.core.api.getJanDataFolderPath).toHaveBeenCalled(); + expect(result).toBe('/path/to/jan/data'); +}); + + +it('should abort download', async () => { + const fileName = 'testFile'; + globalThis.core = { + api: { + abortDownload: jest.fn().mockResolvedValue('aborted') + } + }; + const result = await abortDownload(fileName); + expect(globalThis.core.api.abortDownload).toHaveBeenCalledWith(fileName); + expect(result).toBe('aborted'); +}); + + +it('should get file size', async () => { + const url = 'http://example.com/file'; + globalThis.core = { + api: { + getFileSize: jest.fn().mockResolvedValue(1024) + } + }; + const result = await getFileSize(url); + expect(globalThis.core.api.getFileSize).toHaveBeenCalledWith(url); + expect(result).toBe(1024); +}); + + +it('should execute function on main process', async () => { + const extension = 'testExtension'; + const method = 'testMethod'; + const args = ['arg1', 'arg2']; + globalThis.core = { + api: { + invokeExtensionFunc: jest.fn().mockResolvedValue('result') + } + }; + const result = await executeOnMain(extension, method, ...args); + expect(globalThis.core.api.invokeExtensionFunc).toHaveBeenCalledWith(extension, method, ...args); + expect(result).toBe('result'); +}); diff --git a/core/src/browser/events.test.ts b/core/src/browser/events.test.ts new file mode 100644 index 000000000..23b4d78d9 --- /dev/null +++ b/core/src/browser/events.test.ts @@ -0,0 +1,37 @@ +import { events } from './events'; +import { jest } from '@jest/globals'; + +it('should emit an event', () => { + const mockObject = { key: 'value' }; + globalThis.core = { + events: { + emit: jest.fn() + } + }; + events.emit('testEvent', mockObject); + expect(globalThis.core.events.emit).toHaveBeenCalledWith('testEvent', mockObject); +}); + + +it('should remove an observer for an event', () => { + const mockHandler = jest.fn(); + globalThis.core = { + events: { + off: jest.fn() + } + }; + events.off('testEvent', mockHandler); + expect(globalThis.core.events.off).toHaveBeenCalledWith('testEvent', mockHandler); +}); + + +it('should add an observer for an event', () => { + const mockHandler = jest.fn(); + globalThis.core = { + events: { + on: jest.fn() + } + }; + events.on('testEvent', mockHandler); + expect(globalThis.core.events.on).toHaveBeenCalledWith('testEvent', mockHandler); +}); diff --git a/core/src/browser/extension.test.ts b/core/src/browser/extension.test.ts new file mode 100644 index 000000000..6c1cd8579 --- /dev/null +++ b/core/src/browser/extension.test.ts @@ -0,0 +1,46 @@ +import { BaseExtension } from './extension' + +class TestBaseExtension extends BaseExtension { + onLoad(): void {} + onUnload(): void {} +} + +describe('BaseExtension', () => { + 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 + }) +}) diff --git a/core/src/browser/extensions/engines/helpers/sse.test.ts b/core/src/browser/extensions/engines/helpers/sse.test.ts new file mode 100644 index 000000000..cff5b93b3 --- /dev/null +++ b/core/src/browser/extensions/engines/helpers/sse.test.ts @@ -0,0 +1,60 @@ +import { lastValueFrom, Observable } from 'rxjs' +import { requestInference } from './sse' + +describe('requestInference', () => { + it('should send a request to the inference server and return an Observable', () => { + // 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', + // Add other required properties here + }) + ) + 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 } } + + // Call the function + const result = requestInference(inferenceUrl, requestBody, model) + + // Assert the expected behavior + expect(result).toBeInstanceOf(Observable) + expect(lastValueFrom(result)).resolves.toEqual('Generated response') + }) + + it('returns 401 error', () => { + // Mock the fetch function + const mockFetch: any = jest.fn(() => + Promise.resolve({ + ok: false, + json: () => Promise.resolve({ error: { message: 'Wrong API Key', code: 'invalid_api_key' } }), + headers: new Headers(), + redirected: false, + status: 401, + statusText: 'invalid_api_key', + // Add other required properties here + }) + ) + 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 } } + + // Call the function + const result = requestInference(inferenceUrl, requestBody, model) + + // Assert the expected behavior + expect(result).toBeInstanceOf(Observable) + expect(lastValueFrom(result)).rejects.toEqual({ message: 'Wrong API Key', code: 'invalid_api_key' }) + }) +}) diff --git a/core/src/browser/index.test.ts b/core/src/browser/index.test.ts new file mode 100644 index 000000000..339cd9046 --- /dev/null +++ b/core/src/browser/index.test.ts @@ -0,0 +1,32 @@ +import * as Core from './core'; +import * as Events from './events'; +import * as FileSystem from './fs'; +import * as Extension from './extension'; +import * as Extensions from './extensions'; +import * as Tools from './tools'; + +describe('Module Tests', () => { + it('should export Core module', () => { + expect(Core).toBeDefined(); + }); + + it('should export Event module', () => { + expect(Events).toBeDefined(); + }); + + it('should export Filesystem module', () => { + expect(FileSystem).toBeDefined(); + }); + + it('should export Extension module', () => { + expect(Extension).toBeDefined(); + }); + + it('should export all base extensions', () => { + expect(Extensions).toBeDefined(); + }); + + it('should export all base tools', () => { + expect(Tools).toBeDefined(); + }); +}); \ No newline at end of file diff --git a/core/src/node/api/common/adapter.test.ts b/core/src/node/api/common/adapter.test.ts new file mode 100644 index 000000000..38fd2857f --- /dev/null +++ b/core/src/node/api/common/adapter.test.ts @@ -0,0 +1,10 @@ +import { RequestAdapter } from './adapter'; + +it('should return undefined for unknown route', () => { + const adapter = new RequestAdapter(); + const route = 'unknownRoute'; + + const result = adapter.process(route, 'arg1', 'arg2'); + + expect(result).toBeUndefined(); +}); diff --git a/core/src/node/api/common/handler.test.ts b/core/src/node/api/common/handler.test.ts new file mode 100644 index 000000000..bd55d41cc --- /dev/null +++ b/core/src/node/api/common/handler.test.ts @@ -0,0 +1,25 @@ +import { CoreRoutes } from '../../../types/api'; +import { RequestHandler } from './handler'; +import { RequestAdapter } from './adapter'; + +it('should not call handler if CoreRoutes is empty', () => { + const mockHandler = jest.fn(); + const mockObserver = jest.fn(); + const requestHandler = new RequestHandler(mockHandler, mockObserver); + + CoreRoutes.length = 0; // Ensure CoreRoutes is empty + + requestHandler.handle(); + + expect(mockHandler).not.toHaveBeenCalled(); +}); + + +it('should initialize handler and adapter correctly', () => { + const mockHandler = jest.fn(); + const mockObserver = jest.fn(); + const requestHandler = new RequestHandler(mockHandler, mockObserver); + + expect(requestHandler.handler).toBe(mockHandler); + expect(requestHandler.adapter).toBeInstanceOf(RequestAdapter); +}); diff --git a/core/src/node/api/processors/app.test.ts b/core/src/node/api/processors/app.test.ts new file mode 100644 index 000000000..3ada5df1e --- /dev/null +++ b/core/src/node/api/processors/app.test.ts @@ -0,0 +1,40 @@ +import { App } from './app'; + +it('should call stopServer', () => { + const app = new App(); + const stopServerMock = jest.fn().mockResolvedValue('Server stopped'); + jest.mock('@janhq/server', () => ({ + stopServer: stopServerMock + })); + const result = app.stopServer(); + expect(stopServerMock).toHaveBeenCalled(); +}); + +it('should correctly retrieve basename', () => { + const app = new App(); + const result = app.baseName('/path/to/file.txt'); + expect(result).toBe('file.txt'); +}); + +it('should correctly identify subdirectories', () => { + const app = new App(); + const basePath = process.platform === 'win32' ? 'C:\\path\\to' : '/path/to'; + const subPath = process.platform === 'win32' ? 'C:\\path\\to\\subdir' : '/path/to/subdir'; + const result = app.isSubdirectory(basePath, subPath); + expect(result).toBe(true); +}); + +it('should correctly join multiple paths', () => { + const app = new App(); + const result = app.joinPath(['path', 'to', 'file']); + const expectedPath = process.platform === 'win32' ? 'path\\to\\file' : 'path/to/file'; + expect(result).toBe(expectedPath); +}); + +it('should call correct function with provided arguments using process method', () => { + const app = new App(); + const mockFunc = jest.fn(); + app.joinPath = mockFunc; + app.process('joinPath', ['path1', 'path2']); + expect(mockFunc).toHaveBeenCalledWith(['path1', 'path2']); +}); diff --git a/core/src/node/api/processors/download.test.ts b/core/src/node/api/processors/download.test.ts new file mode 100644 index 000000000..1dc0eefb8 --- /dev/null +++ b/core/src/node/api/processors/download.test.ts @@ -0,0 +1,59 @@ +import { Downloader } from './download'; +import { DownloadEvent } from '../../../types/api'; +import { DownloadManager } from '../../helper/download'; + +it('should handle getFileSize errors correctly', async () => { + const observer = jest.fn(); + const url = 'http://example.com/file'; + + const downloader = new Downloader(observer); + const requestMock = jest.fn((options, callback) => { + callback(new Error('Test error'), null); + }); + jest.mock('request', () => requestMock); + + await expect(downloader.getFileSize(observer, url)).rejects.toThrow('Test error'); +}); + + +it('should pause download correctly', () => { + const observer = jest.fn(); + const fileName = process.platform === 'win32' ? 'C:\\path\\to\\file' : 'path/to/file'; + + const downloader = new Downloader(observer); + const pauseMock = jest.fn(); + DownloadManager.instance.networkRequests[fileName] = { pause: pauseMock }; + + downloader.pauseDownload(observer, fileName); + + expect(pauseMock).toHaveBeenCalled(); +}); + +it('should resume download correctly', () => { + const observer = jest.fn(); + const fileName = process.platform === 'win32' ? 'C:\\path\\to\\file' : 'path/to/file'; + + const downloader = new Downloader(observer); + const resumeMock = jest.fn(); + DownloadManager.instance.networkRequests[fileName] = { resume: resumeMock }; + + downloader.resumeDownload(observer, fileName); + + expect(resumeMock).toHaveBeenCalled(); +}); + +it('should handle aborting a download correctly', () => { + const observer = jest.fn(); + const fileName = process.platform === 'win32' ? 'C:\\path\\to\\file' : 'path/to/file'; + + const downloader = new Downloader(observer); + const abortMock = jest.fn(); + DownloadManager.instance.networkRequests[fileName] = { abort: abortMock }; + + downloader.abortDownload(observer, fileName); + + expect(abortMock).toHaveBeenCalled(); + expect(observer).toHaveBeenCalledWith(DownloadEvent.onFileDownloadError, expect.objectContaining({ + error: 'aborted' + })); +}); diff --git a/core/src/node/api/processors/extension.test.ts b/core/src/node/api/processors/extension.test.ts new file mode 100644 index 000000000..917883499 --- /dev/null +++ b/core/src/node/api/processors/extension.test.ts @@ -0,0 +1,9 @@ +import { Extension } from './extension'; + +it('should call function associated with key in process method', () => { + const mockFunc = jest.fn(); + const extension = new Extension(); + (extension as any).testKey = mockFunc; + extension.process('testKey', 'arg1', 'arg2'); + expect(mockFunc).toHaveBeenCalledWith('arg1', 'arg2'); +}); diff --git a/core/src/node/api/processors/fs.test.ts b/core/src/node/api/processors/fs.test.ts new file mode 100644 index 000000000..3cac2e2ff --- /dev/null +++ b/core/src/node/api/processors/fs.test.ts @@ -0,0 +1,18 @@ +import { FileSystem } from './fs'; + +it('should throw an error when the route does not exist in process', async () => { + const fileSystem = new FileSystem(); + await expect(fileSystem.process('nonExistentRoute', 'arg1')).rejects.toThrow(); +}); + + +it('should throw an error for invalid argument in mkdir', async () => { + const fileSystem = new FileSystem(); + expect(() => fileSystem.mkdir(123)).toThrow('mkdir error: Invalid argument [123]'); +}); + + +it('should throw an error for invalid argument in rm', async () => { + const fileSystem = new FileSystem(); + expect(() => fileSystem.rm(123)).toThrow('rm error: Invalid argument [123]'); +}); diff --git a/core/src/node/api/processors/fsExt.test.ts b/core/src/node/api/processors/fsExt.test.ts new file mode 100644 index 000000000..bfc54897a --- /dev/null +++ b/core/src/node/api/processors/fsExt.test.ts @@ -0,0 +1,34 @@ +import { FSExt } from './fsExt'; +import { defaultAppConfig } from '../../helper'; + +it('should handle errors in writeBlob', () => { + const fsExt = new FSExt(); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + fsExt.writeBlob('invalid-path', 'data'); + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); +}); + +it('should call correct function in process method', () => { + const fsExt = new FSExt(); + const mockFunction = jest.fn(); + (fsExt as any).mockFunction = mockFunction; + fsExt.process('mockFunction', 'arg1', 'arg2'); + expect(mockFunction).toHaveBeenCalledWith('arg1', 'arg2'); +}); + + +it('should return correct user home path', () => { + const fsExt = new FSExt(); + const userHomePath = fsExt.getUserHomePath(); + expect(userHomePath).toBe(defaultAppConfig().data_folder); +}); + + + +it('should return empty array when no files are provided', async () => { + const fsExt = new FSExt(); + const result = await fsExt.getGgufFiles([]); + expect(result.supportedFiles).toEqual([]); + expect(result.unsupportedFiles).toEqual([]); +}); diff --git a/core/src/node/api/processors/processor.test.ts b/core/src/node/api/processors/processor.test.ts new file mode 100644 index 000000000..e69de29bb diff --git a/core/src/node/api/restful/app/download.test.ts b/core/src/node/api/restful/app/download.test.ts new file mode 100644 index 000000000..b2af1bb0d --- /dev/null +++ b/core/src/node/api/restful/app/download.test.ts @@ -0,0 +1,62 @@ +import { HttpServer } from '../../HttpServer' +import { DownloadManager } from '../../../helper/download' + +describe('downloadRouter', () => { + let app: HttpServer + + beforeEach(() => { + app = { + register: jest.fn(), + post: jest.fn(), + get: jest.fn(), + patch: jest.fn(), + put: jest.fn(), + delete: jest.fn(), + } + }) + + it('should return download progress for a given modelId', async () => { + const modelId = '123' + const downloadProgress = { progress: 50 } + + DownloadManager.instance.downloadProgressMap[modelId] = downloadProgress as any + + const req = { params: { modelId } } + const res = { + status: jest.fn(), + send: jest.fn(), + } + + jest.spyOn(app, 'get').mockImplementation((path, handler) => { + if (path === `/download/getDownloadProgress/${modelId}`) { + res.status(200) + res.send(downloadProgress) + } + }) + + app.get(`/download/getDownloadProgress/${modelId}`, req as any) + expect(res.status).toHaveBeenCalledWith(200) + expect(res.send).toHaveBeenCalledWith(downloadProgress) + }) + + it('should return 404 if download progress is not found', async () => { + const modelId = '123' + + const req = { params: { modelId } } + const res = { + status: jest.fn(), + send: jest.fn(), + } + + + jest.spyOn(app, 'get').mockImplementation((path, handler) => { + if (path === `/download/getDownloadProgress/${modelId}`) { + res.status(404) + res.send({ message: 'Download progress not found' }) + } + }) + app.get(`/download/getDownloadProgress/${modelId}`, req as any) + expect(res.status).toHaveBeenCalledWith(404) + expect(res.send).toHaveBeenCalledWith({ message: 'Download progress not found' }) + }) +}) diff --git a/core/src/node/api/restful/app/handlers.test.ts b/core/src/node/api/restful/app/handlers.test.ts new file mode 100644 index 000000000..680623d86 --- /dev/null +++ b/core/src/node/api/restful/app/handlers.test.ts @@ -0,0 +1,16 @@ +// +import { jest } from '@jest/globals'; + +import { HttpServer } from '../../HttpServer'; +import { handleRequests } from './handlers'; +import { Handler, RequestHandler } from '../../common/handler'; + +it('should initialize RequestHandler and call handle', () => { + const mockHandle = jest.fn(); + jest.spyOn(RequestHandler.prototype, 'handle').mockImplementation(mockHandle); + + const mockApp = { post: jest.fn() }; + handleRequests(mockApp as unknown as HttpServer); + + expect(mockHandle).toHaveBeenCalled(); +}); diff --git a/core/src/node/api/restful/common.test.ts b/core/src/node/api/restful/common.test.ts new file mode 100644 index 000000000..b40f6606f --- /dev/null +++ b/core/src/node/api/restful/common.test.ts @@ -0,0 +1,21 @@ + +import { commonRouter } from './common'; +import { JanApiRouteConfiguration } from './helper/configuration'; + +test('commonRouter sets up routes for each key in JanApiRouteConfiguration', async () => { + const mockHttpServer = { + get: jest.fn(), + post: jest.fn(), + patch: jest.fn(), + put: jest.fn(), + delete: jest.fn(), + }; + await commonRouter(mockHttpServer as any); + + const expectedRoutes = Object.keys(JanApiRouteConfiguration); + expectedRoutes.forEach((key) => { + expect(mockHttpServer.get).toHaveBeenCalledWith(`/${key}`, expect.any(Function)); + expect(mockHttpServer.get).toHaveBeenCalledWith(`/${key}/:id`, expect.any(Function)); + expect(mockHttpServer.delete).toHaveBeenCalledWith(`/${key}/:id`, expect.any(Function)); + }); +}); diff --git a/core/src/node/api/restful/helper/configuration.test.ts b/core/src/node/api/restful/helper/configuration.test.ts new file mode 100644 index 000000000..ae002312a --- /dev/null +++ b/core/src/node/api/restful/helper/configuration.test.ts @@ -0,0 +1,24 @@ +import { JanApiRouteConfiguration } from './configuration' + +describe('JanApiRouteConfiguration', () => { + it('should have the correct models configuration', () => { + const modelsConfig = JanApiRouteConfiguration.models; + expect(modelsConfig.dirName).toBe('models'); + expect(modelsConfig.metadataFileName).toBe('model.json'); + expect(modelsConfig.delete.object).toBe('model'); + }); + + it('should have the correct assistants configuration', () => { + const assistantsConfig = JanApiRouteConfiguration.assistants; + expect(assistantsConfig.dirName).toBe('assistants'); + expect(assistantsConfig.metadataFileName).toBe('assistant.json'); + expect(assistantsConfig.delete.object).toBe('assistant'); + }); + + it('should have the correct threads configuration', () => { + const threadsConfig = JanApiRouteConfiguration.threads; + expect(threadsConfig.dirName).toBe('threads'); + expect(threadsConfig.metadataFileName).toBe('thread.json'); + expect(threadsConfig.delete.object).toBe('thread'); + }); +}); \ No newline at end of file diff --git a/core/src/node/api/restful/v1.test.ts b/core/src/node/api/restful/v1.test.ts new file mode 100644 index 000000000..8e22496e9 --- /dev/null +++ b/core/src/node/api/restful/v1.test.ts @@ -0,0 +1,16 @@ + +import { v1Router } from './v1'; +import { commonRouter } from './common'; + +test('should define v1Router function', () => { + expect(v1Router).toBeDefined(); +}); + +test('should register commonRouter', () => { + const mockApp = { + register: jest.fn(), + }; + v1Router(mockApp as any); + expect(mockApp.register).toHaveBeenCalledWith(commonRouter); +}); + diff --git a/core/src/node/extension/extension.test.ts b/core/src/node/extension/extension.test.ts new file mode 100644 index 000000000..c43b5c0cb --- /dev/null +++ b/core/src/node/extension/extension.test.ts @@ -0,0 +1,122 @@ +import Extension from './extension'; +import { join } from 'path'; +import 'pacote'; + +it('should set active and call emitUpdate', () => { + const extension = new Extension(); + extension.emitUpdate = jest.fn(); + + extension.setActive(true); + + expect(extension._active).toBe(true); + expect(extension.emitUpdate).toHaveBeenCalled(); +}); + + +it('should return correct specifier', () => { + const origin = 'test-origin'; + const options = { version: '1.0.0' }; + const extension = new Extension(origin, options); + + expect(extension.specifier).toBe('test-origin@1.0.0'); +}); + + +it('should set origin and installOptions in constructor', () => { + const origin = 'test-origin'; + const options = { someOption: true }; + const extension = new Extension(origin, options); + + expect(extension.origin).toBe(origin); + expect(extension.installOptions.someOption).toBe(true); + expect(extension.installOptions.fullMetadata).toBe(true); // default option +}); + +it('should install extension and set url', async () => { + const origin = 'test-origin'; + const options = {}; + const extension = new Extension(origin, options); + + const mockManifest = { + name: 'test-name', + productName: 'Test Product', + version: '1.0.0', + main: 'index.js', + description: 'Test description' + }; + + jest.mock('pacote', () => ({ + manifest: jest.fn().mockResolvedValue(mockManifest), + extract: jest.fn().mockResolvedValue(null) + })); + + extension.emitUpdate = jest.fn(); + await extension._install(); + + expect(extension.url).toBe('extension://test-name/index.js'); + expect(extension.emitUpdate).toHaveBeenCalled(); +}); + + +it('should call all listeners in emitUpdate', () => { + const extension = new Extension(); + const callback1 = jest.fn(); + const callback2 = jest.fn(); + + extension.subscribe('listener1', callback1); + extension.subscribe('listener2', callback2); + + extension.emitUpdate(); + + expect(callback1).toHaveBeenCalledWith(extension); + expect(callback2).toHaveBeenCalledWith(extension); +}); + + +it('should remove listener in unsubscribe', () => { + const extension = new Extension(); + const callback = jest.fn(); + + extension.subscribe('testListener', callback); + extension.unsubscribe('testListener'); + + expect(extension.listeners['testListener']).toBeUndefined(); +}); + + +it('should add listener in subscribe', () => { + const extension = new Extension(); + const callback = jest.fn(); + + extension.subscribe('testListener', callback); + + expect(extension.listeners['testListener']).toBe(callback); +}); + + +it('should set properties from manifest', async () => { + const origin = 'test-origin'; + const options = {}; + const extension = new Extension(origin, options); + + const mockManifest = { + name: 'test-name', + productName: 'Test Product', + version: '1.0.0', + main: 'index.js', + description: 'Test description' + }; + + jest.mock('pacote', () => ({ + manifest: jest.fn().mockResolvedValue(mockManifest) + })); + + await extension.getManifest(); + + expect(extension.name).toBe('test-name'); + expect(extension.productName).toBe('Test Product'); + expect(extension.version).toBe('1.0.0'); + expect(extension.main).toBe('index.js'); + expect(extension.description).toBe('Test description'); +}); + diff --git a/core/src/node/extension/manager.test.ts b/core/src/node/extension/manager.test.ts new file mode 100644 index 000000000..1c8123d21 --- /dev/null +++ b/core/src/node/extension/manager.test.ts @@ -0,0 +1,28 @@ +import * as fs from 'fs'; +import { join } from 'path'; +import { ExtensionManager } from './manager'; + +it('should throw an error when an invalid path is provided', () => { + const manager = new ExtensionManager(); + jest.spyOn(fs, 'existsSync').mockReturnValue(false); + expect(() => manager.setExtensionsPath('')).toThrow('Invalid path provided to the extensions folder'); +}); + + +it('should return an empty string when extensionsPath is not set', () => { + const manager = new ExtensionManager(); + expect(manager.getExtensionsFile()).toBe(join('', 'extensions.json')); +}); + + +it('should return undefined if no path is set', () => { + const manager = new ExtensionManager(); + expect(manager.getExtensionsPath()).toBeUndefined(); +}); + + +it('should return the singleton instance', () => { + const instance1 = new ExtensionManager(); + const instance2 = new ExtensionManager(); + expect(instance1).toBe(instance2); +}); diff --git a/core/src/node/extension/store.test.ts b/core/src/node/extension/store.test.ts new file mode 100644 index 000000000..cbaa84f7c --- /dev/null +++ b/core/src/node/extension/store.test.ts @@ -0,0 +1,43 @@ +import { getAllExtensions } from './store'; +import { getActiveExtensions } from './store'; +import { getExtension } from './store'; + +test('should return empty array when no extensions added', () => { + expect(getAllExtensions()).toEqual([]); +}); + + +test('should throw error when extension does not exist', () => { + expect(() => getExtension('nonExistentExtension')).toThrow('Extension nonExistentExtension does not exist'); +}); + +import { addExtension } from './store'; +import Extension from './extension'; + +test('should return all extensions when multiple extensions added', () => { + const ext1 = new Extension('ext1'); + ext1.name = 'ext1'; + const ext2 = new Extension('ext2'); + ext2.name = 'ext2'; + + addExtension(ext1, false); + addExtension(ext2, false); + + expect(getAllExtensions()).toEqual([ext1, ext2]); +}); + + + +test('should return only active extensions', () => { + const ext1 = new Extension('ext1'); + ext1.name = 'ext1'; + ext1.setActive(true); + const ext2 = new Extension('ext2'); + ext2.name = 'ext2'; + ext2.setActive(false); + + addExtension(ext1, false); + addExtension(ext2, false); + + expect(getActiveExtensions()).toEqual([ext1]); +}); diff --git a/core/src/node/helper/config.test.ts b/core/src/node/helper/config.test.ts new file mode 100644 index 000000000..201a98141 --- /dev/null +++ b/core/src/node/helper/config.test.ts @@ -0,0 +1,14 @@ +import { getEngineConfiguration } from './config'; +import { getAppConfigurations, defaultAppConfig } from './config'; + +it('should return undefined for invalid engine ID', async () => { + const config = await getEngineConfiguration('invalid_engine'); + expect(config).toBeUndefined(); +}); + + +it('should return default config when CI is e2e', () => { + process.env.CI = 'e2e'; + const config = getAppConfigurations(); + expect(config).toEqual(defaultAppConfig()); +}); diff --git a/core/src/node/helper/download.test.ts b/core/src/node/helper/download.test.ts new file mode 100644 index 000000000..95cc553b5 --- /dev/null +++ b/core/src/node/helper/download.test.ts @@ -0,0 +1,11 @@ +import { DownloadManager } from './download'; + +it('should set a network request for a specific file', () => { + const downloadManager = new DownloadManager(); + const fileName = 'testFile'; + const request = { url: 'http://example.com' }; + + downloadManager.setRequest(fileName, request); + + expect(downloadManager.networkRequests[fileName]).toEqual(request); +}); diff --git a/core/src/node/helper/logger.test.ts b/core/src/node/helper/logger.test.ts new file mode 100644 index 000000000..0f44bfcd4 --- /dev/null +++ b/core/src/node/helper/logger.test.ts @@ -0,0 +1,47 @@ +import { Logger, LoggerManager } from './logger'; + + it('should flush queued logs to registered loggers', () => { + class TestLogger extends Logger { + name = 'testLogger'; + log(args: any): void { + console.log(args); + } + } + const loggerManager = new LoggerManager(); + const testLogger = new TestLogger(); + loggerManager.register(testLogger); + const logSpy = jest.spyOn(testLogger, 'log'); + loggerManager.log('test log'); + expect(logSpy).toHaveBeenCalledWith('test log'); + }); + + + it('should unregister a logger', () => { + class TestLogger extends Logger { + name = 'testLogger'; + log(args: any): void { + console.log(args); + } + } + const loggerManager = new LoggerManager(); + const testLogger = new TestLogger(); + loggerManager.register(testLogger); + loggerManager.unregister('testLogger'); + const retrievedLogger = loggerManager.get('testLogger'); + expect(retrievedLogger).toBeUndefined(); + }); + + + it('should register and retrieve a logger', () => { + class TestLogger extends Logger { + name = 'testLogger'; + log(args: any): void { + console.log(args); + } + } + const loggerManager = new LoggerManager(); + const testLogger = new TestLogger(); + loggerManager.register(testLogger); + const retrievedLogger = loggerManager.get('testLogger'); + expect(retrievedLogger).toBe(testLogger); + }); diff --git a/core/src/node/helper/module.test.ts b/core/src/node/helper/module.test.ts new file mode 100644 index 000000000..bb8327cbf --- /dev/null +++ b/core/src/node/helper/module.test.ts @@ -0,0 +1,23 @@ +import { ModuleManager } from './module'; + +it('should clear all imported modules', () => { + const moduleManager = new ModuleManager(); + moduleManager.setModule('module1', { key: 'value1' }); + moduleManager.setModule('module2', { key: 'value2' }); + moduleManager.clearImportedModules(); + expect(moduleManager.requiredModules).toEqual({}); +}); + + +it('should set a module correctly', () => { + const moduleManager = new ModuleManager(); + moduleManager.setModule('testModule', { key: 'value' }); + expect(moduleManager.requiredModules['testModule']).toEqual({ key: 'value' }); +}); + + +it('should return the singleton instance', () => { + const instance1 = new ModuleManager(); + const instance2 = new ModuleManager(); + expect(instance1).toBe(instance2); +}); diff --git a/core/src/node/helper/path.test.ts b/core/src/node/helper/path.test.ts new file mode 100644 index 000000000..f9a3b5766 --- /dev/null +++ b/core/src/node/helper/path.test.ts @@ -0,0 +1,29 @@ +import { normalizeFilePath } from './path' + +import { jest } from '@jest/globals' +describe('Test file normalize', () => { + test('returns no file protocol prefix on Unix', async () => { + expect(normalizeFilePath('file://test.txt')).toBe('test.txt') + expect(normalizeFilePath('file:/test.txt')).toBe('test.txt') + }) + test('returns no file protocol prefix on Windows', async () => { + expect(normalizeFilePath('file:\\\\test.txt')).toBe('test.txt') + expect(normalizeFilePath('file:\\test.txt')).toBe('test.txt') + }) + + test('returns correct path when Electron is available and app is not packaged', () => { + const electronMock = { + app: { + getAppPath: jest.fn().mockReturnValue('/mocked/path'), + isPackaged: false, + }, + protocol: {}, + } + jest.mock('electron', () => electronMock) + + const { appResourcePath } = require('./path') + + const expectedPath = process.platform === 'win32' ? '\\mocked\\path' : '/mocked/path' + expect(appResourcePath()).toBe(expectedPath) + }) +}) diff --git a/core/src/node/helper/resource.test.ts b/core/src/node/helper/resource.test.ts new file mode 100644 index 000000000..aaeab9d65 --- /dev/null +++ b/core/src/node/helper/resource.test.ts @@ -0,0 +1,15 @@ +import { getSystemResourceInfo } from './resource'; + +it('should return the correct system resource information with a valid CPU count', async () => { + const mockCpuCount = 4; + jest.spyOn(require('./config'), 'physicalCpuCount').mockResolvedValue(mockCpuCount); + const logSpy = jest.spyOn(require('./logger'), 'log').mockImplementation(() => {}); + + const result = await getSystemResourceInfo(); + + expect(result).toEqual({ + numCpuPhysicalCore: mockCpuCount, + memAvailable: 0, + }); + expect(logSpy).toHaveBeenCalledWith(`[CORTEX]::CPU information - ${mockCpuCount}`); +}); diff --git a/core/src/types/api/index.ts b/core/src/types/api/index.ts index e1d1b28da..bca11c0a8 100644 --- a/core/src/types/api/index.ts +++ b/core/src/types/api/index.ts @@ -55,6 +55,7 @@ export enum AppEvent { onSelectedText = 'onSelectedText', onDeepLink = 'onDeepLink', + onMainViewStateChange = 'onMainViewStateChange', } export enum DownloadRoute { diff --git a/core/src/types/assistant/assistantEvent.test.ts b/core/src/types/assistant/assistantEvent.test.ts new file mode 100644 index 000000000..4b1ed552c --- /dev/null +++ b/core/src/types/assistant/assistantEvent.test.ts @@ -0,0 +1,7 @@ +import { AssistantEvent } from './assistantEvent'; +it('dummy test', () => { expect(true).toBe(true); }); + +it('should contain OnAssistantsUpdate event', () => { + expect(AssistantEvent.OnAssistantsUpdate).toBe('OnAssistantsUpdate'); +}); + diff --git a/core/src/types/file/index.ts b/core/src/types/file/index.ts index d941987ef..1b36a5777 100644 --- a/core/src/types/file/index.ts +++ b/core/src/types/file/index.ts @@ -16,7 +16,7 @@ export type DownloadState = { error?: string extensionId?: string - downloadType?: DownloadType + downloadType?: DownloadType | string localPath?: string } @@ -40,7 +40,7 @@ export type DownloadRequest = { */ extensionId?: string - downloadType?: DownloadType + downloadType?: DownloadType | string } type DownloadTime = { diff --git a/core/testRunner.js b/core/testRunner.js new file mode 100644 index 000000000..b0d108160 --- /dev/null +++ b/core/testRunner.js @@ -0,0 +1,10 @@ +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; \ No newline at end of file diff --git a/core/tests/node/path.test.ts b/core/tests/node/path.test.ts deleted file mode 100644 index 5390df119..000000000 --- a/core/tests/node/path.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { normalizeFilePath } from "../../src/node/helper/path"; - -describe("Test file normalize", () => { - test("returns no file protocol prefix on Unix", async () => { - expect(normalizeFilePath("file://test.txt")).toBe("test.txt"); - expect(normalizeFilePath("file:/test.txt")).toBe("test.txt"); - }); - test("returns no file protocol prefix on Windows", async () => { - expect(normalizeFilePath("file:\\\\test.txt")).toBe("test.txt"); - expect(normalizeFilePath("file:\\test.txt")).toBe("test.txt"); - }); -}); diff --git a/core/tsconfig.json b/core/tsconfig.json index daeb7eeff..02caf65e2 100644 --- a/core/tsconfig.json +++ b/core/tsconfig.json @@ -16,4 +16,5 @@ "types": ["@types/jest"], }, "include": ["src"], + "exclude": ["**/*.test.ts"] } diff --git a/electron/managers/mainWindowConfig.ts b/electron/managers/mainWindowConfig.ts index 82c437106..997d081c3 100644 --- a/electron/managers/mainWindowConfig.ts +++ b/electron/managers/mainWindowConfig.ts @@ -6,9 +6,10 @@ export const mainWindowConfig: Electron.BrowserWindowConstructorOptions = { minWidth: DEFAULT_MIN_WIDTH, minHeight: DEFAULT_MIN_HEIGHT, show: true, - transparent: true, - frame: false, - titleBarStyle: 'hidden', + // we want to go frameless on windows and linux + transparent: process.platform === 'darwin', + frame: process.platform === 'darwin', + titleBarStyle: 'hiddenInset', vibrancy: 'fullscreen-ui', visualEffectState: 'active', backgroundMaterial: 'acrylic', diff --git a/electron/managers/window.ts b/electron/managers/window.ts index 3d5107b28..c9c43ea77 100644 --- a/electron/managers/window.ts +++ b/electron/managers/window.ts @@ -166,6 +166,15 @@ class WindowManager { }, 500) } + /** + * Send main view state to the main app. + */ + sendMainViewState(route: string) { + if (this.mainWindow && !this.mainWindow.isDestroyed()) { + this.mainWindow.webContents.send(AppEvent.onMainViewStateChange, route) + } + } + cleanUp(): void { if (!this.mainWindow?.isDestroyed()) { this.mainWindow?.close() diff --git a/electron/utils/menu.ts b/electron/utils/menu.ts index 3f838e5ca..553412faf 100644 --- a/electron/utils/menu.ts +++ b/electron/utils/menu.ts @@ -3,6 +3,7 @@ import { app, Menu, shell, dialog } from 'electron' import { autoUpdater } from 'electron-updater' import { log } from '@janhq/core/node' const isMac = process.platform === 'darwin' +import { windowManager } from '../managers/window' const template: (Electron.MenuItemConstructorOptions | Electron.MenuItem)[] = [ { @@ -43,6 +44,14 @@ const template: (Electron.MenuItemConstructorOptions | Electron.MenuItem)[] = [ { role: 'hide' }, { role: 'hideOthers' }, { role: 'unhide' }, + { + label: `Settings`, + accelerator: 'CmdOrCtrl+,', + click: () => { + windowManager.showMainWindow() + windowManager.sendMainViewState('Settings') + }, + }, { type: 'separator' }, { role: 'quit' }, ], diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 000000000..a911a7f0a --- /dev/null +++ b/jest.config.js @@ -0,0 +1,3 @@ +module.exports = { + projects: ['/core', '/web', '/joi'], +} diff --git a/joi/jest.config.js b/joi/jest.config.js new file mode 100644 index 000000000..8543f24e3 --- /dev/null +++ b/joi/jest.config.js @@ -0,0 +1,8 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: ['**/*.test.*'], + setupFilesAfterEnv: ['/jest.setup.js'], + testEnvironment: 'jsdom', +} diff --git a/joi/jest.setup.js b/joi/jest.setup.js new file mode 100644 index 000000000..e69de29bb diff --git a/joi/package.json b/joi/package.json index 3f1bd07f7..c336cce12 100644 --- a/joi/package.json +++ b/joi/package.json @@ -21,7 +21,8 @@ "bugs": "https://github.com/codecentrum/piksel/issues", "scripts": { "dev": "rollup -c -w", - "build": "rimraf ./dist && rollup -c" + "build": "rimraf ./dist && rollup -c", + "test": "jest" }, "peerDependencies": { "class-variance-authority": "^0.7.0", @@ -38,13 +39,22 @@ "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-tooltip": "^1.0.7", - "tailwind-merge": "^2.2.0", + "@types/jest": "^29.5.12", "autoprefixer": "10.4.16", - "tailwindcss": "^3.4.1" + "jest": "^29.7.0", + "tailwind-merge": "^2.2.0", + "tailwindcss": "^3.4.1", + "ts-jest": "^29.2.5" }, "devDependencies": { "@rollup/plugin-node-resolve": "^15.2.3", "@rollup/plugin-terser": "^0.4.4", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.5.0", + "@testing-library/react": "^16.0.1", + "@types/jest": "^29.5.12", + "jest-environment-jsdom": "^29.7.0", + "jest-transform-css": "^6.0.1", "prettier": "^3.0.3", "prettier-plugin-tailwindcss": "^0.5.6", "rollup": "^4.12.0", diff --git a/joi/src/core/Accordion/Accordion.test.tsx b/joi/src/core/Accordion/Accordion.test.tsx new file mode 100644 index 000000000..62b575ea3 --- /dev/null +++ b/joi/src/core/Accordion/Accordion.test.tsx @@ -0,0 +1,64 @@ +import React from 'react' +import '@testing-library/jest-dom' +import { render, screen, fireEvent } from '@testing-library/react' +import { Accordion, AccordionItem } from './index' + +// Mock the SCSS import +jest.mock('./styles.scss', () => ({})) + +describe('Accordion', () => { + it('renders accordion with items', () => { + render( + + + Content 1 + + + Content 2 + + + ) + + expect(screen.getByText('Item 1')).toBeInTheDocument() + expect(screen.getByText('Item 2')).toBeInTheDocument() + }) + + it('expands and collapses accordion items', () => { + render( + + + Content 1 + + + ) + + const trigger = screen.getByText('Item 1') + + // Initially, content should not be visible + expect(screen.queryByText('Content 1')).not.toBeInTheDocument() + + // Click to expand + fireEvent.click(trigger) + expect(screen.getByText('Content 1')).toBeInTheDocument() + + // Click to collapse + fireEvent.click(trigger) + expect(screen.queryByText('Content 1')).not.toBeInTheDocument() + }) + + it('respects defaultValue prop', () => { + render( + + + Content 1 + + + Content 2 + + + ) + + expect(screen.queryByText('Content 1')).not.toBeInTheDocument() + expect(screen.getByText('Content 2')).toBeInTheDocument() + }) +}) diff --git a/joi/src/core/Badge/Badge.test.tsx b/joi/src/core/Badge/Badge.test.tsx new file mode 100644 index 000000000..1d3192be7 --- /dev/null +++ b/joi/src/core/Badge/Badge.test.tsx @@ -0,0 +1,83 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import { Badge, badgeConfig } from './index' + +// Mock the styles +jest.mock('./styles.scss', () => ({})) + +describe('@joi/core/Badge', () => { + it('renders with default props', () => { + render(Test Badge) + const badge = screen.getByText('Test Badge') + expect(badge).toBeInTheDocument() + expect(badge).toHaveClass('badge') + expect(badge).toHaveClass('badge--primary') + expect(badge).toHaveClass('badge--medium') + expect(badge).toHaveClass('badge--solid') + }) + + it('applies custom className', () => { + render(Test Badge) + const badge = screen.getByText('Test Badge') + expect(badge).toHaveClass('custom-class') + }) + + it('renders with different themes', () => { + const themes = Object.keys(badgeConfig.variants.theme) + themes.forEach((theme) => { + render(Test Badge {theme}) + const badge = screen.getByText(`Test Badge ${theme}`) + expect(badge).toHaveClass(`badge--${theme}`) + }) + }) + + it('renders with different variants', () => { + const variants = Object.keys(badgeConfig.variants.variant) + variants.forEach((variant) => { + render(Test Badge {variant}) + const badge = screen.getByText(`Test Badge ${variant}`) + expect(badge).toHaveClass(`badge--${variant}`) + }) + }) + + it('renders with different sizes', () => { + const sizes = Object.keys(badgeConfig.variants.size) + sizes.forEach((size) => { + render(Test Badge {size}) + const badge = screen.getByText(`Test Badge ${size}`) + expect(badge).toHaveClass(`badge--${size}`) + }) + }) + + it('fails when a new theme is added without updating the test', () => { + const expectedThemes = [ + 'primary', + 'secondary', + 'warning', + 'success', + 'info', + 'destructive', + ] + const actualThemes = Object.keys(badgeConfig.variants.theme) + expect(actualThemes).toEqual(expectedThemes) + }) + + it('fails when a new variant is added without updating the test', () => { + const expectedVariant = ['solid', 'soft', 'outline'] + const actualVariants = Object.keys(badgeConfig.variants.variant) + expect(actualVariants).toEqual(expectedVariant) + }) + + it('fails when a new size is added without updating the test', () => { + const expectedSizes = ['small', 'medium', 'large'] + const actualSizes = Object.keys(badgeConfig.variants.size) + expect(actualSizes).toEqual(expectedSizes) + }) + + it('fails when a new variant CVA is added without updating the test', () => { + const expectedVariantsCVA = ['theme', 'variant', 'size'] + const actualVariant = Object.keys(badgeConfig.variants) + expect(actualVariant).toEqual(expectedVariantsCVA) + }) +}) diff --git a/joi/src/core/Badge/index.tsx b/joi/src/core/Badge/index.tsx index ffc34624f..d9b04fd2b 100644 --- a/joi/src/core/Badge/index.tsx +++ b/joi/src/core/Badge/index.tsx @@ -6,7 +6,7 @@ import { twMerge } from 'tailwind-merge' import './styles.scss' -const badgeVariants = cva('badge', { +export const badgeConfig = { variants: { theme: { primary: 'badge--primary', @@ -28,11 +28,13 @@ const badgeVariants = cva('badge', { }, }, defaultVariants: { - theme: 'primary', - size: 'medium', - variant: 'solid', + theme: 'primary' as const, + size: 'medium' as const, + variant: 'solid' as const, }, -}) +} + +const badgeVariants = cva('badge', badgeConfig) export interface BadgeProps extends HTMLAttributes, diff --git a/joi/src/core/Button/Button.test.tsx b/joi/src/core/Button/Button.test.tsx new file mode 100644 index 000000000..3ff76143c --- /dev/null +++ b/joi/src/core/Button/Button.test.tsx @@ -0,0 +1,68 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import { Button, buttonConfig } from './index' + +// Mock the styles +jest.mock('./styles.scss', () => ({})) + +describe('Button', () => { + it('renders with default props', () => { + render() + const button = screen.getByRole('button', { name: /click me/i }) + expect(button).toBeInTheDocument() + expect(button).toHaveClass('btn btn--primary btn--medium btn--solid') + }) + + it('renders as a child component when asChild is true', () => { + render( + + ) + const link = screen.getByRole('link', { name: /link button/i }) + expect(link).toBeInTheDocument() + expect(link).toHaveClass('btn btn--primary btn--medium btn--solid') + }) + + it.each(Object.keys(buttonConfig.variants.theme))( + 'renders with theme %s', + (theme) => { + render() + const button = screen.getByRole('button', { name: /theme button/i }) + expect(button).toHaveClass(`btn btn--${theme}`) + } + ) + + it.each(Object.keys(buttonConfig.variants.variant))( + 'renders with variant %s', + (variant) => { + render() + const button = screen.getByRole('button', { name: /variant button/i }) + expect(button).toHaveClass(`btn btn--${variant}`) + } + ) + + it.each(Object.keys(buttonConfig.variants.size))( + 'renders with size %s', + (size) => { + render() + const button = screen.getByRole('button', { name: /size button/i }) + expect(button).toHaveClass(`btn btn--${size}`) + } + ) + + it('renders with block prop', () => { + render() + const button = screen.getByRole('button', { name: /block button/i }) + expect(button).toHaveClass('btn btn--block') + }) + + it('merges custom className with generated classes', () => { + render() + const button = screen.getByRole('button', { name: /custom class button/i }) + expect(button).toHaveClass( + 'btn btn--primary btn--medium btn--solid custom-class' + ) + }) +}) diff --git a/joi/src/core/Button/index.tsx b/joi/src/core/Button/index.tsx index 014f534b0..9945eb4e9 100644 --- a/joi/src/core/Button/index.tsx +++ b/joi/src/core/Button/index.tsx @@ -7,7 +7,7 @@ import { twMerge } from 'tailwind-merge' import './styles.scss' -const buttonVariants = cva('btn', { +export const buttonConfig = { variants: { theme: { primary: 'btn--primary', @@ -30,12 +30,13 @@ const buttonVariants = cva('btn', { }, }, defaultVariants: { - theme: 'primary', - size: 'medium', - variant: 'solid', - block: false, + theme: 'primary' as const, + size: 'medium' as const, + variant: 'solid' as const, + block: false as const, }, -}) +} +const buttonVariants = cva('btn', buttonConfig) export interface ButtonProps extends ButtonHTMLAttributes, diff --git a/joi/src/core/Tabs/index.tsx b/joi/src/core/Tabs/index.tsx index edec179f1..af004e2ba 100644 --- a/joi/src/core/Tabs/index.tsx +++ b/joi/src/core/Tabs/index.tsx @@ -2,10 +2,18 @@ import React, { ReactNode } from 'react' import * as TabsPrimitive from '@radix-ui/react-tabs' +import { Tooltip } from '../Tooltip' + import './styles.scss' +import { twMerge } from 'tailwind-merge' type TabsProps = { - options: { name: string; value: string }[] + options: { + name: string + value: string + disabled?: boolean + tooltipContent?: string + }[] children: ReactNode defaultValue?: string value: string @@ -15,11 +23,15 @@ type TabsProps = { type TabsContentProps = { value: string children: ReactNode + className?: string } -const TabsContent = ({ value, children }: TabsContentProps) => { +const TabsContent = ({ value, children, className }: TabsContentProps) => { return ( - + {children} ) @@ -40,11 +52,27 @@ const Tabs = ({ > {options.map((option, i) => { - return ( + return option.disabled ? ( + + {option.name} + + } + /> + ) : ( {option.name} diff --git a/joi/src/core/Tabs/styles.scss b/joi/src/core/Tabs/styles.scss index 86948ab5a..a24585b4e 100644 --- a/joi/src/core/Tabs/styles.scss +++ b/joi/src/core/Tabs/styles.scss @@ -21,6 +21,10 @@ &:focus { position: relative; } + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } } &__content { diff --git a/joi/src/hooks/useClickOutside/useClickOutside.test.tsx b/joi/src/hooks/useClickOutside/useClickOutside.test.tsx new file mode 100644 index 000000000..ac73b280a --- /dev/null +++ b/joi/src/hooks/useClickOutside/useClickOutside.test.tsx @@ -0,0 +1,55 @@ +import React from 'react' +import { render, fireEvent, act } from '@testing-library/react' +import { useClickOutside } from './index' + +// Mock component to test the hook +const TestComponent: React.FC<{ onClickOutside: () => void }> = ({ + onClickOutside, +}) => { + const ref = useClickOutside(onClickOutside) + return
}>Test
+} + +describe('@joi/hooks/useClickOutside', () => { + it('should call handler when clicking outside', () => { + const handleClickOutside = jest.fn() + const { container } = render( + + ) + + act(() => { + fireEvent.mouseDown(document.body) + }) + + expect(handleClickOutside).toHaveBeenCalledTimes(1) + }) + + it('should not call handler when clicking inside', () => { + const handleClickOutside = jest.fn() + const { getByText } = render( + + ) + + act(() => { + fireEvent.mouseDown(getByText('Test')) + }) + + expect(handleClickOutside).not.toHaveBeenCalled() + }) + + it('should work with custom events', () => { + const handleClickOutside = jest.fn() + const TestComponentWithCustomEvent: React.FC = () => { + const ref = useClickOutside(handleClickOutside, ['click']) + return
}>Test
+ } + + render() + + act(() => { + fireEvent.click(document.body) + }) + + expect(handleClickOutside).toHaveBeenCalledTimes(1) + }) +}) diff --git a/joi/src/hooks/useClipboard/useClipboard.test.ts b/joi/src/hooks/useClipboard/useClipboard.test.ts new file mode 100644 index 000000000..53b4ccd27 --- /dev/null +++ b/joi/src/hooks/useClipboard/useClipboard.test.ts @@ -0,0 +1,102 @@ +import { renderHook, act } from '@testing-library/react' +import { useClipboard } from './index' + +// Mock the navigator.clipboard +const mockClipboard = { + writeText: jest.fn(() => Promise.resolve()), +} +Object.assign(navigator, { clipboard: mockClipboard }) + +describe('@joi/hooks/useClipboard', () => { + beforeEach(() => { + jest.useFakeTimers() + jest.spyOn(window, 'setTimeout') + jest.spyOn(window, 'clearTimeout') + mockClipboard.writeText.mockClear() + }) + + afterEach(() => { + jest.useRealTimers() + jest.clearAllMocks() + }) + + it('should copy text to clipboard', async () => { + const { result } = renderHook(() => useClipboard()) + + await act(async () => { + result.current.copy('Test text') + }) + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('Test text') + expect(result.current.copied).toBe(true) + expect(result.current.error).toBe(null) + }) + + it('should set error if clipboard write fails', async () => { + mockClipboard.writeText.mockRejectedValueOnce( + new Error('Clipboard write failed') + ) + + const { result } = renderHook(() => useClipboard()) + + await act(async () => { + result.current.copy('Test text') + }) + + expect(result.current.error).toBeInstanceOf(Error) + expect(result.current.error?.message).toBe('Clipboard write failed') + }) + + it('should set error if clipboard is not supported', async () => { + const originalClipboard = navigator.clipboard + // @ts-ignore + delete navigator.clipboard + + const { result } = renderHook(() => useClipboard()) + + await act(async () => { + result.current.copy('Test text') + }) + + expect(result.current.error).toBeInstanceOf(Error) + expect(result.current.error?.message).toBe( + 'useClipboard: navigator.clipboard is not supported' + ) + + // Restore clipboard support + Object.assign(navigator, { clipboard: originalClipboard }) + }) + + it('should reset copied state after timeout', async () => { + const { result } = renderHook(() => useClipboard({ timeout: 1000 })) + + await act(async () => { + result.current.copy('Test text') + }) + + expect(result.current.copied).toBe(true) + + act(() => { + jest.advanceTimersByTime(1000) + }) + + expect(result.current.copied).toBe(false) + }) + + it('should reset state when reset is called', async () => { + const { result } = renderHook(() => useClipboard()) + + await act(async () => { + result.current.copy('Test text') + }) + + expect(result.current.copied).toBe(true) + + act(() => { + result.current.reset() + }) + + expect(result.current.copied).toBe(false) + expect(result.current.error).toBe(null) + }) +}) diff --git a/joi/src/hooks/useMediaQuery/useMediaQuery.test.ts b/joi/src/hooks/useMediaQuery/useMediaQuery.test.ts new file mode 100644 index 000000000..5813bd41d --- /dev/null +++ b/joi/src/hooks/useMediaQuery/useMediaQuery.test.ts @@ -0,0 +1,90 @@ +import { renderHook, act } from '@testing-library/react' +import { useMediaQuery } from './index' + +describe('@joi/hooks/useMediaQuery', () => { + const matchMediaMock = jest.fn() + + beforeAll(() => { + window.matchMedia = matchMediaMock + }) + + afterEach(() => { + matchMediaMock.mockClear() + }) + + it('should return initial value when getInitialValueInEffect is true', () => { + matchMediaMock.mockImplementation(() => ({ + matches: true, + addListener: jest.fn(), + removeListener: jest.fn(), + })) + + const { result } = renderHook(() => + useMediaQuery('(min-width: 768px)', true, { + getInitialValueInEffect: true, + }) + ) + + expect(result.current).toBe(true) + }) + + it('should return correct value based on media query', () => { + matchMediaMock.mockImplementation(() => ({ + matches: true, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + })) + + const { result } = renderHook(() => useMediaQuery('(min-width: 768px)')) + + expect(result.current).toBe(true) + }) + + it('should update value when media query changes', () => { + let listener: ((event: { matches: boolean }) => void) | null = null + + matchMediaMock.mockImplementation(() => ({ + matches: false, + addEventListener: (_, cb) => { + listener = cb + }, + removeEventListener: jest.fn(), + })) + + const { result } = renderHook(() => useMediaQuery('(min-width: 768px)')) + + expect(result.current).toBe(false) + + act(() => { + if (listener) { + listener({ matches: true }) + } + }) + + expect(result.current).toBe(true) + }) + + it('should handle older browsers without addEventListener', () => { + let listener: ((event: { matches: boolean }) => void) | null = null + + matchMediaMock.mockImplementation(() => ({ + matches: false, + addListener: (cb) => { + listener = cb + }, + removeListener: jest.fn(), + })) + + const { result } = renderHook(() => useMediaQuery('(min-width: 768px)')) + + expect(result.current).toBe(false) + + act(() => { + if (listener) { + listener({ matches: true }) + } + }) + + expect(result.current).toBe(true) + }) +}) diff --git a/joi/src/hooks/useOs/useOs.test.ts b/joi/src/hooks/useOs/useOs.test.ts new file mode 100644 index 000000000..037640b5e --- /dev/null +++ b/joi/src/hooks/useOs/useOs.test.ts @@ -0,0 +1,39 @@ +import { renderHook } from '@testing-library/react' +import { useOs } from './index' + +const platforms = { + windows: [ + 'Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0', + ], + macos: [ + 'Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0', + ], + linux: [ + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36', + ], + ios: [ + 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.1 Mobile/15E148 Safari/604.1', + ], + android: [ + 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36', + ], + undetermined: ['UNKNOWN'], +} as const + +describe('@joi/hooks/useOS', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + Object.entries(platforms).forEach(([os, userAgents]) => { + it.each(userAgents)(`should detect %s platform on ${os}`, (userAgent) => { + jest + .spyOn(window.navigator, 'userAgent', 'get') + .mockReturnValueOnce(userAgent) + + const { result } = renderHook(() => useOs()) + + expect(result.current).toBe(os) + }) + }) +}) diff --git a/joi/src/hooks/usePageLeave/usePageLeave.test.ts b/joi/src/hooks/usePageLeave/usePageLeave.test.ts new file mode 100644 index 000000000..093ae31c1 --- /dev/null +++ b/joi/src/hooks/usePageLeave/usePageLeave.test.ts @@ -0,0 +1,32 @@ +import { renderHook } from '@testing-library/react' +import { fireEvent } from '@testing-library/react' +import { usePageLeave } from './index' + +describe('@joi/hooks/usePageLeave', () => { + it('should call onPageLeave when mouse leaves the document', () => { + const onPageLeaveMock = jest.fn() + const { result } = renderHook(() => usePageLeave(onPageLeaveMock)) + + fireEvent.mouseLeave(document.documentElement) + + expect(onPageLeaveMock).toHaveBeenCalledTimes(1) + }) + + it('should remove event listener on unmount', () => { + const onPageLeaveMock = jest.fn() + const removeEventListenerSpy = jest.spyOn( + document.documentElement, + 'removeEventListener' + ) + + const { unmount } = renderHook(() => usePageLeave(onPageLeaveMock)) + + unmount() + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'mouseleave', + expect.any(Function) + ) + removeEventListenerSpy.mockRestore() + }) +}) diff --git a/joi/src/hooks/useTextSelection/useTextSelection.test.ts b/joi/src/hooks/useTextSelection/useTextSelection.test.ts new file mode 100644 index 000000000..26efa23e7 --- /dev/null +++ b/joi/src/hooks/useTextSelection/useTextSelection.test.ts @@ -0,0 +1,56 @@ +import { renderHook, act } from '@testing-library/react' +import { useTextSelection } from './index' + +describe('@joi/hooks/useTextSelection', () => { + let mockSelection: Selection + + beforeEach(() => { + mockSelection = { + toString: jest.fn(), + removeAllRanges: jest.fn(), + addRange: jest.fn(), + } as unknown as Selection + + jest.spyOn(document, 'getSelection').mockReturnValue(mockSelection) + jest.spyOn(document, 'addEventListener') + jest.spyOn(document, 'removeEventListener') + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('should return the initial selection', () => { + const { result } = renderHook(() => useTextSelection()) + expect(result.current).toBe(mockSelection) + }) + + it('should add and remove event listener', () => { + const { unmount } = renderHook(() => useTextSelection()) + + expect(document.addEventListener).toHaveBeenCalledWith( + 'selectionchange', + expect.any(Function) + ) + + unmount() + + expect(document.removeEventListener).toHaveBeenCalledWith( + 'selectionchange', + expect.any(Function) + ) + }) + + it('should update selection when selectionchange event is triggered', () => { + const { result } = renderHook(() => useTextSelection()) + + const newMockSelection = { toString: jest.fn() } as unknown as Selection + jest.spyOn(document, 'getSelection').mockReturnValue(newMockSelection) + + act(() => { + document.dispatchEvent(new Event('selectionchange')) + }) + + expect(result.current).toBe(newMockSelection) + }) +}) diff --git a/joi/tsconfig.json b/joi/tsconfig.json index f72e8151e..25e2ee66f 100644 --- a/joi/tsconfig.json +++ b/joi/tsconfig.json @@ -3,6 +3,7 @@ "target": "esnext", "declaration": true, "declarationDir": "dist/types", + "types": ["jest", "@testing-library/jest-dom"], "module": "esnext", "lib": ["es6", "dom", "es2016", "es2017"], "sourceMap": true, diff --git a/package.json b/package.json index c86405182..2785ee3b5 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ }, "scripts": { "lint": "yarn workspace jan lint && yarn workspace @janhq/web lint", - "test:unit": "yarn workspace @janhq/core test", + "test:unit": "jest", + "test:coverage": "jest --coverage --collectCoverageFrom='src/**/*.{ts,tsx}'", "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/web/containers/Layout/RibbonPanel/index.tsx b/web/containers/Layout/RibbonPanel/index.tsx index b9b1434ae..41ceea8e3 100644 --- a/web/containers/Layout/RibbonPanel/index.tsx +++ b/web/containers/Layout/RibbonPanel/index.tsx @@ -12,9 +12,12 @@ import { twMerge } from 'tailwind-merge' import { MainViewState } from '@/constants/screens' +import { localEngines } from '@/utils/modelEngine' + import { mainViewStateAtom, showLeftPanelAtom } from '@/helpers/atoms/App.atom' import { editMessageAtom } from '@/helpers/atoms/ChatMessage.atom' import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom' +import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom' import { reduceTransparentAtom, selectedSettingAtom, @@ -28,6 +31,7 @@ export default function RibbonPanel() { const matches = useMediaQuery('(max-width: 880px)') const reduceTransparent = useAtomValue(reduceTransparentAtom) const setSelectedSetting = useSetAtom(selectedSettingAtom) + const downloadedModels = useAtomValue(downloadedModelsAtom) const onMenuClick = (state: MainViewState) => { if (mainViewState === state) return @@ -37,6 +41,10 @@ export default function RibbonPanel() { setEditMessage('') } + const isDownloadALocalModel = downloadedModels.some((x) => + localEngines.includes(x.engine) + ) + const RibbonNavMenus = [ { name: 'Thread', @@ -77,7 +85,10 @@ export default function RibbonPanel() { 'border-none', !showLeftPanel && !reduceTransparent && 'border-none', matches && !reduceTransparent && 'border-none', - reduceTransparent && ' bg-[hsla(var(--ribbon-panel-bg))]' + reduceTransparent && ' bg-[hsla(var(--ribbon-panel-bg))]', + mainViewState === MainViewState.Thread && + !isDownloadALocalModel && + 'border-none' )} > {RibbonNavMenus.filter((menu) => !!menu).map((menu, i) => { diff --git a/web/containers/Layout/index.tsx b/web/containers/Layout/index.tsx index 3b3f57a21..8a3f417f4 100644 --- a/web/containers/Layout/index.tsx +++ b/web/containers/Layout/index.tsx @@ -46,6 +46,16 @@ const BaseLayout = () => { } }, [setMainViewState]) + useEffect(() => { + window.electronAPI?.onMainViewStateChange( + (_event: string, route: string) => { + if (route === 'Settings') { + setMainViewState(MainViewState.Settings) + } + } + ) + }, [setMainViewState]) + return (
!filterOptionsOpen && setOpen(false), null, [ dropdownOptions, toggle, @@ -101,6 +104,13 @@ const ModelDropdown = ({ showEngineListModelAtom ) + const isModelSupportRagAndTools = useCallback((model: Model) => { + return ( + model?.engine === InferenceEngine.openai || + localEngines.includes(model?.engine as InferenceEngine) + ) + }, []) + const filteredDownloadedModels = useMemo( () => configuredModels @@ -161,6 +171,26 @@ const ModelDropdown = ({ setOpen(false) if (activeThread) { + // Change assistand tools based on model support RAG + updateThreadMetadata({ + ...activeThread, + assistants: [ + { + ...activeThread.assistants[0], + tools: [ + { + type: 'retrieval', + enabled: isModelSupportRagAndTools(model as Model), + settings: { + ...(activeThread.assistants[0].tools && + activeThread.assistants[0].tools[0]?.settings), + }, + }, + ], + }, + ], + }) + // Default setting ctx_len for the model for a better onboarding experience // TODO: When Cortex support hardware instructions, we should remove this const defaultContextLength = preserveModelSettings @@ -201,8 +231,10 @@ const ModelDropdown = ({ downloadedModels, activeThread, setSelectedModel, + isModelSupportRagAndTools, setThreadModelParams, updateModelParameter, + updateThreadMetadata, preserveModelSettings, ] ) @@ -433,158 +465,73 @@ const ModelDropdown = ({ {engine === InferenceEngine.nitro && !isDownloadALocalModel && - showModel && ( - <> - {!searchText.length ? ( -
    - {featuredModel.map((model) => { - const isDownloading = downloadingModels.some( - (md) => md.id === model.id - ) - return ( -
  • + {featuredModel.map((model) => { + const isDownloading = downloadingModels.some( + (md) => md.id === model.id + ) + return ( +
  • +
    +

    -

    -

    - {model.name} -

    - -
    -
    - - {toGibibytes(model.metadata.size)} - - {!isDownloading ? ( - downloadModel(model)} - /> - ) : ( - Object.values(downloadStates) - .filter((x) => x.modelId === model.id) - .map((item) => ( - + +
    +
    + + {toGibibytes(model.metadata.size)} + + {!isDownloading ? ( + downloadModel(model)} + /> + ) : ( + Object.values(downloadStates) + .filter((x) => x.modelId === model.id) + .map((item) => ( + - )) - )} -
    -
  • - ) - })} -
- ) : ( - <> - {filteredDownloadedModels - .filter( - (x) => x.engine === InferenceEngine.nitro - ) - .filter((x) => { - if (searchText.length === 0) { - return downloadedModels.find( - (c) => c.id === x.id - ) - } else { - return x - } - }) - .map((model) => { - const isDownloading = downloadingModels.some( - (md) => md.id === model.id - ) - const isdDownloaded = downloadedModels.some( - (c) => c.id === model.id - ) - return ( -
  • { - if (isdDownloaded) { - onClickModelItem(model.id) - } - }} - > -
    -

    - {model.name} -

    - -
    -
    - {!isdDownloaded && ( - - {toGibibytes(model.metadata.size)} - - )} - {!isDownloading && !isdDownloaded ? ( - downloadModel(model)} - /> - ) : ( - Object.values(downloadStates) - .filter( - (x) => x.modelId === model.id - ) - .map((item) => ( - - )) - )} -
    -
  • - ) - })} - - )} - + )) + )} +
    + + ) + })} + )}
      {filteredDownloadedModels .filter((x) => x.engine === engine) .filter((y) => { - if (localEngines.includes(y.engine)) { + if ( + localEngines.includes(y.engine) && + !searchText.length + ) { return downloadedModels.find((c) => c.id === y.id) } else { return y @@ -592,11 +539,17 @@ const ModelDropdown = ({ }) .map((model) => { if (!showModel) return null + const isDownloading = downloadingModels.some( + (md) => md.id === model.id + ) + const isdDownloaded = downloadedModels.some( + (c) => c.id === model.id + ) return (
    • -
      -

      +

      +

      {model.name}

      + +
      +
      + {!isdDownloaded && ( + + {toGibibytes(model.metadata.size)} + + )} + {!isDownloading && !isdDownloaded ? ( + downloadModel(model)} + /> + ) : ( + Object.values(downloadStates) + .filter((x) => x.modelId === model.id) + .map((item) => ( + + )) + )}
    • ) diff --git a/web/containers/Providers/Responsive.tsx b/web/containers/Providers/Responsive.tsx index 4c9e87ab4..940cb68fb 100644 --- a/web/containers/Providers/Responsive.tsx +++ b/web/containers/Providers/Responsive.tsx @@ -1,24 +1,33 @@ -import { Fragment, PropsWithChildren, useEffect } from 'react' +import { Fragment, PropsWithChildren, useEffect, useRef } from 'react' import { useMediaQuery } from '@janhq/joi' - -import { useSetAtom } from 'jotai' +import { useAtom } from 'jotai' import { showLeftPanelAtom, showRightPanelAtom } from '@/helpers/atoms/App.atom' const Responsive = ({ children }: PropsWithChildren) => { const matches = useMediaQuery('(max-width: 880px)') - const setShowLeftPanel = useSetAtom(showLeftPanelAtom) - const setShowRightPanel = useSetAtom(showRightPanelAtom) + const [showLeftPanel, setShowLeftPanel] = useAtom(showLeftPanelAtom) + const [showRightPanel, setShowRightPanel] = useAtom(showRightPanelAtom) + + // Refs to store the last known state of the panels + const lastLeftPanelState = useRef(true) + const lastRightPanelState = useRef(true) useEffect(() => { if (matches) { + // Store the last known state before closing the panels + lastLeftPanelState.current = showLeftPanel + lastRightPanelState.current = showRightPanel + setShowLeftPanel(false) setShowRightPanel(false) } else { - setShowLeftPanel(true) - setShowRightPanel(true) + // Restore the last known state when the screen is resized back + setShowLeftPanel(lastLeftPanelState.current) + setShowRightPanel(lastRightPanelState.current) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [matches, setShowLeftPanel, setShowRightPanel]) return {children} diff --git a/web/containers/SliderRightPanel/index.tsx b/web/containers/SliderRightPanel/index.tsx index 3b2ef8d47..2a42ade61 100644 --- a/web/containers/SliderRightPanel/index.tsx +++ b/web/containers/SliderRightPanel/index.tsx @@ -28,8 +28,10 @@ const SliderRightPanel = ({ onValueChanged, }: Props) => { const [showTooltip, setShowTooltip] = useState({ max: false, min: false }) + const [val, setVal] = useState(value.toString()) useClickOutside(() => setShowTooltip({ max: false, min: false }), null, []) + return (
      @@ -48,7 +50,10 @@ const SliderRightPanel = ({
      onValueChanged?.(e[0])} + onValueChange={(e) => { + onValueChanged?.(e[0]) + setVal(e[0].toString()) + }} min={min} max={max} step={step} @@ -63,24 +68,29 @@ const SliderRightPanel = ({ open={showTooltip.max || showTooltip.min} trigger={ { if (Number(e.target.value) > Number(max)) { onValueChanged?.(Number(max)) + setVal(max.toString()) setShowTooltip({ max: true, min: false }) } else if (Number(e.target.value) < Number(min)) { onValueChanged?.(Number(min)) + setVal(min.toString()) setShowTooltip({ max: false, min: true }) } }} onChange={(e) => { - onValueChanged?.(Number(e.target.value)) + onValueChanged?.(e.target.value) + if (/^\d*\.?\d*$/.test(e.target.value)) { + setVal(e.target.value) + } }} /> } diff --git a/web/helpers/atoms/HuggingFace.atom.ts b/web/helpers/atoms/HuggingFace.atom.ts index 514efb186..09f7870a3 100644 --- a/web/helpers/atoms/HuggingFace.atom.ts +++ b/web/helpers/atoms/HuggingFace.atom.ts @@ -1,4 +1,4 @@ -import { HuggingFaceRepoData } from '@janhq/core/.' +import { HuggingFaceRepoData } from '@janhq/core' import { atom } from 'jotai' // modals diff --git a/web/screens/Thread/ThreadCenterPanel/ChatBody/EmptyModel/index.tsx b/web/screens/Thread/ThreadCenterPanel/ChatBody/EmptyModel/index.tsx index 77913c991..12739b5de 100644 --- a/web/screens/Thread/ThreadCenterPanel/ChatBody/EmptyModel/index.tsx +++ b/web/screens/Thread/ThreadCenterPanel/ChatBody/EmptyModel/index.tsx @@ -1,32 +1,317 @@ -import { memo } from 'react' +import React, { Fragment, useState } from 'react' -import { Button } from '@janhq/joi' -import { useSetAtom } from 'jotai' +import Image from 'next/image' + +import { InferenceEngine } from '@janhq/core' +import { Button, Input, Progress, ScrollArea } from '@janhq/joi' + +import { useAtomValue, useSetAtom } from 'jotai' +import { SearchIcon, DownloadCloudIcon } from 'lucide-react' + +import { twMerge } from 'tailwind-merge' import LogoMark from '@/containers/Brand/Logo/Mark' +import CenterPanelContainer from '@/containers/CenterPanelContainer' + +import ProgressCircle from '@/containers/Loader/ProgressCircle' + +import ModelLabel from '@/containers/ModelLabel' import { MainViewState } from '@/constants/screens' -import { mainViewStateAtom } from '@/helpers/atoms/App.atom' +import useDownloadModel from '@/hooks/useDownloadModel' -const EmptyModel = () => { +import { modelDownloadStateAtom } from '@/hooks/useDownloadState' + +import { formatDownloadPercentage, toGibibytes } from '@/utils/converter' +import { + getLogoEngine, + getTitleByEngine, + localEngines, +} from '@/utils/modelEngine' + +import { mainViewStateAtom } from '@/helpers/atoms/App.atom' +import { + configuredModelsAtom, + getDownloadingModelAtom, +} from '@/helpers/atoms/Model.atom' +import { selectedSettingAtom } from '@/helpers/atoms/Setting.atom' + +type Props = { + extensionHasSettings: { + name?: string + setting: string + apiKey: string + provider: string + }[] +} + +const OnDeviceStarterScreen = ({ extensionHasSettings }: Props) => { + const [searchValue, setSearchValue] = useState('') + const downloadingModels = useAtomValue(getDownloadingModelAtom) + const { downloadModel } = useDownloadModel() + const downloadStates = useAtomValue(modelDownloadStateAtom) + const setSelectedSetting = useSetAtom(selectedSettingAtom) + + const configuredModels = useAtomValue(configuredModelsAtom) const setMainViewState = useSetAtom(mainViewStateAtom) + const featuredModel = configuredModels.filter((x) => + x.metadata.tags.includes('Featured') + ) + + const remoteModel = configuredModels.filter( + (x) => !localEngines.includes(x.engine) + ) + + const filteredModels = configuredModels.filter((model) => { + return ( + localEngines.includes(model.engine) && + model.name.toLowerCase().includes(searchValue.toLowerCase()) + ) + }) + + const remoteModelEngine = remoteModel.map((x) => x.engine) + const groupByEngine = remoteModelEngine.filter(function (item, index) { + if (remoteModelEngine.indexOf(item) === index) return item + }) + + const itemsPerRow = 5 + + const getRows = (array: string[], itemsPerRow: number) => { + const rows = [] + for (let i = 0; i < array.length; i += itemsPerRow) { + rows.push(array.slice(i, i + itemsPerRow)) + } + return rows + } + + const rows = getRows(groupByEngine, itemsPerRow) + + const [visibleRows, setVisibleRows] = useState(1) + return ( -
      - -

      Welcome!

      -

      - You need to download your first model -

      - -
      + + +
      +
      + +

      Select a model to start

      +
      + +
      + setSearchValue(e.target.value)} + placeholder="Search..." + prefixIcon={} + /> +
      + {!filteredModels.length ? ( +
      +

      + No Result Found +

      +
      + ) : ( + filteredModels.map((model) => { + const isDownloading = downloadingModels.some( + (md) => md.id === model.id + ) + return ( +
      +
      +

      + {model.name} +

      + +
      +
      + + {toGibibytes(model.metadata.size)} + + {!isDownloading ? ( + downloadModel(model)} + /> + ) : ( + Object.values(downloadStates) + .filter((x) => x.modelId === model.id) + .map((item) => ( + + )) + )} +
      +
      + ) + }) + )} +
      +
      +
      +

      + On-device Models +

      +

      { + setMainViewState(MainViewState.Hub) + }} + > + See All +

      +
      + + {featuredModel.slice(0, 2).map((featModel) => { + const isDownloading = downloadingModels.some( + (md) => md.id === featModel.id + ) + return ( +
      +
      +
      {featModel.name}
      +

      + {featModel.metadata.author} +

      +
      + + {isDownloading ? ( +
      + {Object.values(downloadStates).map((item, i) => ( +
      + +
      +
      + + {formatDownloadPercentage(item?.percent)} + +
      +
      +
      + ))} +
      + ) : ( + + )} +
      + ) + })} + +
      +

      + Cloud Models +

      +
      + +
      + {rows.slice(0, visibleRows).map((row, rowIndex) => { + return ( +
      + {row.map((remoteEngine) => { + const engineLogo = getLogoEngine( + remoteEngine as InferenceEngine + ) + + return ( +
      { + setMainViewState(MainViewState.Settings) + setSelectedSetting( + extensionHasSettings.find((x) => + x.name?.toLowerCase().includes(remoteEngine) + )?.setting as string + ) + }} + > + {engineLogo && ( + Engine logo + )} + +

      + {getTitleByEngine( + remoteEngine as InferenceEngine + )} +

      +
      + ) + })} +
      + ) + })} +
      + {visibleRows < rows.length && ( + + )} +
      +
      +
      +
      +
      +
      ) } -export default memo(EmptyModel) +export default OnDeviceStarterScreen diff --git a/web/screens/Thread/ThreadCenterPanel/ChatBody/index.tsx b/web/screens/Thread/ThreadCenterPanel/ChatBody/index.tsx index 5b5218bb9..6b3f4150a 100644 --- a/web/screens/Thread/ThreadCenterPanel/ChatBody/index.tsx +++ b/web/screens/Thread/ThreadCenterPanel/ChatBody/index.tsx @@ -11,18 +11,15 @@ import ChatItem from '../ChatItem' import LoadModelError from '../LoadModelError' -import EmptyModel from './EmptyModel' import EmptyThread from './EmptyThread' import { getCurrentChatMessagesAtom } from '@/helpers/atoms/ChatMessage.atom' -import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom' const ChatBody = () => { const messages = useAtomValue(getCurrentChatMessagesAtom) - const downloadedModels = useAtomValue(downloadedModelsAtom) + const loadModelError = useAtomValue(loadModelErrorAtom) - if (!downloadedModels.length) return if (!messages.length) return return ( diff --git a/web/screens/Thread/ThreadCenterPanel/ChatInput/index.tsx b/web/screens/Thread/ThreadCenterPanel/ChatInput/index.tsx index e3d469a97..bbecef10e 100644 --- a/web/screens/Thread/ThreadCenterPanel/ChatInput/index.tsx +++ b/web/screens/Thread/ThreadCenterPanel/ChatInput/index.tsx @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { useEffect, useRef, useState } from 'react' -import { MessageStatus } from '@janhq/core' +import { InferenceEngine, MessageStatus } from '@janhq/core' import { TextArea, Button, Tooltip, useClickOutside, Badge } from '@janhq/joi' import { useAtom, useAtomValue } from 'jotai' @@ -24,12 +24,15 @@ import { useActiveModel } from '@/hooks/useActiveModel' import useSendChatMessage from '@/hooks/useSendChatMessage' +import { localEngines } from '@/utils/modelEngine' + import FileUploadPreview from '../FileUploadPreview' import ImageUploadPreview from '../ImageUploadPreview' import { showRightPanelAtom } from '@/helpers/atoms/App.atom' import { experimentalFeatureEnabledAtom } from '@/helpers/atoms/AppConfig.atom' import { getCurrentChatMessagesAtom } from '@/helpers/atoms/ChatMessage.atom' +import { selectedModelAtom } from '@/helpers/atoms/Model.atom' import { spellCheckAtom } from '@/helpers/atoms/Setting.atom' import { activeSettingInputBoxAtom, @@ -53,6 +56,7 @@ const ChatInput = () => { activeSettingInputBoxAtom ) const { sendChatMessage } = useSendChatMessage() + const selectedModel = useAtomValue(selectedModelAtom) const activeThreadId = useAtomValue(getActiveThreadIdAtom) const [isWaitingToSend, setIsWaitingToSend] = useAtom(waitingToSendMessage) @@ -124,6 +128,10 @@ const ChatInput = () => { stopInference() } + const isModelSupportRagAndTools = + selectedModel?.engine === InferenceEngine.openai || + localEngines.includes(selectedModel?.engine as InferenceEngine) + /** * Handles the change event of the extension file input element by setting the file name state. * Its to be used to display the extension file name of the selected file. @@ -198,6 +206,7 @@ const ChatInput = () => { } disabled={ + isModelSupportRagAndTools && activeThread?.assistants[0].tools && activeThread?.assistants[0].tools[0]?.enabled } @@ -217,12 +226,16 @@ const ChatInput = () => { )} {activeThread?.assistants[0].tools && activeThread?.assistants[0].tools[0]?.enabled === - false && ( + false && + isModelSupportRagAndTools && ( - Turn on Retrieval in Assistant Settings to use - this feature. + Turn on Retrieval in Tools settings to use this + feature )} + {!isModelSupportRagAndTools && ( + Not supported for this model + )} ))} diff --git a/web/screens/Thread/ThreadRightPanel/index.tsx b/web/screens/Thread/ThreadRightPanel/index.tsx index e374cb362..9e7cdf7d8 100644 --- a/web/screens/Thread/ThreadRightPanel/index.tsx +++ b/web/screens/Thread/ThreadRightPanel/index.tsx @@ -1,6 +1,10 @@ import { memo, useCallback, useMemo } from 'react' -import { SettingComponentProps, SliderComponentProps } from '@janhq/core/.' +import { + InferenceEngine, + SettingComponentProps, + SliderComponentProps, +} from '@janhq/core' import { Tabs, TabsContent, @@ -24,6 +28,7 @@ import { useCreateNewThread } from '@/hooks/useCreateNewThread' import useUpdateModelParameters from '@/hooks/useUpdateModelParameters' import { getConfigurationsData } from '@/utils/componentSettings' +import { localEngines } from '@/utils/modelEngine' import { toRuntimeParams, toSettingParams } from '@/utils/modelParam' import PromptTemplateSetting from './PromptTemplateSetting' @@ -39,6 +44,10 @@ import { import { activeTabThreadRightPanelAtom } from '@/helpers/atoms/ThreadRightPanel.atom' +const INFERENCE_SETTINGS = 'Inference Settings' +const MODEL_SETTINGS = 'Model Settings' +const ENGINE_SETTINGS = 'Engine Settings' + const ThreadRightPanel = () => { const activeThread = useAtomValue(activeThreadAtom) const activeModelParams = useAtomValue(getActiveThreadModelParamsAtom) @@ -49,6 +58,10 @@ const ThreadRightPanel = () => { const { updateThreadMetadata } = useCreateNewThread() const experimentalFeature = useAtomValue(experimentalFeatureEnabledAtom) + const isModelSupportRagAndTools = + selectedModel?.engine === InferenceEngine.openai || + localEngines.includes(selectedModel?.engine as InferenceEngine) + const setEngineParamsUpdate = useSetAtom(engineParamsUpdateAtom) const { stopModel } = useActiveModel() const { updateModelParameter } = useUpdateModelParameters() @@ -189,7 +202,16 @@ const ThreadRightPanel = () => { options={[ { name: 'Assistant', value: 'assistant' }, { name: 'Model', value: 'model' }, - ...(experimentalFeature ? [{ name: 'Tools', value: 'tools' }] : []), + ...(experimentalFeature + ? [ + { + name: 'Tools', + value: 'tools', + disabled: !isModelSupportRagAndTools, + tooltipContent: 'Not supported for this model', + }, + ] + : []), ]} value={activeTabThreadRightPanel as string} onValueChange={(value) => setActiveTabThreadRightPanel(value)} @@ -221,8 +243,8 @@ const ThreadRightPanel = () => { {settings.runtimeSettings.length !== 0 && ( { )} {promptTemplateSettings.length !== 0 && ( - + )} {settings.engineSettings.length !== 0 && ( - + { + const downloadedModels = useAtomValue(downloadedModelsAtom) + const threads = useAtomValue(threadsAtom) + + const isDownloadALocalModel = downloadedModels.some((x) => + localEngines.includes(x.engine) + ) + + const [extensionHasSettings, setExtensionHasSettings] = useState< + { name?: string; setting: string; apiKey: string; provider: string }[] + >([]) + + useEffect(() => { + const getAllSettings = async () => { + const extensionsMenu: { + name?: string + setting: string + apiKey: string + provider: string + }[] = [] + const extensions = extensionManager.getAll() + + for (const extension of extensions) { + if (typeof extension.getSettings === 'function') { + const settings = await extension.getSettings() + + if ( + (settings && settings.length > 0) || + (await extension.installationState()) !== 'NotRequired' + ) { + extensionsMenu.push({ + name: extension.productName, + setting: extension.name, + apiKey: + 'apiKey' in extension && typeof extension.apiKey === 'string' + ? extension.apiKey + : '', + provider: + 'provider' in extension && + typeof extension.provider === 'string' + ? extension.provider + : '', + }) + } + } + } + setExtensionHasSettings(extensionsMenu) + } + getAllSettings() + }, []) + + const isAnyRemoteModelConfigured = extensionHasSettings.some( + (x) => x.apiKey.length > 1 + ) + return (
      - - - + {!isAnyRemoteModelConfigured && + !isDownloadALocalModel && + !threads.length ? ( + <> + + + ) : ( + <> + + + + + )} {/* Showing variant modal action for thread screen */} diff --git a/web/utils/Stack.test.ts b/web/utils/Stack.test.ts new file mode 100644 index 000000000..e10753c68 --- /dev/null +++ b/web/utils/Stack.test.ts @@ -0,0 +1,35 @@ + +import { Stack } from './Stack'; + +it('should return elements in reverse order', () => { + const stack = new Stack(); + stack.push(1); + stack.push(2); + stack.push(3); + const reversedOutput = stack.reverseOutput(); + expect(reversedOutput).toEqual([1, 2, 3]); +}); + + +it('should pop an element from the stack', () => { + const stack = new Stack(); + stack.push(1); + const poppedElement = stack.pop(); + expect(poppedElement).toBe(1); + expect(stack.isEmpty()).toBe(true); +}); + + +it('should push an element to the stack', () => { + const stack = new Stack(); + stack.push(1); + expect(stack.isEmpty()).toBe(false); + expect(stack.size()).toBe(1); + expect(stack.peek()).toBe(1); +}); + + +it('should initialize as empty', () => { + const stack = new Stack(); + expect(stack.isEmpty()).toBe(true); +}); diff --git a/web/utils/componentSettings.test.ts b/web/utils/componentSettings.test.ts new file mode 100644 index 000000000..51d2a90fd --- /dev/null +++ b/web/utils/componentSettings.test.ts @@ -0,0 +1,22 @@ + +import { getConfigurationsData } from './componentSettings'; + +it('should process checkbox setting', () => { + const settings = { embedding: true }; + const result = getConfigurationsData(settings); + expect(result[0].controllerProps.value).toBe(true); +}); + + +it('should process input setting and handle array input', () => { + const settings = { prompt_template: ['Hello', 'World', ''] }; + const result = getConfigurationsData(settings); + expect(result[0].controllerProps.value).toBe('Hello World '); +}); + + +it('should return an empty array when settings object is empty', () => { + const settings = {}; + const result = getConfigurationsData(settings); + expect(result).toEqual([]); +}); diff --git a/web/utils/datetime.test.ts b/web/utils/datetime.test.ts new file mode 100644 index 000000000..75bdb1f8f --- /dev/null +++ b/web/utils/datetime.test.ts @@ -0,0 +1,27 @@ + +import { displayDate } from './datetime'; +import { isToday } from './datetime'; + +test('should return only time for today\'s timestamp', () => { + const today = new Date(); + const timestamp = today.getTime(); + const expectedTime = today.toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: true, + }); + expect(displayDate(timestamp)).toBe(expectedTime); +}); + + +test('should return N/A for undefined timestamp', () => { + expect(displayDate()).toBe('N/A'); +}); + + +test('should return true for today\'s timestamp', () => { + const today = new Date(); + const timestamp = today.setHours(0, 0, 0, 0); + expect(isToday(timestamp)).toBe(true); +}); diff --git a/web/utils/jsonToCssVariables.test.ts b/web/utils/jsonToCssVariables.test.ts new file mode 100644 index 000000000..0ff19e9a2 --- /dev/null +++ b/web/utils/jsonToCssVariables.test.ts @@ -0,0 +1,17 @@ + +import cssVars from './jsonToCssVariables'; + +test('should convert nested JSON object to CSS variables', () => { + const input = { theme: { color: 'blue', font: { size: '14px', weight: 'bold' } } }; + const expectedOutput = '--theme-color: blue;--theme-font-size: 14px;--theme-font-weight: bold;'; + const result = cssVars(input); + expect(result).toBe(expectedOutput); +}); + + +test('should convert simple JSON object to CSS variables', () => { + const input = { color: 'red', fontSize: '16px' }; + const expectedOutput = '--color: red;--fontSize: 16px;'; + const result = cssVars(input); + expect(result).toBe(expectedOutput); +}); diff --git a/web/utils/memory.test.ts b/web/utils/memory.test.ts index e7420957d..5150df72b 100644 --- a/web/utils/memory.test.ts +++ b/web/utils/memory.test.ts @@ -1,5 +1,3 @@ -// @auto-generated - import { utilizedMemory } from './memory' test('test_utilizedMemory_arbitraryValues', () => { diff --git a/web/utils/predefinedComponent.test.ts b/web/utils/predefinedComponent.test.ts new file mode 100644 index 000000000..42b391d73 --- /dev/null +++ b/web/utils/predefinedComponent.test.ts @@ -0,0 +1,18 @@ + +import { presetConfiguration } from './predefinedComponent'; + +it('should have correct configuration for prompt_template', () => { + const config = presetConfiguration['prompt_template']; + expect(config).toEqual({ + key: 'prompt_template', + title: 'Prompt template', + description: `A predefined text or framework that guides the AI model's response generation. It includes placeholders or instructions for the model to fill in or expand upon.`, + controllerType: 'input', + controllerProps: { + placeholder: 'Prompt template', + value: '', + }, + requireModelReload: true, + configType: 'setting', + }); +}); diff --git a/web/utils/predefinedComponent.ts b/web/utils/predefinedComponent.ts index 2d0238bec..82087f43b 100644 --- a/web/utils/predefinedComponent.ts +++ b/web/utils/predefinedComponent.ts @@ -1,4 +1,4 @@ -import { SettingComponentProps } from '@janhq/core/.' +import { SettingComponentProps } from '@janhq/core' export const presetConfiguration: Record = { prompt_template: { diff --git a/web/utils/thread.test.ts b/web/utils/thread.test.ts new file mode 100644 index 000000000..672e3adde --- /dev/null +++ b/web/utils/thread.test.ts @@ -0,0 +1,9 @@ + +import { generateThreadId } from './thread'; + +test('shouldGenerateThreadIdWithCorrectFormat', () => { + const assistantId = 'assistant123'; + const threadId = generateThreadId(assistantId); + const regex = /^assistant123_\d{10}$/; + expect(threadId).toMatch(regex); +}); diff --git a/web/utils/titleUtils.test.ts b/web/utils/titleUtils.test.ts new file mode 100644 index 000000000..0cf2e1db7 --- /dev/null +++ b/web/utils/titleUtils.test.ts @@ -0,0 +1,25 @@ + +import { openFileTitle } from './titleUtils'; + + test('should return "Open Containing Folder" when neither isMac nor isWindows is true', () => { + (global as any).isMac = false; + (global as any).isWindows = false; + const result = openFileTitle(); + expect(result).toBe('Open Containing Folder'); + }); + + + test('should return "Show in File Explorer" when isWindows is true', () => { + (global as any).isMac = false; + (global as any).isWindows = true; + const result = openFileTitle(); + expect(result).toBe('Show in File Explorer'); + }); + + + test('should return "Show in Finder" when isMac is true', () => { + (global as any).isMac = true; + (global as any).isWindows = false; + const result = openFileTitle(); + expect(result).toBe('Show in Finder'); + });