Merge branch 'bugreport' of https://github.com/janhq/jan into bugreport

This commit is contained in:
0xSage 2024-09-09 13:40:41 +08:00
commit e614702d18
82 changed files with 2510 additions and 284 deletions

View File

@ -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

View File

@ -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

View File

@ -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

4
.gitignore vendored
View File

@ -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

View File

@ -104,7 +104,7 @@ endif
# Testing
test: lint
yarn build:test
yarn test:unit
yarn test:coverage
yarn test
# Builds and publishes the app

View File

@ -4,4 +4,5 @@ module.exports = {
moduleNameMapper: {
'@/(.*)': '<rootDir>/src/$1',
},
runner: './testRunner.js',
}

View File

@ -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"
},

View File

@ -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');
});

View File

@ -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);
});

View File

@ -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
})
})

View File

@ -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' })
})
})

View File

@ -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();
});
});

View File

@ -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();
});

View File

@ -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);
});

View File

@ -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']);
});

View File

@ -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'
}));
});

View File

@ -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');
});

View File

@ -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]');
});

View File

@ -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([]);
});

View File

@ -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' })
})
})

View File

@ -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();
});

View File

@ -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));
});
});

View File

@ -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');
});
});

View File

@ -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);
});

View File

@ -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');
});

View File

@ -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);
});

View File

@ -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]);
});

View File

@ -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());
});

View File

@ -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);
});

View File

@ -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);
});

View File

@ -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);
});

View File

@ -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)
})
})

View File

