Merge branch 'bugreport' of https://github.com/janhq/jan into bugreport
This commit is contained in:
commit
e614702d18
17
.github/ISSUE_TEMPLATE/documentation-request.md
vendored
17
.github/ISSUE_TEMPLATE/documentation-request.md
vendored
@ -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
|
|
||||||
25
.github/ISSUE_TEMPLATE/epic-request.md
vendored
25
.github/ISSUE_TEMPLATE/epic-request.md
vendored
@ -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
|
|
||||||
@ -37,6 +37,33 @@ on:
|
|||||||
- '!README.md'
|
- '!README.md'
|
||||||
|
|
||||||
jobs:
|
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:
|
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'
|
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]
|
runs-on: [self-hosted, macOS, macos-desktop]
|
||||||
@ -292,6 +319,56 @@ jobs:
|
|||||||
TURBO_TEAM: 'linux'
|
TURBO_TEAM: 'linux'
|
||||||
TURBO_TOKEN: '${{ secrets.TURBO_TOKEN }}'
|
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:
|
test-on-ubuntu-pr-target:
|
||||||
runs-on: [self-hosted, Linux, ubuntu-desktop]
|
runs-on: [self-hosted, Linux, ubuntu-desktop]
|
||||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository
|
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -20,7 +20,7 @@ electron/themes
|
|||||||
electron/playwright-report
|
electron/playwright-report
|
||||||
server/pre-install
|
server/pre-install
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
coverage
|
||||||
*.log
|
*.log
|
||||||
core/lib/**
|
core/lib/**
|
||||||
|
|
||||||
@ -41,3 +41,5 @@ extensions/*-extension/bin/vulkaninfo
|
|||||||
.turbo
|
.turbo
|
||||||
electron/test-data
|
electron/test-data
|
||||||
electron/test-results
|
electron/test-results
|
||||||
|
core/test_results.html
|
||||||
|
coverage
|
||||||
|
|||||||
2
Makefile
2
Makefile
@ -104,7 +104,7 @@ endif
|
|||||||
# Testing
|
# Testing
|
||||||
test: lint
|
test: lint
|
||||||
yarn build:test
|
yarn build:test
|
||||||
yarn test:unit
|
yarn test:coverage
|
||||||
yarn test
|
yarn test
|
||||||
|
|
||||||
# Builds and publishes the app
|
# Builds and publishes the app
|
||||||
|
|||||||
@ -4,4 +4,5 @@ module.exports = {
|
|||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'@/(.*)': '<rootDir>/src/$1',
|
'@/(.*)': '<rootDir>/src/$1',
|
||||||
},
|
},
|
||||||
|
runner: './testRunner.js',
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,6 +46,8 @@
|
|||||||
"eslint": "8.57.0",
|
"eslint": "8.57.0",
|
||||||
"eslint-plugin-jest": "^27.9.0",
|
"eslint-plugin-jest": "^27.9.0",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
|
"jest-junit": "^16.0.0",
|
||||||
|
"jest-runner": "^29.7.0",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"rollup": "^2.38.5",
|
"rollup": "^2.38.5",
|
||||||
"rollup-plugin-commonjs": "^9.1.8",
|
"rollup-plugin-commonjs": "^9.1.8",
|
||||||
@ -53,7 +55,7 @@
|
|||||||
"rollup-plugin-node-resolve": "^5.2.0",
|
"rollup-plugin-node-resolve": "^5.2.0",
|
||||||
"rollup-plugin-sourcemaps": "^0.6.3",
|
"rollup-plugin-sourcemaps": "^0.6.3",
|
||||||
"rollup-plugin-typescript2": "^0.36.0",
|
"rollup-plugin-typescript2": "^0.36.0",
|
||||||
"ts-jest": "^29.1.2",
|
"ts-jest": "^29.2.5",
|
||||||
"tslib": "^2.6.2",
|
"tslib": "^2.6.2",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
},
|
},
|
||||||
|
|||||||
98
core/src/browser/core.test.ts
Normal file
98
core/src/browser/core.test.ts
Normal 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');
|
||||||
|
});
|
||||||
37
core/src/browser/events.test.ts
Normal file
37
core/src/browser/events.test.ts
Normal 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);
|
||||||
|
});
|
||||||
46
core/src/browser/extension.test.ts
Normal file
46
core/src/browser/extension.test.ts
Normal 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
|
||||||
|
})
|
||||||
|
})
|
||||||
60
core/src/browser/extensions/engines/helpers/sse.test.ts
Normal file
60
core/src/browser/extensions/engines/helpers/sse.test.ts
Normal 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' })
|
||||||
|
})
|
||||||
|
})
|
||||||
32
core/src/browser/index.test.ts
Normal file
32
core/src/browser/index.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
10
core/src/node/api/common/adapter.test.ts
Normal file
10
core/src/node/api/common/adapter.test.ts
Normal 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();
|
||||||
|
});
|
||||||
25
core/src/node/api/common/handler.test.ts
Normal file
25
core/src/node/api/common/handler.test.ts
Normal 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);
|
||||||
|
});
|
||||||
40
core/src/node/api/processors/app.test.ts
Normal file
40
core/src/node/api/processors/app.test.ts
Normal 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']);
|
||||||
|
});
|
||||||
59
core/src/node/api/processors/download.test.ts
Normal file
59
core/src/node/api/processors/download.test.ts
Normal 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'
|
||||||
|
}));
|
||||||
|
});
|
||||||
9
core/src/node/api/processors/extension.test.ts
Normal file
9
core/src/node/api/processors/extension.test.ts
Normal 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');
|
||||||
|
});
|
||||||
18
core/src/node/api/processors/fs.test.ts
Normal file
18
core/src/node/api/processors/fs.test.ts
Normal 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]');
|
||||||
|
});
|
||||||
34
core/src/node/api/processors/fsExt.test.ts
Normal file
34
core/src/node/api/processors/fsExt.test.ts
Normal 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([]);
|
||||||
|
});
|
||||||
0
core/src/node/api/processors/processor.test.ts
Normal file
0
core/src/node/api/processors/processor.test.ts
Normal file
62
core/src/node/api/restful/app/download.test.ts
Normal file
62
core/src/node/api/restful/app/download.test.ts
Normal 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' })
|
||||||
|
})
|
||||||
|
})
|
||||||
16
core/src/node/api/restful/app/handlers.test.ts
Normal file
16
core/src/node/api/restful/app/handlers.test.ts
Normal 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();
|
||||||
|
});
|
||||||
21
core/src/node/api/restful/common.test.ts
Normal file
21
core/src/node/api/restful/common.test.ts
Normal 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));
|
||||||
|
});
|
||||||
|
});
|
||||||
24
core/src/node/api/restful/helper/configuration.test.ts
Normal file
24
core/src/node/api/restful/helper/configuration.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
16
core/src/node/api/restful/v1.test.ts
Normal file
16
core/src/node/api/restful/v1.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
|
||||||
122
core/src/node/extension/extension.test.ts
Normal file
122
core/src/node/extension/extension.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
|
||||||
28
core/src/node/extension/manager.test.ts
Normal file
28
core/src/node/extension/manager.test.ts
Normal 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);
|
||||||
|
});
|
||||||
43
core/src/node/extension/store.test.ts
Normal file
43
core/src/node/extension/store.test.ts
Normal 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]);
|
||||||
|
});
|
||||||
14
core/src/node/helper/config.test.ts
Normal file
14
core/src/node/helper/config.test.ts
Normal 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());
|
||||||
|
});
|
||||||
11
core/src/node/helper/download.test.ts
Normal file
11
core/src/node/helper/download.test.ts
Normal 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);
|
||||||
|
});
|
||||||
47
core/src/node/helper/logger.test.ts
Normal file
47
core/src/node/helper/logger.test.ts
Normal 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);
|
||||||
|
});
|
||||||
23
core/src/node/helper/module.test.ts
Normal file
23
core/src/node/helper/module.test.ts
Normal 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);
|
||||||
|
});
|
||||||
29
core/src/node/helper/path.test.ts
Normal file
29
core/src/node/helper/path.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
15
core/src/node/helper/resource.test.ts
Normal file
15
core/src/node/helper/resource.test.ts
Normal 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}`);
|
||||||
|
});
|
||||||
@ -55,6 +55,7 @@ export enum AppEvent {
|
|||||||
onSelectedText = 'onSelectedText',
|
onSelectedText = 'onSelectedText',
|
||||||
|
|
||||||
onDeepLink = 'onDeepLink',
|
onDeepLink = 'onDeepLink',
|
||||||
|
onMainViewStateChange = 'onMainViewStateChange',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum DownloadRoute {
|
export enum DownloadRoute {
|
||||||
|
|||||||
7
core/src/types/assistant/assistantEvent.test.ts
Normal file
7
core/src/types/assistant/assistantEvent.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
|
||||||
@ -16,7 +16,7 @@ export type DownloadState = {
|
|||||||
|
|
||||||
error?: string
|
error?: string
|
||||||
extensionId?: string
|
extensionId?: string
|
||||||
downloadType?: DownloadType
|
downloadType?: DownloadType | string
|
||||||
localPath?: string
|
localPath?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -40,7 +40,7 @@ export type DownloadRequest = {
|
|||||||
*/
|
*/
|
||||||
extensionId?: string
|
extensionId?: string
|
||||||
|
|
||||||
downloadType?: DownloadType
|
downloadType?: DownloadType | string
|
||||||
}
|
}
|
||||||
|
|
||||||
type DownloadTime = {
|
type DownloadTime = {
|
||||||
|
|||||||
10
core/testRunner.js
Normal file
10
core/testRunner.js
Normal 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;
|
||||||
@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -16,4 +16,5 @@
|
|||||||
"types": ["@types/jest"],
|
"types": ["@types/jest"],
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
|
"exclude": ["**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,9 +6,10 @@ export const mainWindowConfig: Electron.BrowserWindowConstructorOptions = {
|
|||||||
minWidth: DEFAULT_MIN_WIDTH,
|
minWidth: DEFAULT_MIN_WIDTH,
|
||||||
minHeight: DEFAULT_MIN_HEIGHT,
|
minHeight: DEFAULT_MIN_HEIGHT,
|
||||||
show: true,
|
show: true,
|
||||||
transparent: true,
|
// we want to go frameless on windows and linux
|
||||||
frame: false,
|
transparent: process.platform === 'darwin',
|
||||||
titleBarStyle: 'hidden',
|
frame: process.platform === 'darwin',
|
||||||
|
titleBarStyle: 'hiddenInset',
|
||||||
vibrancy: 'fullscreen-ui',
|
vibrancy: 'fullscreen-ui',
|
||||||
visualEffectState: 'active',
|
visualEffectState: 'active',
|
||||||
backgroundMaterial: 'acrylic',
|
backgroundMaterial: 'acrylic',
|
||||||
|
|||||||
@ -166,6 +166,15 @@ class WindowManager {
|
|||||||
}, 500)
|
}, 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 {
|
cleanUp(): void {
|
||||||
if (!this.mainWindow?.isDestroyed()) {
|
if (!this.mainWindow?.isDestroyed()) {
|
||||||
this.mainWindow?.close()
|
this.mainWindow?.close()
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { app, Menu, shell, dialog } from 'electron'
|
|||||||
import { autoUpdater } from 'electron-updater'
|
import { autoUpdater } from 'electron-updater'
|
||||||
import { log } from '@janhq/core/node'
|
import { log } from '@janhq/core/node'
|
||||||
const isMac = process.platform === 'darwin'
|
const isMac = process.platform === 'darwin'
|
||||||
|
import { windowManager } from '../managers/window'
|
||||||
|
|
||||||
const template: (Electron.MenuItemConstructorOptions | Electron.MenuItem)[] = [
|
const template: (Electron.MenuItemConstructorOptions | Electron.MenuItem)[] = [
|
||||||
{
|
{
|
||||||
@ -43,6 +44,14 @@ const template: (Electron.MenuItemConstructorOptions | Electron.MenuItem)[] = [
|
|||||||
{ role: 'hide' },
|
{ role: 'hide' },
|
||||||
{ role: 'hideOthers' },
|
{ role: 'hideOthers' },
|
||||||
{ role: 'unhide' },
|
{ role: 'unhide' },
|
||||||
|
{
|
||||||
|
label: `Settings`,
|
||||||
|
accelerator: 'CmdOrCtrl+,',
|
||||||
|
click: () => {
|
||||||
|
windowManager.showMainWindow()
|
||||||
|
windowManager.sendMainViewState('Settings')
|
||||||
|
},
|
||||||
|
},
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ role: 'quit' },
|
{ role: 'quit' },
|
||||||
],
|
],
|
||||||
|
|||||||
3
jest.config.js
Normal file
3
jest.config.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
module.exports = {
|
||||||
|
projects: ['<rootDir>/core', '<rootDir>/web', '<rootDir>/joi'],
|
||||||
|
}
|
||||||
8
joi/jest.config.js
Normal file
8
joi/jest.config.js
Normal 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
0
joi/jest.setup.js
Normal file
@ -21,7 +21,8 @@
|
|||||||
"bugs": "https://github.com/codecentrum/piksel/issues",
|
"bugs": "https://github.com/codecentrum/piksel/issues",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "rollup -c -w",
|
"dev": "rollup -c -w",
|
||||||
"build": "rimraf ./dist && rollup -c"
|
"build": "rimraf ./dist && rollup -c",
|
||||||
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
@ -38,13 +39,22 @@
|
|||||||
"@radix-ui/react-slot": "^1.0.2",
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
"@radix-ui/react-tabs": "^1.0.4",
|
"@radix-ui/react-tabs": "^1.0.4",
|
||||||
"@radix-ui/react-tooltip": "^1.0.7",
|
"@radix-ui/react-tooltip": "^1.0.7",
|
||||||
"tailwind-merge": "^2.2.0",
|
"@types/jest": "^29.5.12",
|
||||||
"autoprefixer": "10.4.16",
|
"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": {
|
"devDependencies": {
|
||||||
"@rollup/plugin-node-resolve": "^15.2.3",
|
"@rollup/plugin-node-resolve": "^15.2.3",
|
||||||
"@rollup/plugin-terser": "^0.4.4",
|
"@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": "^3.0.3",
|
||||||
"prettier-plugin-tailwindcss": "^0.5.6",
|
"prettier-plugin-tailwindcss": "^0.5.6",
|
||||||
"rollup": "^4.12.0",
|
"rollup": "^4.12.0",
|
||||||
|
|||||||
64
joi/src/core/Accordion/Accordion.test.tsx
Normal file
64
joi/src/core/Accordion/Accordion.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
83
joi/src/core/Badge/Badge.test.tsx
Normal file
83
joi/src/core/Badge/Badge.test.tsx
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -6,7 +6,7 @@ import { twMerge } from 'tailwind-merge'
|
|||||||
|
|
||||||
import './styles.scss'
|
import './styles.scss'
|
||||||
|
|
||||||
const badgeVariants = cva('badge', {
|
export const badgeConfig = {
|
||||||
variants: {
|
variants: {
|
||||||
theme: {
|
theme: {
|
||||||
primary: 'badge--primary',
|
primary: 'badge--primary',
|
||||||
@ -28,11 +28,13 @@ const badgeVariants = cva('badge', {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
theme: 'primary',
|
theme: 'primary' as const,
|
||||||
size: 'medium',
|
size: 'medium' as const,
|
||||||
variant: 'solid',
|
variant: 'solid' as const,
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
|
||||||
|
const badgeVariants = cva('badge', badgeConfig)
|
||||||
|
|
||||||
export interface BadgeProps
|
export interface BadgeProps
|
||||||
extends HTMLAttributes<HTMLDivElement>,
|
extends HTMLAttributes<HTMLDivElement>,
|
||||||
|
|||||||
68
joi/src/core/Button/Button.test.tsx
Normal file
68
joi/src/core/Button/Button.test.tsx
Normal 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'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -7,7 +7,7 @@ import { twMerge } from 'tailwind-merge'
|
|||||||
|
|
||||||
import './styles.scss'
|
import './styles.scss'
|
||||||
|
|
||||||
const buttonVariants = cva('btn', {
|
export const buttonConfig = {
|
||||||
variants: {
|
variants: {
|
||||||
theme: {
|
theme: {
|
||||||
primary: 'btn--primary',
|
primary: 'btn--primary',
|
||||||
@ -30,12 +30,13 @@ const buttonVariants = cva('btn', {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
theme: 'primary',
|
theme: 'primary' as const,
|
||||||
size: 'medium',
|
size: 'medium' as const,
|
||||||
variant: 'solid',
|
variant: 'solid' as const,
|
||||||
block: false,
|
block: false as const,
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
const buttonVariants = cva('btn', buttonConfig)
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
extends ButtonHTMLAttributes<HTMLButtonElement>,
|
extends ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
|||||||
@ -2,10 +2,18 @@ import React, { ReactNode } from 'react'
|
|||||||
|
|
||||||
import * as TabsPrimitive from '@radix-ui/react-tabs'
|
import * as TabsPrimitive from '@radix-ui/react-tabs'
|
||||||
|
|
||||||
|
import { Tooltip } from '../Tooltip'
|
||||||
|
|
||||||
import './styles.scss'
|
import './styles.scss'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
type TabsProps = {
|
type TabsProps = {
|
||||||
options: { name: string; value: string }[]
|
options: {
|
||||||
|
name: string
|
||||||
|
value: string
|
||||||
|
disabled?: boolean
|
||||||
|
tooltipContent?: string
|
||||||
|
}[]
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
defaultValue?: string
|
defaultValue?: string
|
||||||
value: string
|
value: string
|
||||||
@ -15,11 +23,15 @@ type TabsProps = {
|
|||||||
type TabsContentProps = {
|
type TabsContentProps = {
|
||||||
value: string
|
value: string
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const TabsContent = ({ value, children }: TabsContentProps) => {
|
const TabsContent = ({ value, children, className }: TabsContentProps) => {
|
||||||
return (
|
return (
|
||||||
<TabsPrimitive.Content className="tabs__content" value={value}>
|
<TabsPrimitive.Content
|
||||||
|
className={twMerge('tabs__content', className)}
|
||||||
|
value={value}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</TabsPrimitive.Content>
|
</TabsPrimitive.Content>
|
||||||
)
|
)
|
||||||
@ -40,11 +52,27 @@ const Tabs = ({
|
|||||||
>
|
>
|
||||||
<TabsPrimitive.List className="tabs__list">
|
<TabsPrimitive.List className="tabs__list">
|
||||||
{options.map((option, i) => {
|
{options.map((option, i) => {
|
||||||
return (
|
return option.disabled ? (
|
||||||
|
<Tooltip
|
||||||
|
key={i}
|
||||||
|
content={option.tooltipContent}
|
||||||
|
trigger={
|
||||||
<TabsPrimitive.Trigger
|
<TabsPrimitive.Trigger
|
||||||
key={i}
|
key={i}
|
||||||
className="tabs__trigger"
|
className="tabs__trigger"
|
||||||
value={option.value}
|
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}
|
{option.name}
|
||||||
</TabsPrimitive.Trigger>
|
</TabsPrimitive.Trigger>
|
||||||
|
|||||||
@ -21,6 +21,10 @@
|
|||||||
&:focus {
|
&:focus {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__content {
|
&__content {
|
||||||
|
|||||||
55
joi/src/hooks/useClickOutside/useClickOutside.test.tsx
Normal file
55
joi/src/hooks/useClickOutside/useClickOutside.test.tsx
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
102
joi/src/hooks/useClipboard/useClipboard.test.ts
Normal file
102
joi/src/hooks/useClipboard/useClipboard.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
90
joi/src/hooks/useMediaQuery/useMediaQuery.test.ts
Normal file
90
joi/src/hooks/useMediaQuery/useMediaQuery.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
39
joi/src/hooks/useOs/useOs.test.ts
Normal file
39
joi/src/hooks/useOs/useOs.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
32
joi/src/hooks/usePageLeave/usePageLeave.test.ts
Normal file
32
joi/src/hooks/usePageLeave/usePageLeave.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
56
joi/src/hooks/useTextSelection/useTextSelection.test.ts
Normal file
56
joi/src/hooks/useTextSelection/useTextSelection.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -3,6 +3,7 @@
|
|||||||
"target": "esnext",
|
"target": "esnext",
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"declarationDir": "dist/types",
|
"declarationDir": "dist/types",
|
||||||
|
"types": ["jest", "@testing-library/jest-dom"],
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"lib": ["es6", "dom", "es2016", "es2017"],
|
"lib": ["es6", "dom", "es2016", "es2017"],
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
|
|||||||
@ -19,7 +19,8 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "yarn workspace jan lint && yarn workspace @janhq/web lint",
|
"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": "yarn workspace jan test:e2e",
|
||||||
"test-local": "yarn lint && yarn build:test && yarn test",
|
"test-local": "yarn lint && yarn build:test && yarn test",
|
||||||
"pre-install:darwin": "find extensions -type f -path \"**/*.tgz\" -exec cp {} pre-install \\;",
|
"pre-install:darwin": "find extensions -type f -path \"**/*.tgz\" -exec cp {} pre-install \\;",
|
||||||
|
|||||||
@ -12,9 +12,12 @@ import { twMerge } from 'tailwind-merge'
|
|||||||
|
|
||||||
import { MainViewState } from '@/constants/screens'
|
import { MainViewState } from '@/constants/screens'
|
||||||
|
|
||||||
|
import { localEngines } from '@/utils/modelEngine'
|
||||||
|
|
||||||
import { mainViewStateAtom, showLeftPanelAtom } from '@/helpers/atoms/App.atom'
|
import { mainViewStateAtom, showLeftPanelAtom } from '@/helpers/atoms/App.atom'
|
||||||
import { editMessageAtom } from '@/helpers/atoms/ChatMessage.atom'
|
import { editMessageAtom } from '@/helpers/atoms/ChatMessage.atom'
|
||||||
import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom'
|
import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom'
|
||||||
|
import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom'
|
||||||
import {
|
import {
|
||||||
reduceTransparentAtom,
|
reduceTransparentAtom,
|
||||||
selectedSettingAtom,
|
selectedSettingAtom,
|
||||||
@ -28,6 +31,7 @@ export default function RibbonPanel() {
|
|||||||
const matches = useMediaQuery('(max-width: 880px)')
|
const matches = useMediaQuery('(max-width: 880px)')
|
||||||
const reduceTransparent = useAtomValue(reduceTransparentAtom)
|
const reduceTransparent = useAtomValue(reduceTransparentAtom)
|
||||||
const setSelectedSetting = useSetAtom(selectedSettingAtom)
|
const setSelectedSetting = useSetAtom(selectedSettingAtom)
|
||||||
|
const downloadedModels = useAtomValue(downloadedModelsAtom)
|
||||||
|
|
||||||
const onMenuClick = (state: MainViewState) => {
|
const onMenuClick = (state: MainViewState) => {
|
||||||
if (mainViewState === state) return
|
if (mainViewState === state) return
|
||||||
@ -37,6 +41,10 @@ export default function RibbonPanel() {
|
|||||||
setEditMessage('')
|
setEditMessage('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isDownloadALocalModel = downloadedModels.some((x) =>
|
||||||
|
localEngines.includes(x.engine)
|
||||||
|
)
|
||||||
|
|
||||||
const RibbonNavMenus = [
|
const RibbonNavMenus = [
|
||||||
{
|
{
|
||||||
name: 'Thread',
|
name: 'Thread',
|
||||||
@ -77,7 +85,10 @@ export default function RibbonPanel() {
|
|||||||
'border-none',
|
'border-none',
|
||||||
!showLeftPanel && !reduceTransparent && 'border-none',
|
!showLeftPanel && !reduceTransparent && 'border-none',
|
||||||
matches && !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) => {
|
{RibbonNavMenus.filter((menu) => !!menu).map((menu, i) => {
|
||||||
|
|||||||
@ -46,6 +46,16 @@ const BaseLayout = () => {
|
|||||||
}
|
}
|
||||||
}, [setMainViewState])
|
}, [setMainViewState])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.electronAPI?.onMainViewStateChange(
|
||||||
|
(_event: string, route: string) => {
|
||||||
|
if (route === 'Settings') {
|
||||||
|
setMainViewState(MainViewState.Settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}, [setMainViewState])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useState, useMemo, useEffect, useCallback, useRef } from 'react'
|
|||||||
|
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
|
|
||||||
import { InferenceEngine } from '@janhq/core'
|
import { InferenceEngine, Model } from '@janhq/core'
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
@ -28,6 +28,7 @@ import ModelLabel from '@/containers/ModelLabel'
|
|||||||
|
|
||||||
import SetupRemoteModel from '@/containers/SetupRemoteModel'
|
import SetupRemoteModel from '@/containers/SetupRemoteModel'
|
||||||
|
|
||||||
|
import { useCreateNewThread } from '@/hooks/useCreateNewThread'
|
||||||
import useDownloadModel from '@/hooks/useDownloadModel'
|
import useDownloadModel from '@/hooks/useDownloadModel'
|
||||||
import { modelDownloadStateAtom } from '@/hooks/useDownloadState'
|
import { modelDownloadStateAtom } from '@/hooks/useDownloadState'
|
||||||
import useRecommendedModel from '@/hooks/useRecommendedModel'
|
import useRecommendedModel from '@/hooks/useRecommendedModel'
|
||||||
@ -92,6 +93,8 @@ const ModelDropdown = ({
|
|||||||
)
|
)
|
||||||
const preserveModelSettings = useAtomValue(preserveModelSettingsAtom)
|
const preserveModelSettings = useAtomValue(preserveModelSettingsAtom)
|
||||||
|
|
||||||
|
const { updateThreadMetadata } = useCreateNewThread()
|
||||||
|
|
||||||
useClickOutside(() => !filterOptionsOpen && setOpen(false), null, [
|
useClickOutside(() => !filterOptionsOpen && setOpen(false), null, [
|
||||||
dropdownOptions,
|
dropdownOptions,
|
||||||
toggle,
|
toggle,
|
||||||
@ -101,6 +104,13 @@ const ModelDropdown = ({
|
|||||||
showEngineListModelAtom
|
showEngineListModelAtom
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const isModelSupportRagAndTools = useCallback((model: Model) => {
|
||||||
|
return (
|
||||||
|
model?.engine === InferenceEngine.openai ||
|
||||||
|
localEngines.includes(model?.engine as InferenceEngine)
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const filteredDownloadedModels = useMemo(
|
const filteredDownloadedModels = useMemo(
|
||||||
() =>
|
() =>
|
||||||
configuredModels
|
configuredModels
|
||||||
@ -161,6 +171,26 @@ const ModelDropdown = ({
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
|
|
||||||
if (activeThread) {
|
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
|
// Default setting ctx_len for the model for a better onboarding experience
|
||||||
// TODO: When Cortex support hardware instructions, we should remove this
|
// TODO: When Cortex support hardware instructions, we should remove this
|
||||||
const defaultContextLength = preserveModelSettings
|
const defaultContextLength = preserveModelSettings
|
||||||
@ -201,8 +231,10 @@ const ModelDropdown = ({
|
|||||||
downloadedModels,
|
downloadedModels,
|
||||||
activeThread,
|
activeThread,
|
||||||
setSelectedModel,
|
setSelectedModel,
|
||||||
|
isModelSupportRagAndTools,
|
||||||
setThreadModelParams,
|
setThreadModelParams,
|
||||||
updateModelParameter,
|
updateModelParameter,
|
||||||
|
updateThreadMetadata,
|
||||||
preserveModelSettings,
|
preserveModelSettings,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@ -433,9 +465,8 @@ const ModelDropdown = ({
|
|||||||
|
|
||||||
{engine === InferenceEngine.nitro &&
|
{engine === InferenceEngine.nitro &&
|
||||||
!isDownloadALocalModel &&
|
!isDownloadALocalModel &&
|
||||||
showModel && (
|
showModel &&
|
||||||
<>
|
!searchText.length && (
|
||||||
{!searchText.length ? (
|
|
||||||
<ul className="pb-2">
|
<ul className="pb-2">
|
||||||
{featuredModel.map((model) => {
|
{featuredModel.map((model) => {
|
||||||
const isDownloading = downloadingModels.some(
|
const isDownloading = downloadingModels.some(
|
||||||
@ -491,22 +522,23 @@ const ModelDropdown = ({
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
)}
|
||||||
<>
|
|
||||||
|
<ul className="pb-2">
|
||||||
{filteredDownloadedModels
|
{filteredDownloadedModels
|
||||||
.filter(
|
.filter((x) => x.engine === engine)
|
||||||
(x) => x.engine === InferenceEngine.nitro
|
.filter((y) => {
|
||||||
)
|
if (
|
||||||
.filter((x) => {
|
localEngines.includes(y.engine) &&
|
||||||
if (searchText.length === 0) {
|
!searchText.length
|
||||||
return downloadedModels.find(
|
) {
|
||||||
(c) => c.id === x.id
|
return downloadedModels.find((c) => c.id === y.id)
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
return x
|
return y
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.map((model) => {
|
.map((model) => {
|
||||||
|
if (!showModel) return null
|
||||||
const isDownloading = downloadingModels.some(
|
const isDownloading = downloadingModels.some(
|
||||||
(md) => md.id === model.id
|
(md) => md.id === model.id
|
||||||
)
|
)
|
||||||
@ -516,14 +548,24 @@ const ModelDropdown = ({
|
|||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={model.id}
|
key={model.id}
|
||||||
className="flex items-center justify-between gap-4 px-3 py-2 hover:bg-[hsla(var(--dropdown-menu-hover-bg))]"
|
className={twMerge(
|
||||||
|
'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))]'
|
||||||
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (
|
||||||
|
!apiKey &&
|
||||||
|
!localEngines.includes(model.engine)
|
||||||
|
)
|
||||||
|
return null
|
||||||
if (isdDownloaded) {
|
if (isdDownloaded) {
|
||||||
onClickModelItem(model.id)
|
onClickModelItem(model.id)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex gap-x-2">
|
||||||
<p
|
<p
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
'line-clamp-1',
|
'line-clamp-1',
|
||||||
@ -534,10 +576,7 @@ const ModelDropdown = ({
|
|||||||
>
|
>
|
||||||
{model.name}
|
{model.name}
|
||||||
</p>
|
</p>
|
||||||
<ModelLabel
|
<ModelLabel metadata={model.metadata} compact />
|
||||||
metadata={model.metadata}
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-[hsla(var(--text-tertiary))]">
|
<div className="flex items-center gap-2 text-[hsla(var(--text-tertiary))]">
|
||||||
{!isdDownloaded && (
|
{!isdDownloaded && (
|
||||||
@ -553,9 +592,7 @@ const ModelDropdown = ({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
Object.values(downloadStates)
|
Object.values(downloadStates)
|
||||||
.filter(
|
.filter((x) => x.modelId === model.id)
|
||||||
(x) => x.modelId === model.id
|
|
||||||
)
|
|
||||||
.map((item) => (
|
.map((item) => (
|
||||||
<ProgressCircle
|
<ProgressCircle
|
||||||
key={item.modelId}
|
key={item.modelId}
|
||||||
@ -575,49 +612,6 @@ const ModelDropdown = ({
|
|||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ul className="pb-2">
|
|
||||||
{filteredDownloadedModels
|
|
||||||
.filter((x) => x.engine === engine)
|
|
||||||
.filter((y) => {
|
|
||||||
if (localEngines.includes(y.engine)) {
|
|
||||||
return downloadedModels.find((c) => c.id === y.id)
|
|
||||||
} else {
|
|
||||||
return y
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.map((model) => {
|
|
||||||
if (!showModel) return null
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
key={model.id}
|
|
||||||
className={twMerge(
|
|
||||||
'cursor-pointer 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))]'
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
if (
|
|
||||||
!apiKey &&
|
|
||||||
!localEngines.includes(model.engine)
|
|
||||||
)
|
|
||||||
return null
|
|
||||||
onClickModelItem(model.id)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex flex-shrink-0 gap-x-2">
|
|
||||||
<p className="line-clamp-1 " title={model.name}>
|
|
||||||
{model.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,24 +1,33 @@
|
|||||||
import { Fragment, PropsWithChildren, useEffect } from 'react'
|
import { Fragment, PropsWithChildren, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
import { useMediaQuery } from '@janhq/joi'
|
import { useMediaQuery } from '@janhq/joi'
|
||||||
|
import { useAtom } from 'jotai'
|
||||||
import { useSetAtom } from 'jotai'
|
|
||||||
|
|
||||||
import { showLeftPanelAtom, showRightPanelAtom } from '@/helpers/atoms/App.atom'
|
import { showLeftPanelAtom, showRightPanelAtom } from '@/helpers/atoms/App.atom'
|
||||||
|
|
||||||
const Responsive = ({ children }: PropsWithChildren) => {
|
const Responsive = ({ children }: PropsWithChildren) => {
|
||||||
const matches = useMediaQuery('(max-width: 880px)')
|
const matches = useMediaQuery('(max-width: 880px)')
|
||||||
const setShowLeftPanel = useSetAtom(showLeftPanelAtom)
|
const [showLeftPanel, setShowLeftPanel] = useAtom(showLeftPanelAtom)
|
||||||
const setShowRightPanel = useSetAtom(showRightPanelAtom)
|
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(() => {
|
useEffect(() => {
|
||||||
if (matches) {
|
if (matches) {
|
||||||
|
// Store the last known state before closing the panels
|
||||||
|
lastLeftPanelState.current = showLeftPanel
|
||||||
|
lastRightPanelState.current = showRightPanel
|
||||||
|
|
||||||
setShowLeftPanel(false)
|
setShowLeftPanel(false)
|
||||||
setShowRightPanel(false)
|
setShowRightPanel(false)
|
||||||
} else {
|
} else {
|
||||||
setShowLeftPanel(true)
|
// Restore the last known state when the screen is resized back
|
||||||
setShowRightPanel(true)
|
setShowLeftPanel(lastLeftPanelState.current)
|
||||||
|
setShowRightPanel(lastRightPanelState.current)
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [matches, setShowLeftPanel, setShowRightPanel])
|
}, [matches, setShowLeftPanel, setShowRightPanel])
|
||||||
|
|
||||||
return <Fragment>{children}</Fragment>
|
return <Fragment>{children}</Fragment>
|
||||||
|
|||||||
@ -28,8 +28,10 @@ const SliderRightPanel = ({
|
|||||||
onValueChanged,
|
onValueChanged,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [showTooltip, setShowTooltip] = useState({ max: false, min: false })
|
const [showTooltip, setShowTooltip] = useState({ max: false, min: false })
|
||||||
|
const [val, setVal] = useState(value.toString())
|
||||||
|
|
||||||
useClickOutside(() => setShowTooltip({ max: false, min: false }), null, [])
|
useClickOutside(() => setShowTooltip({ max: false, min: false }), null, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="mb-3 flex items-center gap-x-2">
|
<div className="mb-3 flex items-center gap-x-2">
|
||||||
@ -48,7 +50,10 @@ const SliderRightPanel = ({
|
|||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
<Slider
|
<Slider
|
||||||
value={[value]}
|
value={[value]}
|
||||||
onValueChange={(e) => onValueChanged?.(e[0])}
|
onValueChange={(e) => {
|
||||||
|
onValueChanged?.(e[0])
|
||||||
|
setVal(e[0].toString())
|
||||||
|
}}
|
||||||
min={min}
|
min={min}
|
||||||
max={max}
|
max={max}
|
||||||
step={step}
|
step={step}
|
||||||
@ -63,24 +68,29 @@ const SliderRightPanel = ({
|
|||||||
open={showTooltip.max || showTooltip.min}
|
open={showTooltip.max || showTooltip.min}
|
||||||
trigger={
|
trigger={
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="text"
|
||||||
className="-mt-4 h-8 w-[60px]"
|
className="-mt-4 h-8 w-[60px]"
|
||||||
min={min}
|
min={min}
|
||||||
max={max}
|
max={max}
|
||||||
value={String(value)}
|
value={val}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
textAlign="right"
|
textAlign="right"
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
if (Number(e.target.value) > Number(max)) {
|
if (Number(e.target.value) > Number(max)) {
|
||||||
onValueChanged?.(Number(max))
|
onValueChanged?.(Number(max))
|
||||||
|
setVal(max.toString())
|
||||||
setShowTooltip({ max: true, min: false })
|
setShowTooltip({ max: true, min: false })
|
||||||
} else if (Number(e.target.value) < Number(min)) {
|
} else if (Number(e.target.value) < Number(min)) {
|
||||||
onValueChanged?.(Number(min))
|
onValueChanged?.(Number(min))
|
||||||
|
setVal(min.toString())
|
||||||
setShowTooltip({ max: false, min: true })
|
setShowTooltip({ max: false, min: true })
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
onValueChanged?.(Number(e.target.value))
|
onValueChanged?.(e.target.value)
|
||||||
|
if (/^\d*\.?\d*$/.test(e.target.value)) {
|
||||||
|
setVal(e.target.value)
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { HuggingFaceRepoData } from '@janhq/core/.'
|
import { HuggingFaceRepoData } from '@janhq/core'
|
||||||
import { atom } from 'jotai'
|
import { atom } from 'jotai'
|
||||||
|
|
||||||
// modals
|
// modals
|
||||||
|
|||||||
@ -1,32 +1,317 @@
|
|||||||
import { memo } from 'react'
|
import React, { Fragment, useState } from 'react'
|
||||||
|
|
||||||
import { Button } from '@janhq/joi'
|
import Image from 'next/image'
|
||||||
import { useSetAtom } from 'jotai'
|
|
||||||
|
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 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 { 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 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 (
|
return (
|
||||||
<div className="mx-auto flex h-full w-3/4 flex-col items-center justify-center text-center">
|
localEngines.includes(model.engine) &&
|
||||||
<LogoMark className="mx-auto mb-4 animate-wave" width={56} height={56} />
|
model.name.toLowerCase().includes(searchValue.toLowerCase())
|
||||||
<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>
|
const remoteModelEngine = remoteModel.map((x) => x.engine)
|
||||||
<Button
|
const groupByEngine = remoteModelEngine.filter(function (item, index) {
|
||||||
className="mt-4"
|
if (remoteModelEngine.indexOf(item) === index) return item
|
||||||
onClick={() => setMainViewState(MainViewState.Hub)}
|
})
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<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'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
Explore The Hub
|
{!filteredModels.length ? (
|
||||||
</Button>
|
<div className="p-3 text-center">
|
||||||
|
<p className="line-clamp-1 text-[hsla(var(--text-secondary))]">
|
||||||
|
No Result Found
|
||||||
|
</p>
|
||||||
</div>
|
</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
|
||||||
|
|||||||
@ -11,18 +11,15 @@ import ChatItem from '../ChatItem'
|
|||||||
|
|
||||||
import LoadModelError from '../LoadModelError'
|
import LoadModelError from '../LoadModelError'
|
||||||
|
|
||||||
import EmptyModel from './EmptyModel'
|
|
||||||
import EmptyThread from './EmptyThread'
|
import EmptyThread from './EmptyThread'
|
||||||
|
|
||||||
import { getCurrentChatMessagesAtom } from '@/helpers/atoms/ChatMessage.atom'
|
import { getCurrentChatMessagesAtom } from '@/helpers/atoms/ChatMessage.atom'
|
||||||
import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom'
|
|
||||||
|
|
||||||
const ChatBody = () => {
|
const ChatBody = () => {
|
||||||
const messages = useAtomValue(getCurrentChatMessagesAtom)
|
const messages = useAtomValue(getCurrentChatMessagesAtom)
|
||||||
const downloadedModels = useAtomValue(downloadedModelsAtom)
|
|
||||||
const loadModelError = useAtomValue(loadModelErrorAtom)
|
const loadModelError = useAtomValue(loadModelErrorAtom)
|
||||||
|
|
||||||
if (!downloadedModels.length) return <EmptyModel />
|
|
||||||
if (!messages.length) return <EmptyThread />
|
if (!messages.length) return <EmptyThread />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { useEffect, useRef, useState } from 'react'
|
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 { TextArea, Button, Tooltip, useClickOutside, Badge } from '@janhq/joi'
|
||||||
import { useAtom, useAtomValue } from 'jotai'
|
import { useAtom, useAtomValue } from 'jotai'
|
||||||
@ -24,12 +24,15 @@ import { useActiveModel } from '@/hooks/useActiveModel'
|
|||||||
|
|
||||||
import useSendChatMessage from '@/hooks/useSendChatMessage'
|
import useSendChatMessage from '@/hooks/useSendChatMessage'
|
||||||
|
|
||||||
|
import { localEngines } from '@/utils/modelEngine'
|
||||||
|
|
||||||
import FileUploadPreview from '../FileUploadPreview'
|
import FileUploadPreview from '../FileUploadPreview'
|
||||||
import ImageUploadPreview from '../ImageUploadPreview'
|
import ImageUploadPreview from '../ImageUploadPreview'
|
||||||
|
|
||||||
import { showRightPanelAtom } from '@/helpers/atoms/App.atom'
|
import { showRightPanelAtom } from '@/helpers/atoms/App.atom'
|
||||||
import { experimentalFeatureEnabledAtom } from '@/helpers/atoms/AppConfig.atom'
|
import { experimentalFeatureEnabledAtom } from '@/helpers/atoms/AppConfig.atom'
|
||||||
import { getCurrentChatMessagesAtom } from '@/helpers/atoms/ChatMessage.atom'
|
import { getCurrentChatMessagesAtom } from '@/helpers/atoms/ChatMessage.atom'
|
||||||
|
import { selectedModelAtom } from '@/helpers/atoms/Model.atom'
|
||||||
import { spellCheckAtom } from '@/helpers/atoms/Setting.atom'
|
import { spellCheckAtom } from '@/helpers/atoms/Setting.atom'
|
||||||
import {
|
import {
|
||||||
activeSettingInputBoxAtom,
|
activeSettingInputBoxAtom,
|
||||||
@ -53,6 +56,7 @@ const ChatInput = () => {
|
|||||||
activeSettingInputBoxAtom
|
activeSettingInputBoxAtom
|
||||||
)
|
)
|
||||||
const { sendChatMessage } = useSendChatMessage()
|
const { sendChatMessage } = useSendChatMessage()
|
||||||
|
const selectedModel = useAtomValue(selectedModelAtom)
|
||||||
|
|
||||||
const activeThreadId = useAtomValue(getActiveThreadIdAtom)
|
const activeThreadId = useAtomValue(getActiveThreadIdAtom)
|
||||||
const [isWaitingToSend, setIsWaitingToSend] = useAtom(waitingToSendMessage)
|
const [isWaitingToSend, setIsWaitingToSend] = useAtom(waitingToSendMessage)
|
||||||
@ -124,6 +128,10 @@ const ChatInput = () => {
|
|||||||
stopInference()
|
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.
|
* 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.
|
* Its to be used to display the extension file name of the selected file.
|
||||||
@ -198,6 +206,7 @@ const ChatInput = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
disabled={
|
disabled={
|
||||||
|
isModelSupportRagAndTools &&
|
||||||
activeThread?.assistants[0].tools &&
|
activeThread?.assistants[0].tools &&
|
||||||
activeThread?.assistants[0].tools[0]?.enabled
|
activeThread?.assistants[0].tools[0]?.enabled
|
||||||
}
|
}
|
||||||
@ -217,12 +226,16 @@ const ChatInput = () => {
|
|||||||
)}
|
)}
|
||||||
{activeThread?.assistants[0].tools &&
|
{activeThread?.assistants[0].tools &&
|
||||||
activeThread?.assistants[0].tools[0]?.enabled ===
|
activeThread?.assistants[0].tools[0]?.enabled ===
|
||||||
false && (
|
false &&
|
||||||
|
isModelSupportRagAndTools && (
|
||||||
<span>
|
<span>
|
||||||
Turn on Retrieval in Assistant Settings to use
|
Turn on Retrieval in Tools settings to use this
|
||||||
this feature.
|
feature
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{!isModelSupportRagAndTools && (
|
||||||
|
<span>Not supported for this model</span>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
import { memo, useCallback, useMemo } from 'react'
|
import { memo, useCallback, useMemo } from 'react'
|
||||||
|
|
||||||
import { SettingComponentProps, SliderComponentProps } from '@janhq/core/.'
|
import {
|
||||||
|
InferenceEngine,
|
||||||
|
SettingComponentProps,
|
||||||
|
SliderComponentProps,
|
||||||
|
} from '@janhq/core'
|
||||||
import {
|
import {
|
||||||
Tabs,
|
Tabs,
|
||||||
TabsContent,
|
TabsContent,
|
||||||
@ -24,6 +28,7 @@ import { useCreateNewThread } from '@/hooks/useCreateNewThread'
|
|||||||
import useUpdateModelParameters from '@/hooks/useUpdateModelParameters'
|
import useUpdateModelParameters from '@/hooks/useUpdateModelParameters'
|
||||||
|
|
||||||
import { getConfigurationsData } from '@/utils/componentSettings'
|
import { getConfigurationsData } from '@/utils/componentSettings'
|
||||||
|
import { localEngines } from '@/utils/modelEngine'
|
||||||
import { toRuntimeParams, toSettingParams } from '@/utils/modelParam'
|
import { toRuntimeParams, toSettingParams } from '@/utils/modelParam'
|
||||||
|
|
||||||
import PromptTemplateSetting from './PromptTemplateSetting'
|
import PromptTemplateSetting from './PromptTemplateSetting'
|
||||||
@ -39,6 +44,10 @@ import {
|
|||||||
|
|
||||||
import { activeTabThreadRightPanelAtom } from '@/helpers/atoms/ThreadRightPanel.atom'
|
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 ThreadRightPanel = () => {
|
||||||
const activeThread = useAtomValue(activeThreadAtom)
|
const activeThread = useAtomValue(activeThreadAtom)
|
||||||
const activeModelParams = useAtomValue(getActiveThreadModelParamsAtom)
|
const activeModelParams = useAtomValue(getActiveThreadModelParamsAtom)
|
||||||
@ -49,6 +58,10 @@ const ThreadRightPanel = () => {
|
|||||||
const { updateThreadMetadata } = useCreateNewThread()
|
const { updateThreadMetadata } = useCreateNewThread()
|
||||||
const experimentalFeature = useAtomValue(experimentalFeatureEnabledAtom)
|
const experimentalFeature = useAtomValue(experimentalFeatureEnabledAtom)
|
||||||
|
|
||||||
|
const isModelSupportRagAndTools =
|
||||||
|
selectedModel?.engine === InferenceEngine.openai ||
|
||||||
|
localEngines.includes(selectedModel?.engine as InferenceEngine)
|
||||||
|
|
||||||
const setEngineParamsUpdate = useSetAtom(engineParamsUpdateAtom)
|
const setEngineParamsUpdate = useSetAtom(engineParamsUpdateAtom)
|
||||||
const { stopModel } = useActiveModel()
|
const { stopModel } = useActiveModel()
|
||||||
const { updateModelParameter } = useUpdateModelParameters()
|
const { updateModelParameter } = useUpdateModelParameters()
|
||||||
@ -189,7 +202,16 @@ const ThreadRightPanel = () => {
|
|||||||
options={[
|
options={[
|
||||||
{ name: 'Assistant', value: 'assistant' },
|
{ name: 'Assistant', value: 'assistant' },
|
||||||
{ name: 'Model', value: 'model' },
|
{ 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}
|
value={activeTabThreadRightPanel as string}
|
||||||
onValueChange={(value) => setActiveTabThreadRightPanel(value)}
|
onValueChange={(value) => setActiveTabThreadRightPanel(value)}
|
||||||
@ -221,8 +243,8 @@ const ThreadRightPanel = () => {
|
|||||||
<Accordion defaultValue={[]}>
|
<Accordion defaultValue={[]}>
|
||||||
{settings.runtimeSettings.length !== 0 && (
|
{settings.runtimeSettings.length !== 0 && (
|
||||||
<AccordionItem
|
<AccordionItem
|
||||||
title="Inference Parameters"
|
title={INFERENCE_SETTINGS}
|
||||||
value="Inference Parameters"
|
value={INFERENCE_SETTINGS}
|
||||||
>
|
>
|
||||||
<ModelSetting
|
<ModelSetting
|
||||||
componentProps={settings.runtimeSettings}
|
componentProps={settings.runtimeSettings}
|
||||||
@ -232,16 +254,13 @@ const ThreadRightPanel = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{promptTemplateSettings.length !== 0 && (
|
{promptTemplateSettings.length !== 0 && (
|
||||||
<AccordionItem title="Model Parameters" value="Model Parameters">
|
<AccordionItem title={MODEL_SETTINGS} value={MODEL_SETTINGS}>
|
||||||
<PromptTemplateSetting componentData={promptTemplateSettings} />
|
<PromptTemplateSetting componentData={promptTemplateSettings} />
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{settings.engineSettings.length !== 0 && (
|
{settings.engineSettings.length !== 0 && (
|
||||||
<AccordionItem
|
<AccordionItem title={ENGINE_SETTINGS} value={ENGINE_SETTINGS}>
|
||||||
title="Engine Parameters"
|
|
||||||
value="Engine Parameters"
|
|
||||||
>
|
|
||||||
<EngineSetting
|
<EngineSetting
|
||||||
componentData={settings.engineSettings}
|
componentData={settings.engineSettings}
|
||||||
onValueChanged={onValueChanged}
|
onValueChanged={onValueChanged}
|
||||||
|
|||||||
@ -1,17 +1,92 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import { useAtomValue } from 'jotai'
|
||||||
|
|
||||||
import ThreadLeftPanel from '@/screens/Thread/ThreadLeftPanel'
|
import ThreadLeftPanel from '@/screens/Thread/ThreadLeftPanel'
|
||||||
|
|
||||||
|
import { localEngines } from '@/utils/modelEngine'
|
||||||
|
|
||||||
import ThreadCenterPanel from './ThreadCenterPanel'
|
import ThreadCenterPanel from './ThreadCenterPanel'
|
||||||
|
import OnDeviceStarterScreen from './ThreadCenterPanel/ChatBody/EmptyModel'
|
||||||
import ModalCleanThread from './ThreadLeftPanel/ModalCleanThread'
|
import ModalCleanThread from './ThreadLeftPanel/ModalCleanThread'
|
||||||
import ModalDeleteThread from './ThreadLeftPanel/ModalDeleteThread'
|
import ModalDeleteThread from './ThreadLeftPanel/ModalDeleteThread'
|
||||||
import ModalEditTitleThread from './ThreadLeftPanel/ModalEditTitleThread'
|
import ModalEditTitleThread from './ThreadLeftPanel/ModalEditTitleThread'
|
||||||
import ThreadRightPanel from './ThreadRightPanel'
|
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 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 (
|
return (
|
||||||
<div className="relative flex h-full w-full flex-1 overflow-x-hidden">
|
<div className="relative flex h-full w-full flex-1 overflow-x-hidden">
|
||||||
|
{!isAnyRemoteModelConfigured &&
|
||||||
|
!isDownloadALocalModel &&
|
||||||
|
!threads.length ? (
|
||||||
|
<>
|
||||||
|
<OnDeviceStarterScreen extensionHasSettings={extensionHasSettings} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<ThreadLeftPanel />
|
<ThreadLeftPanel />
|
||||||
<ThreadCenterPanel />
|
<ThreadCenterPanel />
|
||||||
<ThreadRightPanel />
|
<ThreadRightPanel />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Showing variant modal action for thread screen */}
|
{/* Showing variant modal action for thread screen */}
|
||||||
<ModalEditTitleThread />
|
<ModalEditTitleThread />
|
||||||
|
|||||||
35
web/utils/Stack.test.ts
Normal file
35
web/utils/Stack.test.ts
Normal 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);
|
||||||
|
});
|
||||||
22
web/utils/componentSettings.test.ts
Normal file
22
web/utils/componentSettings.test.ts
Normal 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([]);
|
||||||
|
});
|
||||||
27
web/utils/datetime.test.ts
Normal file
27
web/utils/datetime.test.ts
Normal 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);
|
||||||
|
});
|
||||||
17
web/utils/jsonToCssVariables.test.ts
Normal file
17
web/utils/jsonToCssVariables.test.ts
Normal 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);
|
||||||
|
});
|
||||||
@ -1,5 +1,3 @@
|
|||||||
// @auto-generated
|
|
||||||
|
|
||||||
import { utilizedMemory } from './memory'
|
import { utilizedMemory } from './memory'
|
||||||
|
|
||||||
test('test_utilizedMemory_arbitraryValues', () => {
|
test('test_utilizedMemory_arbitraryValues', () => {
|
||||||
|
|||||||
18
web/utils/predefinedComponent.test.ts
Normal file
18
web/utils/predefinedComponent.test.ts
Normal 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',
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { SettingComponentProps } from '@janhq/core/.'
|
import { SettingComponentProps } from '@janhq/core'
|
||||||
|
|
||||||
export const presetConfiguration: Record<string, SettingComponentProps> = {
|
export const presetConfiguration: Record<string, SettingComponentProps> = {
|
||||||
prompt_template: {
|
prompt_template: {
|
||||||
|
|||||||
9
web/utils/thread.test.ts
Normal file
9
web/utils/thread.test.ts
Normal 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);
|
||||||
|
});
|
||||||
25
web/utils/titleUtils.test.ts
Normal file
25
web/utils/titleUtils.test.ts
Normal 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');
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user