@ -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}`);
});

View File

@ -55,6 +55,7 @@ export enum AppEvent {
onSelectedText = 'onSelectedText',
onDeepLink = 'onDeepLink',
onMainViewStateChange = 'onMainViewStateChange',
}
export enum DownloadRoute {

View File

@ -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');
});

View File

@ -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 = {

10
core/testRunner.js Normal file
View File

@ -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;

View File

@ -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");
});
});

View File

@ -16,4 +16,5 @@
"types": ["@types/jest"],
},
"include": ["src"],
"exclude": ["**/*.test.ts"]
}

View File

@ -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',

View File

@ -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()

View File

@ -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' },
],

3
jest.config.js Normal file
View File

@ -0,0 +1,3 @@
module.exports = {
projects: ['<rootDir>/core', '<rootDir>/web', '<rootDir>/joi'],
}

8
joi/jest.config.js Normal file
View File

@ -0,0 +1,8 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/*.test.*'],
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jsdom',
}

0
joi/jest.setup.js Normal file
View File

View File

@ -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",

View File

@ -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(
<Accordion defaultValue={['item1']}>
<AccordionItem value="item1" title="Item 1">
Content 1
</AccordionItem>
<AccordionItem value="item2" title="Item 2">
Content 2
</AccordionItem>
</Accordion>
)
expect(screen.getByText('Item 1')).toBeInTheDocument()
expect(screen.getByText('Item 2')).toBeInTheDocument()
})
it('expands and collapses accordion items', () => {
render(
<Accordion defaultValue={[]}>
<AccordionItem value="item1" title="Item 1">
Content 1
</AccordionItem>
</Accordion>
)
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(
<Accordion defaultValue={['item2']}>
<AccordionItem value="item1" title="Item 1">
Content 1
</AccordionItem>
<AccordionItem value="item2" title="Item 2">
Content 2
</AccordionItem>
</Accordion>
)
expect(screen.queryByText('Content 1')).not.toBeInTheDocument()
expect(screen.getByText('Content 2')).toBeInTheDocument()
})
})

View File

@ -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(<Badge>Test Badge</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(<Badge className="custom-class">Test Badge</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(<Badge theme={theme as any}>Test Badge {theme}</Badge>)
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(<Badge variant={variant as any}>Test Badge {variant}</Badge>)
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(<Badge size={size as any}>Test Badge {size}</Badge>)
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)
})
})

View File

@ -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<HTMLDivElement>,

View File

@ -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(<Button>Click me</Button>)
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(
<Button asChild>
<a href="/">Link Button</a>
</Button>
)
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(<Button theme={theme as any}>Theme Button</Button>)
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(<Button variant={variant as any}>Variant Button</Button>)
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(<Button size={size as any}>Size Button</Button>)
const button = screen.getByRole('button', { name: /size button/i })
expect(button).toHaveClass(`btn btn--${size}`)
}
)
it('renders with block prop', () => {
render(<Button block>Block Button</Button>)
const button = screen.getByRole('button', { name: /block button/i })
expect(button).toHaveClass('btn btn--block')
})
it('merges custom className with generated classes', () => {
render(<Button className="custom-class">Custom Class Button</Button>)
const button = screen.getByRole('button', { name: /custom class button/i })
expect(button).toHaveClass(
'btn btn--primary btn--medium btn--solid custom-class'
)
})
})

View File

@ -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<HTMLButtonElement>,

View File

@ -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 (
<TabsPrimitive.Content className="tabs__content" value={value}>
<TabsPrimitive.Content
className={twMerge('tabs__content', className)}
value={value}
>
{children}
</TabsPrimitive.Content>
)
@ -40,11 +52,27 @@ const Tabs = ({
>
<TabsPrimitive.List className="tabs__list">
{options.map((option, i) => {
return (
return option.disabled ? (
<Tooltip
key={i}
content={option.tooltipContent}
trigger={
<TabsPrimitive.Trigger
key={i}
className="tabs__trigger"
value={option.value}
disabled={option.disabled}
>
{option.name}
</TabsPrimitive.Trigger>
}
/>
) : (
<TabsPrimitive.Trigger
key={i}
className="tabs__trigger"
value={option.value}
disabled={option.disabled}
>
{option.name}
</TabsPrimitive.Trigger>

View File

@ -21,6 +21,10 @@
&:focus {
position: relative;
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
&__content {

View File

@ -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 <div ref={ref as React.RefObject<HTMLDivElement>}>Test</div>
}
describe('@joi/hooks/useClickOutside', () => {
it('should call handler when clicking outside', () => {
const handleClickOutside = jest.fn()
const { container } = render(
<TestComponent onClickOutside={handleClickOutside} />
)
act(() => {
fireEvent.mouseDown(document.body)
})
expect(handleClickOutside).toHaveBeenCalledTimes(1)
})
it('should not call handler when clicking inside', () => {
const handleClickOutside = jest.fn()
const { getByText } = render(
<TestComponent onClickOutside={handleClickOutside} />
)
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 <div ref={ref as React.RefObject<HTMLDivElement>}>Test</div>
}
render(<TestComponentWithCustomEvent />)
act(() => {
fireEvent.click(document.body)
})
expect(handleClickOutside).toHaveBeenCalledTimes(1)
})
})

View File

@ -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)
})
})

View File

@ -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)
})
})

View File

@ -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)
})
})
})

View File

@ -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()
})
})

View File

@ -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)
})
})

View File

@ -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,

View File

@ -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 \\;",

View File

@ -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) => {

View File

@ -46,6 +46,16 @@ const BaseLayout = () => {
}
}, [setMainViewState])
useEffect(() => {
window.electronAPI?.onMainViewStateChange(
(_event: string, route: string) => {
if (route === 'Settings') {
setMainViewState(MainViewState.Settings)
}
}
)
}, [setMainViewState])
return (
<div
className={twMerge(

View File

@ -2,7 +2,7 @@ import { useState, useMemo, useEffect, useCallback, useRef } from 'react'
import Image from 'next/image'
import { InferenceEngine } from '@janhq/core'
import { InferenceEngine, Model } from '@janhq/core'
import {
Badge,
Button,
@ -28,6 +28,7 @@ import ModelLabel from '@/containers/ModelLabel'
import SetupRemoteModel from '@/containers/SetupRemoteModel'
import { useCreateNewThread } from '@/hooks/useCreateNewThread'
import useDownloadModel from '@/hooks/useDownloadModel'
import { modelDownloadStateAtom } from '@/hooks/useDownloadState'
import useRecommendedModel from '@/hooks/useRecommendedModel'
@ -92,6 +93,8 @@ const ModelDropdown = ({
)
const preserveModelSettings = useAtomValue(preserveModelSettingsAtom)
const { updateThreadMetadata } = useCreateNewThread()
useClickOutside(() => !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 ? (
<ul className="pb-2">
{featuredModel.map((model) => {
const isDownloading = downloadingModels.some(
(md) => md.id === model.id
)
return (
<li
key={model.id}
className="flex items-center justify-between gap-4 px-3 py-2 hover:bg-[hsla(var(--dropdown-menu-hover-bg))]"
showModel &&
!searchText.length && (
<ul className="pb-2">
{featuredModel.map((model) => {
const isDownloading = downloadingModels.some(
(md) => md.id === model.id
)
return (
<li
key={model.id}
className="flex items-center justify-between gap-4 px-3 py-2 hover:bg-[hsla(var(--dropdown-menu-hover-bg))]"
>
<div className="flex items-center gap-2">
<p
className="line-clamp-1 text-[hsla(var(--text-secondary))]"
title={model.name}
>
<div className="flex items-center gap-2">
<p
className="line-clamp-1 text-[hsla(var(--text-secondary))]"
title={model.name}
>
{model.name}
</p>
<ModelLabel
metadata={model.metadata}
compact
/>
</div>
<div className="flex items-center gap-2 text-[hsla(var(--text-tertiary))]">
<span className="font-medium">
{toGibibytes(model.metadata.size)}
</span>
{!isDownloading ? (
<DownloadCloudIcon
size={18}
className="cursor-pointer text-[hsla(var(--app-link))]"
onClick={() => downloadModel(model)}
/>
) : (
Object.values(downloadStates)
.filter((x) => x.modelId === model.id)
.map((item) => (
<ProgressCircle
key={item.modelId}
percentage={
formatDownloadPercentage(
item?.percent,
{
hidePercentage: true,
}
) as number
{model.name}
</p>
<ModelLabel
metadata={model.metadata}
compact
/>
</div>
<div className="flex items-center gap-2 text-[hsla(var(--text-tertiary))]">
<span className="font-medium">
{toGibibytes(model.metadata.size)}
</span>
{!isDownloading ? (
<DownloadCloudIcon
size={18}
className="cursor-pointer text-[hsla(var(--app-link))]"
onClick={() => downloadModel(model)}
/>
) : (
Object.values(downloadStates)
.filter((x) => x.modelId === model.id)
.map((item) => (
<ProgressCircle
key={item.modelId}
percentage={
formatDownloadPercentage(
item?.percent,
{
hidePercentage: true,
}
size={100}
/>
))
)}
</div>
</li>
)
})}
</ul>
) : (
<>
{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 (
<li
key={model.id}
className="flex items-center justify-between gap-4 px-3 py-2 hover:bg-[hsla(var(--dropdown-menu-hover-bg))]"
onClick={() => {
if (isdDownloaded) {
onClickModelItem(model.id)
}
}}
>
<div className="flex items-center gap-2">
<p
className={twMerge(
'line-clamp-1',
!isdDownloaded &&
'text-[hsla(var(--text-secondary))]'
)}
title={model.name}
>
{model.name}
</p>
<ModelLabel
metadata={model.metadata}
compact
) as number
}
size={100}
/>
</div>
<div className="flex items-center gap-2 text-[hsla(var(--text-tertiary))]">
{!isdDownloaded && (
<span className="font-medium">
{toGibibytes(model.metadata.size)}
</span>
)}
{!isDownloading && !isdDownloaded ? (
<DownloadCloudIcon
size={18}
className="cursor-pointer text-[hsla(var(--app-link))]"
onClick={() => downloadModel(model)}
/>
) : (
Object.values(downloadStates)
.filter(
(x) => x.modelId === model.id
)
.map((item) => (
<ProgressCircle
key={item.modelId}
percentage={
formatDownloadPercentage(
item?.percent,
{
hidePercentage: true,
}
) as number
}
size={100}
/>
))
)}
</div>
</li>
)
})}
</>
)}
</>
))
)}
</div>
</li>
)
})}
</ul>
)}
<ul className="pb-2">
{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 (
<li
key={model.id}
className={twMerge(
'cursor-pointer px-3 py-2 hover:bg-[hsla(var(--dropdown-menu-hover-bg))]',
'flex items-center justify-between gap-4 px-3 py-2 hover:bg-[hsla(var(--dropdown-menu-hover-bg))]',
!apiKey
? 'cursor-not-allowed text-[hsla(var(--text-tertiary))]'
: 'text-[hsla(var(--text-primary))]'
@ -607,13 +560,54 @@ const ModelDropdown = ({
!localEngines.includes(model.engine)
)
return null
onClickModelItem(model.id)
if (isdDownloaded) {
onClickModelItem(model.id)
}
}}
>
<div className="flex flex-shrink-0 gap-x-2">
<p className="line-clamp-1 " title={model.name}>
<div className="flex gap-x-2">
<p
className={twMerge(
'line-clamp-1',
!isdDownloaded &&
'text-[hsla(var(--text-secondary))]'
)}
title={model.name}
>
{model.name}
</p>
<ModelLabel metadata={model.metadata} compact />
</div>
<div className="flex items-center gap-2 text-[hsla(var(--text-tertiary))]">
{!isdDownloaded && (
<span className="font-medium">
{toGibibytes(model.metadata.size)}
</span>
)}
{!isDownloading && !isdDownloaded ? (
<DownloadCloudIcon
size={18}
className="cursor-pointer text-[hsla(var(--app-link))]"
onClick={() => downloadModel(model)}
/>
) : (
Object.values(downloadStates)
.filter((x) => x.modelId === model.id)
.map((item) => (
<ProgressCircle
key={item.modelId}
percentage={
formatDownloadPercentage(
item?.percent,
{
hidePercentage: true,
}
) as number
}
size={100}
/>
))
)}
</div>
</li>
)

View File

@ -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<boolean>(true)
const lastRightPanelState = useRef<boolean>(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 <Fragment>{children}</Fragment>

View File

@ -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 (
<div className="flex flex-col">
<div className="mb-3 flex items-center gap-x-2">
@ -48,7 +50,10 @@ const SliderRightPanel = ({
<div className="relative w-full">
<Slider
value={[value]}
onValueChange={(e) => 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={
<Input
type="number"
type="text"
className="-mt-4 h-8 w-[60px]"
min={min}
max={max}
value={String(value)}
value={val}
disabled={disabled}
textAlign="right"
onBlur={(e) => {
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)
}
}}
/>
}

View File

@ -1,4 +1,4 @@
import { HuggingFaceRepoData } from '@janhq/core/.'
import { HuggingFaceRepoData } from '@janhq/core'
import { atom } from 'jotai'
// modals

View File

@ -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 (
<div className="mx-auto flex h-full w-3/4 flex-col items-center justify-center text-center">
<LogoMark className="mx-auto mb-4 animate-wave" width={56} height={56} />
<h1 className="text-base font-semibold">Welcome!</h1>
<p className="mt-1 text-[hsla(var(--text-secondary))]">
You need to download your first model
</p>
<Button
className="mt-4"
onClick={() => setMainViewState(MainViewState.Hub)}
>
Explore The Hub
</Button>
</div>
<CenterPanelContainer>
<ScrollArea className="flex h-full w-full items-center">
<div className="relative mt-4 flex h-full w-full flex-col items-center justify-center">
<div className="mx-auto flex h-full w-3/4 flex-col items-center justify-center py-16 text-center">
<LogoMark
className="mx-auto mb-4 animate-wave"
width={48}
height={48}
/>
<h1 className="text-base font-semibold">Select a model to start</h1>
<div className="mt-6 w-full lg:w-1/2">
<Fragment>
<div className="relative">
<Input
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
placeholder="Search..."
prefixIcon={<SearchIcon size={16} />}
/>
<div
className={twMerge(
'absolute left-0 top-10 max-h-[240px] w-full overflow-x-auto rounded-lg border border-[hsla(var(--app-border))] bg-[hsla(var(--app-bg))]',
!searchValue.length ? 'invisible' : 'visible'
)}
>
{!filteredModels.length ? (
<div className="p-3 text-center">
<p className="line-clamp-1 text-[hsla(var(--text-secondary))]">
No Result Found
</p>
</div>
) : (
filteredModels.map((model) => {
const isDownloading = downloadingModels.some(
(md) => md.id === model.id
)
return (
<div
key={model.id}
className="flex items-center justify-between gap-4 px-3 py-2 hover:bg-[hsla(var(--dropdown-menu-hover-bg))]"
>
<div className="flex items-center gap-2">
<p
className={twMerge('line-clamp-1')}
title={model.name}
>
{model.name}
</p>
<ModelLabel metadata={model.metadata} compact />
</div>
<div className="flex items-center gap-2 text-[hsla(var(--text-tertiary))]">
<span className="font-medium">
{toGibibytes(model.metadata.size)}
</span>
{!isDownloading ? (
<DownloadCloudIcon
size={18}
className="cursor-pointer text-[hsla(var(--app-link))]"
onClick={() => downloadModel(model)}
/>
) : (
Object.values(downloadStates)
.filter((x) => x.modelId === model.id)
.map((item) => (
<ProgressCircle
key={item.modelId}
percentage={
formatDownloadPercentage(
item?.percent,
{
hidePercentage: true,
}
) as number
}
size={100}
/>
))
)}
</div>
</div>
)
})
)}
</div>
</div>
<div className="mt-6 flex items-center justify-between">
<h2 className="text-[hsla(var(--text-secondary))]">
On-device Models
</h2>
<p
className="cursor-pointer text-sm text-[hsla(var(--text-secondary))]"
onClick={() => {
setMainViewState(MainViewState.Hub)
}}
>
See All
</p>
</div>
{featuredModel.slice(0, 2).map((featModel) => {
const isDownloading = downloadingModels.some(
(md) => md.id === featModel.id
)
return (
<div
key={featModel.id}
className="my-2 flex items-center justify-between gap-2 border-b border-[hsla(var(--app-border))] py-4 last:border-none"
>
<div className="w-full text-left">
<h6>{featModel.name}</h6>
<p className="mt-1 text-[hsla(var(--text-secondary))]">
{featModel.metadata.author}
</p>
</div>
{isDownloading ? (
<div className="flex w-full items-center gap-2">
{Object.values(downloadStates).map((item, i) => (
<div
className="flex w-full items-center gap-2"
key={i}
>
<Progress
className="w-full"
value={
formatDownloadPercentage(item?.percent, {
hidePercentage: true,
}) as number
}
/>
<div className="flex items-center justify-between gap-x-2">
<div className="flex gap-x-2">
<span className="font-medium text-[hsla(var(--primary-bg))]">
{formatDownloadPercentage(item?.percent)}
</span>
</div>
</div>
</div>
))}
</div>
) : (
<Button
theme="ghost"
className="!bg-[hsla(var(--secondary-bg))]"
onClick={() => downloadModel(featModel)}
>
Download
</Button>
)}
</div>
)
})}
<div className="mb-4 mt-8 flex items-center justify-between">
<h2 className="text-[hsla(var(--text-secondary))]">
Cloud Models
</h2>
</div>
<div className="flex flex-col justify-center gap-6">
{rows.slice(0, visibleRows).map((row, rowIndex) => {
return (
<div
key={rowIndex}
className="my-2 flex items-center justify-normal gap-10"
>
{row.map((remoteEngine) => {
const engineLogo = getLogoEngine(
remoteEngine as InferenceEngine
)
return (
<div
className="flex cursor-pointer flex-col items-center justify-center gap-4"
key={remoteEngine}
onClick={() => {
setMainViewState(MainViewState.Settings)
setSelectedSetting(
extensionHasSettings.find((x) =>
x.name?.toLowerCase().includes(remoteEngine)
)?.setting as string
)
}}
>
{engineLogo && (
<Image
width={48}
height={48}
src={engineLogo}
alt="Engine logo"
className="h-10 w-10 flex-shrink-0"
/>
)}
<p>
{getTitleByEngine(
remoteEngine as InferenceEngine
)}
</p>
</div>
)
})}
</div>
)
})}
</div>
{visibleRows < rows.length && (
<button
onClick={() => setVisibleRows(visibleRows + 1)}
className="mt-4 text-[hsla(var(--text-secondary))]"
>
See More
</button>
)}
</Fragment>
</div>
</div>
</div>
</ScrollArea>
</CenterPanelContainer>
)
}
export default memo(EmptyModel)
export default OnDeviceStarterScreen

View File

@ -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 <EmptyModel />
if (!messages.length) return <EmptyThread />
return (

View File

@ -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 = () => {
</Button>
}
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 && (
<span>
Turn on Retrieval in Assistant Settings to use
this feature.
Turn on Retrieval in Tools settings to use this
feature
</span>
)}
{!isModelSupportRagAndTools && (
<span>Not supported for this model</span>
)}
</>
))}
</>

View File

@ -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 = () => {
<Accordion defaultValue={[]}>
{settings.runtimeSettings.length !== 0 && (
<AccordionItem
title="Inference Parameters"
value="Inference Parameters"
title={INFERENCE_SETTINGS}
value={INFERENCE_SETTINGS}
>
<ModelSetting
componentProps={settings.runtimeSettings}
@ -232,16 +254,13 @@ const ThreadRightPanel = () => {
)}
{promptTemplateSettings.length !== 0 && (
<AccordionItem title="Model Parameters" value="Model Parameters">
<AccordionItem title={MODEL_SETTINGS} value={MODEL_SETTINGS}>
<PromptTemplateSetting componentData={promptTemplateSettings} />
</AccordionItem>
)}
{settings.engineSettings.length !== 0 && (
<AccordionItem
title="Engine Parameters"
value="Engine Parameters"
>
<AccordionItem title={ENGINE_SETTINGS} value={ENGINE_SETTINGS}>
<EngineSetting
componentData={settings.engineSettings}
onValueChanged={onValueChanged}

View File

@ -1,17 +1,92 @@
import { useEffect, useState } from 'react'
import { useAtomValue } from 'jotai'
import ThreadLeftPanel from '@/screens/Thread/ThreadLeftPanel'
import { localEngines } from '@/utils/modelEngine'
import ThreadCenterPanel from './ThreadCenterPanel'
import OnDeviceStarterScreen from './ThreadCenterPanel/ChatBody/EmptyModel'
import ModalCleanThread from './ThreadLeftPanel/ModalCleanThread'
import ModalDeleteThread from './ThreadLeftPanel/ModalDeleteThread'
import ModalEditTitleThread from './ThreadLeftPanel/ModalEditTitleThread'
import ThreadRightPanel from './ThreadRightPanel'
import { extensionManager } from '@/extension'
import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom'
import { threadsAtom } from '@/helpers/atoms/Thread.atom'
const ThreadScreen = () => {
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 (
<div className="relative flex h-full w-full flex-1 overflow-x-hidden">
<ThreadLeftPanel />
<ThreadCenterPanel />
<ThreadRightPanel />
{!isAnyRemoteModelConfigured &&
!isDownloadALocalModel &&
!threads.length ? (
<>
<OnDeviceStarterScreen extensionHasSettings={extensionHasSettings} />
</>
) : (
<>
<ThreadLeftPanel />
<ThreadCenterPanel />
<ThreadRightPanel />
</>
)}
{/* Showing variant modal action for thread screen */}
<ModalEditTitleThread />

35
web/utils/Stack.test.ts Normal file
View File

@ -0,0 +1,35 @@
import { Stack } from './Stack';
it('should return elements in reverse order', () => {
const stack = new Stack<number>();
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<number>();
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<number>();
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<number>();
expect(stack.isEmpty()).toBe(true);
});

View File

@ -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([]);
});

View File

@ -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);
});

View File

@ -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);
});

View File

@ -1,5 +1,3 @@
// @auto-generated
import { utilizedMemory } from './memory'
test('test_utilizedMemory_arbitraryValues', () => {

View File

@ -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',
});
});

View File

@ -1,4 +1,4 @@
import { SettingComponentProps } from '@janhq/core/.'
import { SettingComponentProps } from '@janhq/core'
export const presetConfiguration: Record<string, SettingComponentProps> = {
prompt_template: {

9
web/utils/thread.test.ts Normal file
View File

@ -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);
});

View File

@ -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');
});