Merge pull request #3664 from janhq/dev
release: Jan Release cut v0.5.4
This commit is contained in:
commit
6e0c582f1e
37
.github/ISSUE_TEMPLATE/bug_report.md
vendored
37
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -1,37 +0,0 @@
|
||||
---
|
||||
name: "🖋️ Report"
|
||||
about: Create a report to help us improve Jan
|
||||
title: 'bug: [DESCRIPTION]'
|
||||
labels: 'type: bug'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**Steps to reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your issue.
|
||||
|
||||
**Environment details**
|
||||
- Operating System: [Specify your OS. e.g., MacOS Sonoma 14.2.1, Windows 11, Ubuntu 22, etc]
|
||||
- Jan Version: [e.g., 0.4.xxx nightly or manual]
|
||||
- Processor: [e.g., Apple M1, Intel Core i7, AMD Ryzen 5, etc]
|
||||
- RAM: [e.g., 8GB, 16GB]
|
||||
- Any additional relevant hardware specifics: [e.g., Graphics card, SSD/HDD]
|
||||
|
||||
**Logs**
|
||||
If the cause of the error is not clear, kindly provide your usage logs: https://jan.ai/docs/troubleshooting#how-to-get-error-logs
|
||||
|
||||
**Additional context**
|
||||
Add any other context or information that could be helpful in diagnosing the problem.
|
||||
91
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
91
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@ -4,79 +4,40 @@ labels: [ "type: bug" ]
|
||||
title: 'bug: [DESCRIPTION]'
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "Thanks for taking the time to fill out this bug report!"
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: "#"
|
||||
description: "Please search [here](./?q=is%3Aissue) to see if an issue already exists for the bug you encountered"
|
||||
options:
|
||||
- label: "I have searched the existing issues"
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "Current behavior"
|
||||
description: "A clear and concise description of what the bug is"
|
||||
|
||||
- type: textarea
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "Minimum reproduction step"
|
||||
description: |
|
||||
Please list out steps to reproduce the behavior
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
- type: textarea
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "Expected behavior"
|
||||
description: "A clear and concise description of what you expected to happen"
|
||||
|
||||
- type: textarea
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "Screenshots / Logs"
|
||||
description: |
|
||||
Kindly provide your screenshots / [usage logs](https://jan.ai/docs/troubleshooting#how-to-get-error-logs) that could be helpful in diagnosing the problem
|
||||
**Tip:** You can attach images, recordings or log files by clicking this area to highlight it and then dragging files in
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
---
|
||||
|
||||
- type: input
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "Jan version"
|
||||
description: "**Tip:** The version is located in the lower right conner of the Jan app"
|
||||
placeholder: "e.g. 0.5.x-xxx nightly or stable"
|
||||
description: "**Tip:** The version is in the app's bottom right corner"
|
||||
placeholder: "e.g. 0.5.x-xxx"
|
||||
|
||||
- type: checkboxes
|
||||
- type: textarea
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "In which operating systems have you tested?"
|
||||
options:
|
||||
- label: macOS
|
||||
- label: Windows
|
||||
- label: Linux
|
||||
label: "Describe the Bug"
|
||||
description: "A clear & concise description of the bug"
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "Environment details"
|
||||
label: "Steps to Reproduce"
|
||||
description: |
|
||||
- Operating System: [Specify your OS details: e.g., MacOS Sonoma 14.2.1, Windows 11, Ubuntu 22, etc]
|
||||
- Processor: [e.g., Apple M1, Intel Core i7, AMD Ryzen 5, etc]
|
||||
- RAM: [e.g., 8GB, 16GB]
|
||||
- Any additional relevant hardware specifics: [e.g., Graphics card, SSD/HDD]
|
||||
Please list out steps to reproduce the issue
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "Screenshots / Logs"
|
||||
description: |
|
||||
You can find logs in: ~/jan/logs/app.logs
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: "What is your OS?"
|
||||
options:
|
||||
- label: MacOS
|
||||
- label: Windows
|
||||
- label: Linux
|
||||
|
||||
4
.github/ISSUE_TEMPLATE/config.yml
vendored
4
.github/ISSUE_TEMPLATE/config.yml
vendored
@ -2,6 +2,6 @@
|
||||
blank_issues_enabled: true
|
||||
|
||||
contact_links:
|
||||
- name: "\u2753 Our GitHub Discussions page"
|
||||
- name: "\1F4AC Jan Discussions"
|
||||
url: "https://github.com/orgs/janhq/discussions/categories/q-a"
|
||||
about: "Please ask and answer questions here!"
|
||||
about: "Get help, discuss features & roadmap, and share your projects"
|
||||
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
|
||||
34
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
34
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@ -1,26 +1,14 @@
|
||||
name: "\U0001F680 Feature Request"
|
||||
description: "Suggest an idea for this project \U0001F63B!"
|
||||
title: 'feat: [DESCRIPTION]'
|
||||
title: 'idea: [DESCRIPTION]'
|
||||
labels: 'type: feature request'
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "Thanks for taking the time to fill out this form!"
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: "#"
|
||||
description: "Please search [here](./?q=is%3Aissue) to see if an issue already exists for the feature you are requesting"
|
||||
options:
|
||||
- label: "I have searched the existing issues"
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "Is your feature request related to a problem? Please describe it"
|
||||
description: "A clear and concise description of what the problem is"
|
||||
label: "Problem Statement"
|
||||
description: "Describe the problem you're facing"
|
||||
placeholder: |
|
||||
I'm always frustrated when ...
|
||||
|
||||
@ -28,17 +16,5 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "Describe the solution"
|
||||
description: "Description of what you want to happen. Add any considered drawbacks"
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "Teachability, documentation, adoption, migration strategy"
|
||||
description: "Explain how users will be able to use this and possibly write out something for the docs. Maybe a screenshot or design?"
|
||||
|
||||
- type: textarea
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "What is the motivation / use case for changing the behavior?"
|
||||
description: "Describe the motivation or the concrete use case"
|
||||
label: "Feature Idea"
|
||||
description: "Describe what you want instead. Examples are welcome!"
|
||||
21
.github/ISSUE_TEMPLATE/model_request.yml
vendored
Normal file
21
.github/ISSUE_TEMPLATE/model_request.yml
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
name: "\U0001F929 Model Request"
|
||||
description: "Request a new model to be compiled"
|
||||
title: 'feat: [DESCRIPTION]'
|
||||
labels: 'type: model request'
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "**Tip:** Download any HuggingFace model in app ([see guides](https://jan.ai/docs/models/manage-models#add-models)). Use this form for unsupported models only."
|
||||
- type: textarea
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "Model Requests"
|
||||
description: "If applicable, include the source URL, licenses, and any other relevant information"
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: "Which formats?"
|
||||
options:
|
||||
- label: GGUF (llama.cpp)
|
||||
- label: TensorRT (TensorRT-LLM)
|
||||
- label: ONNX (Onnx Runtime)
|
||||
@ -88,12 +88,12 @@ jobs:
|
||||
with:
|
||||
ref: ${{ needs.set-public-provider.outputs.ref }}
|
||||
- name: Download mac-x64 artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: latest-mac-x64
|
||||
path: ./latest-mac-x64
|
||||
- name: Download mac-arm artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: latest-mac-arm64
|
||||
path: ./latest-mac-arm64
|
||||
|
||||
4
.github/workflows/jan-electron-build.yml
vendored
4
.github/workflows/jan-electron-build.yml
vendored
@ -79,12 +79,12 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Download mac-x64 artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: latest-mac-x64
|
||||
path: ./latest-mac-x64
|
||||
- name: Download mac-arm artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: latest-mac-arm64
|
||||
path: ./latest-mac-arm64
|
||||
|
||||
@ -37,6 +37,33 @@ on:
|
||||
- '!README.md'
|
||||
|
||||
jobs:
|
||||
base_branch_cov:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
ref: ${{ github.base_ref }}
|
||||
- name: Use Node.js v20.9.0
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: v20.9.0
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
yarn
|
||||
yarn build:core
|
||||
yarn build:joi
|
||||
|
||||
- name: Run test coverage
|
||||
run: yarn test:coverage
|
||||
|
||||
- name: Upload code coverage for ref branch
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ref-lcov.info
|
||||
path: ./coverage/lcov.info
|
||||
|
||||
test-on-macos:
|
||||
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: [self-hosted, macOS, macos-desktop]
|
||||
@ -292,6 +319,56 @@ jobs:
|
||||
TURBO_TEAM: 'linux'
|
||||
TURBO_TOKEN: '${{ secrets.TURBO_TOKEN }}'
|
||||
|
||||
coverage-check:
|
||||
runs-on: [self-hosted, Linux, ubuntu-desktop]
|
||||
needs: base_branch_cov
|
||||
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
steps:
|
||||
- name: Getting the repo
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Installing node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: 'Cleanup cache'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
rm -rf ~/jan
|
||||
make clean
|
||||
|
||||
- name: Download code coverage report from base branch
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ref-lcov.info
|
||||
|
||||
- name: Linter and test coverage
|
||||
run: |
|
||||
export DISPLAY=$(w -h | awk 'NR==1 {print $2}')
|
||||
echo -e "Display ID: $DISPLAY"
|
||||
npm config set registry ${{ secrets.NPM_PROXY }} --global
|
||||
yarn config set registry ${{ secrets.NPM_PROXY }} --global
|
||||
make lint
|
||||
yarn build:test
|
||||
yarn test:coverage
|
||||
env:
|
||||
TURBO_API: '${{ secrets.TURBO_API }}'
|
||||
TURBO_TEAM: 'linux'
|
||||
TURBO_TOKEN: '${{ secrets.TURBO_TOKEN }}'
|
||||
|
||||
- name: Generate Code Coverage report
|
||||
id: code-coverage
|
||||
uses: barecheck/code-coverage-action@v1
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
lcov-file: './coverage/lcov.info'
|
||||
base-lcov-file: './lcov.info'
|
||||
send-summary-comment: true
|
||||
show-annotations: 'warning'
|
||||
|
||||
test-on-ubuntu-pr-target:
|
||||
runs-on: [self-hosted, Linux, ubuntu-desktop]
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository
|
||||
|
||||
@ -51,13 +51,13 @@ jobs:
|
||||
latest_prerelease_asset_count=$(get_asset_count "$latest_prerelease_name")
|
||||
|
||||
if [ "$current_version_name" = "$latest_prerelease_name" ]; then
|
||||
echo "cortex cpp remote repo doesn't have update today, skip update cortex-cpp for today nightly build"
|
||||
echo "cortex cpp remote repo doesn't have update today, skip update cortex.cpp for today nightly build"
|
||||
echo "::set-output name=pr_created::false"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$current_version_asset_count" != "$latest_prerelease_asset_count" ]; then
|
||||
echo "Latest prerelease version has different number of assets, somethink went wrong, skip update cortex-cpp for today nightly build"
|
||||
echo "Latest prerelease version has different number of assets, somethink went wrong, skip update cortex.cpp for today nightly build"
|
||||
echo "::set-output name=pr_created::false"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@ -102,14 +102,14 @@ jobs:
|
||||
|
||||
- name: Upload Artifact .deb file
|
||||
if: inputs.public_provider != 'github'
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: jan-linux-amd64-${{ inputs.new_version }}-deb
|
||||
path: ./electron/dist/*.deb
|
||||
|
||||
- name: Upload Artifact .AppImage file
|
||||
if: inputs.public_provider != 'github'
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: jan-linux-amd64-${{ inputs.new_version }}-AppImage
|
||||
path: ./electron/dist/*.AppImage
|
||||
|
||||
@ -148,13 +148,13 @@ jobs:
|
||||
|
||||
- name: Upload Artifact
|
||||
if: inputs.public_provider != 'github'
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: jan-mac-arm64-${{ inputs.new_version }}
|
||||
path: ./electron/dist/jan-mac-arm64-${{ inputs.new_version }}.dmg
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: latest-mac-arm64
|
||||
path: ./electron/dist/latest-mac.yml
|
||||
@ -148,13 +148,13 @@ jobs:
|
||||
|
||||
- name: Upload Artifact
|
||||
if: inputs.public_provider != 'github'
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: jan-mac-x64-${{ inputs.new_version }}
|
||||
path: ./electron/dist/jan-mac-x64-${{ inputs.new_version }}.dmg
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: latest-mac-x64
|
||||
path: ./electron/dist/latest-mac.yml
|
||||
@ -136,7 +136,7 @@ jobs:
|
||||
|
||||
- name: Upload Artifact
|
||||
if: inputs.public_provider != 'github'
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: jan-win-x64-${{ inputs.new_version }}
|
||||
path: ./electron/dist/*.exe
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -20,7 +20,7 @@ electron/themes
|
||||
electron/playwright-report
|
||||
server/pre-install
|
||||
package-lock.json
|
||||
|
||||
coverage
|
||||
*.log
|
||||
core/lib/**
|
||||
|
||||
@ -41,3 +41,7 @@ extensions/*-extension/bin/vulkaninfo
|
||||
.turbo
|
||||
electron/test-data
|
||||
electron/test-results
|
||||
core/test_results.html
|
||||
coverage
|
||||
.yarn
|
||||
.yarnrc
|
||||
|
||||
2
Makefile
2
Makefile
@ -104,7 +104,7 @@ endif
|
||||
# Testing
|
||||
test: lint
|
||||
yarn build:test
|
||||
yarn test:unit
|
||||
yarn test:coverage
|
||||
yarn test
|
||||
|
||||
# Builds and publishes the app
|
||||
|
||||
@ -4,4 +4,5 @@ module.exports = {
|
||||
moduleNameMapper: {
|
||||
'@/(.*)': '<rootDir>/src/$1',
|
||||
},
|
||||
runner: './testRunner.js',
|
||||
}
|
||||
|
||||
@ -46,6 +46,8 @@
|
||||
"eslint": "8.57.0",
|
||||
"eslint-plugin-jest": "^27.9.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-junit": "^16.0.0",
|
||||
"jest-runner": "^29.7.0",
|
||||
"rimraf": "^3.0.2",
|
||||
"rollup": "^2.38.5",
|
||||
"rollup-plugin-commonjs": "^9.1.8",
|
||||
@ -53,7 +55,7 @@
|
||||
"rollup-plugin-node-resolve": "^5.2.0",
|
||||
"rollup-plugin-sourcemaps": "^0.6.3",
|
||||
"rollup-plugin-typescript2": "^0.36.0",
|
||||
"ts-jest": "^29.1.2",
|
||||
"ts-jest": "^29.2.5",
|
||||
"tslib": "^2.6.2",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@ -1,31 +1,13 @@
|
||||
import fs from 'fs'
|
||||
import { join } from 'path'
|
||||
import {
|
||||
getJanDataFolderPath,
|
||||
getJanExtensionsPath,
|
||||
getSystemResourceInfo,
|
||||
log,
|
||||
} from '../../../helper'
|
||||
import { ChildProcessWithoutNullStreams, spawn } from 'child_process'
|
||||
import { Model, ModelSettingParams, PromptTemplate } from '../../../../types'
|
||||
import {
|
||||
LOCAL_HOST,
|
||||
NITRO_DEFAULT_PORT,
|
||||
NITRO_HTTP_KILL_URL,
|
||||
NITRO_HTTP_LOAD_MODEL_URL,
|
||||
NITRO_HTTP_VALIDATE_MODEL_URL,
|
||||
SUPPORTED_MODEL_FORMAT,
|
||||
} from './consts'
|
||||
|
||||
// The subprocess instance for Nitro
|
||||
let subprocess: ChildProcessWithoutNullStreams | undefined = undefined
|
||||
|
||||
// TODO: move this to core type
|
||||
interface NitroModelSettings extends ModelSettingParams {
|
||||
llama_model_path: string
|
||||
cpu_threads: number
|
||||
}
|
||||
import { getJanDataFolderPath, getJanExtensionsPath, log } from '../../../helper'
|
||||
import { ModelSettingParams } from '../../../../types'
|
||||
|
||||
/**
|
||||
* Start a model
|
||||
* @param modelId
|
||||
* @param settingParams
|
||||
* @returns
|
||||
*/
|
||||
export const startModel = async (modelId: string, settingParams?: ModelSettingParams) => {
|
||||
try {
|
||||
await runModel(modelId, settingParams)
|
||||
@ -40,316 +22,57 @@ export const startModel = async (modelId: string, settingParams?: ModelSettingPa
|
||||
}
|
||||
}
|
||||
|
||||
const runModel = async (modelId: string, settingParams?: ModelSettingParams): Promise<void> => {
|
||||
const janDataFolderPath = getJanDataFolderPath()
|
||||
const modelFolderFullPath = join(janDataFolderPath, 'models', modelId)
|
||||
|
||||
if (!fs.existsSync(modelFolderFullPath)) {
|
||||
throw new Error(`Model not found: ${modelId}`)
|
||||
}
|
||||
|
||||
const files: string[] = fs.readdirSync(modelFolderFullPath)
|
||||
|
||||
// Look for GGUF model file
|
||||
const ggufBinFile = files.find((file) => file.toLowerCase().includes(SUPPORTED_MODEL_FORMAT))
|
||||
|
||||
const modelMetadataPath = join(modelFolderFullPath, 'model.json')
|
||||
const modelMetadata: Model = JSON.parse(fs.readFileSync(modelMetadataPath, 'utf-8'))
|
||||
|
||||
if (!ggufBinFile) {
|
||||
throw new Error('No GGUF model file found')
|
||||
}
|
||||
const modelBinaryPath = join(modelFolderFullPath, ggufBinFile)
|
||||
|
||||
const nitroResourceProbe = await getSystemResourceInfo()
|
||||
const nitroModelSettings: NitroModelSettings = {
|
||||
// This is critical and requires real CPU physical core count (or performance core)
|
||||
cpu_threads: Math.max(1, nitroResourceProbe.numCpuPhysicalCore),
|
||||
...modelMetadata.settings,
|
||||
...settingParams,
|
||||
llama_model_path: modelBinaryPath,
|
||||
...(modelMetadata.settings.mmproj && {
|
||||
mmproj: join(modelFolderFullPath, modelMetadata.settings.mmproj),
|
||||
}),
|
||||
}
|
||||
|
||||
log(`[SERVER]::Debug: Nitro model settings: ${JSON.stringify(nitroModelSettings)}`)
|
||||
|
||||
// Convert settings.prompt_template to system_prompt, user_prompt, ai_prompt
|
||||
if (modelMetadata.settings.prompt_template) {
|
||||
const promptTemplate = modelMetadata.settings.prompt_template
|
||||
const prompt = promptTemplateConverter(promptTemplate)
|
||||
if (prompt?.error) {
|
||||
throw new Error(prompt.error)
|
||||
}
|
||||
nitroModelSettings.system_prompt = prompt.system_prompt
|
||||
nitroModelSettings.user_prompt = prompt.user_prompt
|
||||
nitroModelSettings.ai_prompt = prompt.ai_prompt
|
||||
}
|
||||
|
||||
await runNitroAndLoadModel(modelId, nitroModelSettings)
|
||||
}
|
||||
|
||||
// TODO: move to util
|
||||
const promptTemplateConverter = (promptTemplate: string): PromptTemplate => {
|
||||
// Split the string using the markers
|
||||
const systemMarker = '{system_message}'
|
||||
const promptMarker = '{prompt}'
|
||||
|
||||
if (promptTemplate.includes(systemMarker) && promptTemplate.includes(promptMarker)) {
|
||||
// Find the indices of the markers
|
||||
const systemIndex = promptTemplate.indexOf(systemMarker)
|
||||
const promptIndex = promptTemplate.indexOf(promptMarker)
|
||||
|
||||
// Extract the parts of the string
|
||||
const system_prompt = promptTemplate.substring(0, systemIndex)
|
||||
const user_prompt = promptTemplate.substring(systemIndex + systemMarker.length, promptIndex)
|
||||
const ai_prompt = promptTemplate.substring(promptIndex + promptMarker.length)
|
||||
|
||||
// Return the split parts
|
||||
return { system_prompt, user_prompt, ai_prompt }
|
||||
} else if (promptTemplate.includes(promptMarker)) {
|
||||
// Extract the parts of the string for the case where only promptMarker is present
|
||||
const promptIndex = promptTemplate.indexOf(promptMarker)
|
||||
const user_prompt = promptTemplate.substring(0, promptIndex)
|
||||
const ai_prompt = promptTemplate.substring(promptIndex + promptMarker.length)
|
||||
|
||||
// Return the split parts
|
||||
return { user_prompt, ai_prompt }
|
||||
}
|
||||
|
||||
// Return an error if none of the conditions are met
|
||||
return { error: 'Cannot split prompt template' }
|
||||
}
|
||||
|
||||
const runNitroAndLoadModel = async (modelId: string, modelSettings: NitroModelSettings) => {
|
||||
// Gather system information for CPU physical cores and memory
|
||||
const tcpPortUsed = require('tcp-port-used')
|
||||
|
||||
await stopModel(modelId)
|
||||
await tcpPortUsed.waitUntilFree(NITRO_DEFAULT_PORT, 300, 5000)
|
||||
|
||||
/**
|
||||
* There is a problem with Windows process manager
|
||||
* Should wait for awhile to make sure the port is free and subprocess is killed
|
||||
* The tested threshold is 500ms
|
||||
**/
|
||||
if (process.platform === 'win32') {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
}
|
||||
|
||||
await spawnNitroProcess()
|
||||
await loadLLMModel(modelSettings)
|
||||
await validateModelStatus()
|
||||
}
|
||||
|
||||
const spawnNitroProcess = async (): Promise<void> => {
|
||||
log(`[SERVER]::Debug: Spawning cortex subprocess...`)
|
||||
|
||||
let binaryFolder = join(
|
||||
getJanExtensionsPath(),
|
||||
'@janhq',
|
||||
'inference-cortex-extension',
|
||||
'dist',
|
||||
'bin'
|
||||
)
|
||||
|
||||
let executableOptions = executableNitroFile()
|
||||
const tcpPortUsed = require('tcp-port-used')
|
||||
|
||||
const args: string[] = ['1', LOCAL_HOST, NITRO_DEFAULT_PORT.toString()]
|
||||
// Execute the binary
|
||||
log(
|
||||
`[SERVER]::Debug: Spawn cortex at path: ${executableOptions.executablePath}, and args: ${args}`
|
||||
)
|
||||
subprocess = spawn(
|
||||
executableOptions.executablePath,
|
||||
['1', LOCAL_HOST, NITRO_DEFAULT_PORT.toString()],
|
||||
{
|
||||
cwd: binaryFolder,
|
||||
env: {
|
||||
...process.env,
|
||||
CUDA_VISIBLE_DEVICES: executableOptions.cudaVisibleDevices,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Handle subprocess output
|
||||
subprocess.stdout.on('data', (data: any) => {
|
||||
log(`[SERVER]::Debug: ${data}`)
|
||||
})
|
||||
|
||||
subprocess.stderr.on('data', (data: any) => {
|
||||
log(`[SERVER]::Error: ${data}`)
|
||||
})
|
||||
|
||||
subprocess.on('close', (code: any) => {
|
||||
log(`[SERVER]::Debug: cortex exited with code: ${code}`)
|
||||
subprocess = undefined
|
||||
})
|
||||
|
||||
tcpPortUsed.waitUntilUsed(NITRO_DEFAULT_PORT, 300, 30000).then(() => {
|
||||
log(`[SERVER]::Debug: cortex is ready`)
|
||||
})
|
||||
}
|
||||
|
||||
type NitroExecutableOptions = {
|
||||
executablePath: string
|
||||
cudaVisibleDevices: string
|
||||
}
|
||||
|
||||
const executableNitroFile = (): NitroExecutableOptions => {
|
||||
const nvidiaInfoFilePath = join(getJanDataFolderPath(), 'settings', 'settings.json')
|
||||
let binaryFolder = join(
|
||||
getJanExtensionsPath(),
|
||||
'@janhq',
|
||||
'inference-cortex-extension',
|
||||
'dist',
|
||||
'bin'
|
||||
)
|
||||
|
||||
let cudaVisibleDevices = ''
|
||||
let binaryName = 'cortex-cpp'
|
||||
/**
|
||||
* The binary folder is different for each platform.
|
||||
*/
|
||||
if (process.platform === 'win32') {
|
||||
/**
|
||||
* For Windows: win-cpu, win-cuda-11-7, win-cuda-12-0
|
||||
*/
|
||||
let nvidiaInfo = JSON.parse(fs.readFileSync(nvidiaInfoFilePath, 'utf-8'))
|
||||
if (nvidiaInfo['run_mode'] === 'cpu') {
|
||||
binaryFolder = join(binaryFolder, 'win-cpu')
|
||||
} else {
|
||||
if (nvidiaInfo['cuda'].version === '12') {
|
||||
binaryFolder = join(binaryFolder, 'win-cuda-12-0')
|
||||
} else {
|
||||
binaryFolder = join(binaryFolder, 'win-cuda-11-7')
|
||||
}
|
||||
cudaVisibleDevices = nvidiaInfo['gpu_highest_vram']
|
||||
}
|
||||
binaryName = 'cortex-cpp.exe'
|
||||
} else if (process.platform === 'darwin') {
|
||||
/**
|
||||
* For MacOS: mac-universal both Silicon and InteL
|
||||
*/
|
||||
if(process.arch === 'arm64') {
|
||||
binaryFolder = join(binaryFolder, 'mac-arm64')
|
||||
} else {
|
||||
binaryFolder = join(binaryFolder, 'mac-amd64')
|
||||
}
|
||||
} else {
|
||||
/**
|
||||
* For Linux: linux-cpu, linux-cuda-11-7, linux-cuda-12-0
|
||||
*/
|
||||
let nvidiaInfo = JSON.parse(fs.readFileSync(nvidiaInfoFilePath, 'utf-8'))
|
||||
if (nvidiaInfo['run_mode'] === 'cpu') {
|
||||
binaryFolder = join(binaryFolder, 'linux-cpu')
|
||||
} else {
|
||||
if (nvidiaInfo['cuda'].version === '12') {
|
||||
binaryFolder = join(binaryFolder, 'linux-cuda-12-0')
|
||||
} else {
|
||||
binaryFolder = join(binaryFolder, 'linux-cuda-11-7')
|
||||
}
|
||||
cudaVisibleDevices = nvidiaInfo['gpu_highest_vram']
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
executablePath: join(binaryFolder, binaryName),
|
||||
cudaVisibleDevices,
|
||||
}
|
||||
}
|
||||
|
||||
const validateModelStatus = async (): Promise<void> => {
|
||||
// Send a GET request to the validation URL.
|
||||
// Retry the request up to 3 times if it fails, with a delay of 500 milliseconds between retries.
|
||||
const fetchRT = require('fetch-retry')
|
||||
const fetchRetry = fetchRT(fetch)
|
||||
|
||||
return fetchRetry(NITRO_HTTP_VALIDATE_MODEL_URL, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
retries: 5,
|
||||
retryDelay: 500,
|
||||
}).then(async (res: Response) => {
|
||||
log(`[SERVER]::Debug: Validate model state success with response ${JSON.stringify(res)}`)
|
||||
// If the response is OK, check model_loaded status.
|
||||
if (res.ok) {
|
||||
const body = await res.json()
|
||||
// If the model is loaded, return an empty object.
|
||||
// Otherwise, return an object with an error message.
|
||||
if (body.model_loaded) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
return Promise.reject('Validate model status failed')
|
||||
})
|
||||
}
|
||||
|
||||
const loadLLMModel = async (settings: NitroModelSettings): Promise<Response> => {
|
||||
log(`[SERVER]::Debug: Loading model with params ${JSON.stringify(settings)}`)
|
||||
const fetchRT = require('fetch-retry')
|
||||
const fetchRetry = fetchRT(fetch)
|
||||
|
||||
return fetchRetry(NITRO_HTTP_LOAD_MODEL_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(settings),
|
||||
retries: 3,
|
||||
retryDelay: 500,
|
||||
})
|
||||
.then((res: any) => {
|
||||
log(`[SERVER]::Debug: Load model request with response ${JSON.stringify(res)}`)
|
||||
return Promise.resolve(res)
|
||||
})
|
||||
.catch((err: any) => {
|
||||
log(`[SERVER]::Error: Load model failed with error ${err}`)
|
||||
return Promise.reject(err)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a model using installed cortex extension
|
||||
* @param model
|
||||
* @param settingParams
|
||||
*/
|
||||
const runModel = async (model: string, settingParams?: ModelSettingParams): Promise<void> => {
|
||||
const janDataFolderPath = getJanDataFolderPath()
|
||||
const modelFolder = join(janDataFolderPath, 'models', model)
|
||||
let module = join(
|
||||
getJanExtensionsPath(),
|
||||
'@janhq',
|
||||
'inference-cortex-extension',
|
||||
'dist',
|
||||
'node',
|
||||
'index.cjs'
|
||||
)
|
||||
// Just reuse the cortex extension implementation, don't duplicate then lost of sync
|
||||
return import(module).then((extension) =>
|
||||
extension
|
||||
.loadModel(
|
||||
{
|
||||
modelFolder,
|
||||
model,
|
||||
},
|
||||
settingParams
|
||||
)
|
||||
.then(() => log(`[SERVER]::Debug: Model is loaded`))
|
||||
.then({
|
||||
message: 'Model started',
|
||||
})
|
||||
)
|
||||
}
|
||||
/*
|
||||
* Stop model and kill nitro process.
|
||||
*/
|
||||
export const stopModel = async (_modelId: string) => {
|
||||
if (!subprocess) {
|
||||
return {
|
||||
error: "Model isn't running",
|
||||
}
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const controller = new AbortController()
|
||||
setTimeout(() => {
|
||||
controller.abort()
|
||||
reject({
|
||||
error: 'Failed to stop model: Timedout',
|
||||
let module = join(
|
||||
getJanExtensionsPath(),
|
||||
'@janhq',
|
||||
'inference-cortex-extension',
|
||||
'dist',
|
||||
'node',
|
||||
'index.cjs'
|
||||
)
|
||||
// Just reuse the cortex extension implementation, don't duplicate then lost of sync
|
||||
return import(module).then((extension) =>
|
||||
extension
|
||||
.unloadModel()
|
||||
.then(() => log(`[SERVER]::Debug: Model is unloaded`))
|
||||
.then({
|
||||
message: 'Model stopped',
|
||||
})
|
||||
}, 5000)
|
||||
const tcpPortUsed = require('tcp-port-used')
|
||||
log(`[SERVER]::Debug: Request to kill cortex`)
|
||||
|
||||
fetch(NITRO_HTTP_KILL_URL, {
|
||||
method: 'DELETE',
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(() => {
|
||||
subprocess?.kill()
|
||||
subprocess = undefined
|
||||
})
|
||||
.catch(() => {
|
||||
// don't need to do anything, we still kill the subprocess
|
||||
})
|
||||
.then(() => tcpPortUsed.waitUntilFree(NITRO_DEFAULT_PORT, 300, 5000))
|
||||
.then(() => log(`[SERVER]::Debug: Nitro process is terminated`))
|
||||
.then(() =>
|
||||
resolve({
|
||||
message: 'Model stopped',
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
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',
|
||||
|
||||
onDeepLink = 'onDeepLink',
|
||||
onMainViewStateChange = 'onMainViewStateChange',
|
||||
}
|
||||
|
||||
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
|
||||
extensionId?: string
|
||||
downloadType?: DownloadType
|
||||
downloadType?: DownloadType | string
|
||||
localPath?: string
|
||||
}
|
||||
|
||||
@ -40,7 +40,7 @@ export type DownloadRequest = {
|
||||
*/
|
||||
extensionId?: string
|
||||
|
||||
downloadType?: DownloadType
|
||||
downloadType?: DownloadType | string
|
||||
}
|
||||
|
||||
type DownloadTime = {
|
||||
|
||||
10
core/testRunner.js
Normal file
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"],
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["**/*.test.ts"]
|
||||
}
|
||||
|
||||
@ -11,8 +11,9 @@ module.exports = {
|
||||
'plugin:react/recommended',
|
||||
],
|
||||
rules: {
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
'react/prop-types': 'off', // In favor of strong typing - no need to dedupe
|
||||
'react/no-is-mounted': 'off',
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
'@typescript-eslint/no-var-requires': 'off',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
@ -34,5 +35,5 @@ module.exports = {
|
||||
{ name: 'Link', linkAttribute: 'to' },
|
||||
],
|
||||
},
|
||||
ignorePatterns: ['build', 'renderer', 'node_modules', '@global'],
|
||||
ignorePatterns: ['build', 'renderer', 'node_modules', '@global', 'playwright-report'],
|
||||
}
|
||||
|
||||
@ -6,9 +6,10 @@ export const mainWindowConfig: Electron.BrowserWindowConstructorOptions = {
|
||||
minWidth: DEFAULT_MIN_WIDTH,
|
||||
minHeight: DEFAULT_MIN_HEIGHT,
|
||||
show: true,
|
||||
transparent: true,
|
||||
frame: false,
|
||||
titleBarStyle: 'hidden',
|
||||
// we want to go frameless on windows and linux
|
||||
transparent: process.platform === 'darwin',
|
||||
frame: process.platform === 'darwin',
|
||||
titleBarStyle: 'hiddenInset',
|
||||
vibrancy: 'fullscreen-ui',
|
||||
visualEffectState: 'active',
|
||||
backgroundMaterial: 'acrylic',
|
||||
|
||||
@ -166,6 +166,15 @@ class WindowManager {
|
||||
}, 500)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send main view state to the main app.
|
||||
*/
|
||||
sendMainViewState(route: string) {
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.webContents.send(AppEvent.onMainViewStateChange, route)
|
||||
}
|
||||
}
|
||||
|
||||
cleanUp(): void {
|
||||
if (!this.mainWindow?.isDestroyed()) {
|
||||
this.mainWindow?.close()
|
||||
|
||||
@ -3,6 +3,7 @@ import { app, Menu, shell, dialog } from 'electron'
|
||||
import { autoUpdater } from 'electron-updater'
|
||||
import { log } from '@janhq/core/node'
|
||||
const isMac = process.platform === 'darwin'
|
||||
import { windowManager } from '../managers/window'
|
||||
|
||||
const template: (Electron.MenuItemConstructorOptions | Electron.MenuItem)[] = [
|
||||
{
|
||||
@ -43,6 +44,14 @@ const template: (Electron.MenuItemConstructorOptions | Electron.MenuItem)[] = [
|
||||
{ role: 'hide' },
|
||||
{ role: 'hideOthers' },
|
||||
{ role: 'unhide' },
|
||||
{
|
||||
label: `Settings`,
|
||||
accelerator: 'CmdOrCtrl+,',
|
||||
click: () => {
|
||||
windowManager.showMainWindow()
|
||||
windowManager.sendMainViewState('Settings')
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ role: 'quit' },
|
||||
],
|
||||
|
||||
@ -69,9 +69,9 @@
|
||||
],
|
||||
"id": "gemma-7b-it",
|
||||
"object": "model",
|
||||
"name": "Groq Gemma 7b Instruct",
|
||||
"version": "1.1",
|
||||
"description": "Groq Gemma 7b Instruct with supercharged speed!",
|
||||
"name": "Groq Gemma 7B Instruct",
|
||||
"version": "1.2",
|
||||
"description": "Groq Gemma 7B Instruct with supercharged speed!",
|
||||
"format": "api",
|
||||
"settings": {},
|
||||
"parameters": {
|
||||
@ -99,9 +99,9 @@
|
||||
],
|
||||
"id": "mixtral-8x7b-32768",
|
||||
"object": "model",
|
||||
"name": "Groq Mixtral 8x7b Instruct",
|
||||
"version": "1.1",
|
||||
"description": "Groq Mixtral 8x7b Instruct is Mixtral with supercharged speed!",
|
||||
"name": "Groq Mixtral 8x7B Instruct",
|
||||
"version": "1.2",
|
||||
"description": "Groq Mixtral 8x7B Instruct is Mixtral with supercharged speed!",
|
||||
"format": "api",
|
||||
"settings": {},
|
||||
"parameters": {
|
||||
|
||||
@ -1,3 +1,31 @@
|
||||
@echo off
|
||||
set BIN_PATH=./bin
|
||||
set /p CORTEX_VERSION=<./bin/version.txt
|
||||
.\node_modules\.bin\download https://github.com/janhq/cortex/releases/download/v%CORTEX_VERSION%/cortex-cpp-%CORTEX_VERSION%-windows-amd64.tar.gz -e --strip 1 -o ./bin/win-cuda-12-0 && .\node_modules\.bin\download https://github.com/janhq/cortex/releases/download/v%CORTEX_VERSION%/cortex-cpp-%CORTEX_VERSION%-windows-amd64.tar.gz -e --strip 1 -o ./bin/win-cuda-11-7 && .\node_modules\.bin\download https://github.com/janhq/cortex/releases/download/v%CORTEX_VERSION%/cortex-cpp-%CORTEX_VERSION%-windows-amd64.tar.gz -e --strip 1 -o ./bin/win-cpu && .\node_modules\.bin\download https://github.com/janhq/cortex/releases/download/v%CORTEX_VERSION%/cortex-cpp-%CORTEX_VERSION%-windows-amd64.tar.gz -e --strip 1 -o ./bin/win-vulkan && .\node_modules\.bin\download https://github.com/janhq/cortex.llamacpp/releases/download/v0.1.25/cortex.llamacpp-0.1.25-windows-amd64-noavx-cuda-12-0.tar.gz -e --strip 1 -o ./bin/win-cuda-12-0/engines/cortex.llamacpp && .\node_modules\.bin\download https://github.com/janhq/cortex.llamacpp/releases/download/v0.1.25/cortex.llamacpp-0.1.25-windows-amd64-noavx-cuda-11-7.tar.gz -e --strip 1 -o ./bin/win-cuda-11-7/engines/cortex.llamacpp && .\node_modules\.bin\download https://github.com/janhq/cortex.llamacpp/releases/download/v0.1.25/cortex.llamacpp-0.1.25-windows-amd64-noavx.tar.gz -e --strip 1 -o ./bin/win-cpu/engines/cortex.llamacpp && .\node_modules\.bin\download https://github.com/janhq/cortex.llamacpp/releases/download/v0.1.25/cortex.llamacpp-0.1.25-windows-amd64-vulkan.tar.gz -e --strip 1 -o ./bin/win-vulkan/engines/cortex.llamacpp
|
||||
|
||||
@REM Download cortex.llamacpp binaries
|
||||
set VERSION=v0.1.25
|
||||
set DOWNLOAD_URL=https://github.com/janhq/cortex.llamacpp/releases/download/%VERSION%/cortex.llamacpp-0.1.25-windows-amd64
|
||||
set SUBFOLDERS=win-cuda-12-0 win-cuda-11-7 win-noavx win-avx win-avx2 win-avx512 win-vulkan
|
||||
|
||||
call .\node_modules\.bin\download -e --strip 1 -o %BIN_PATH% https://github.com/janhq/cortex/releases/download/v%CORTEX_VERSION%/cortex-cpp-%CORTEX_VERSION%-windows-amd64.tar.gz
|
||||
call .\node_modules\.bin\download %DOWNLOAD_URL%-avx2-cuda-12-0.tar.gz -e --strip 1 -o %BIN_PATH%/win-cuda-12-0/engines/cortex.llamacpp
|
||||
call .\node_modules\.bin\download %DOWNLOAD_URL%-avx2-cuda-11-7.tar.gz -e --strip 1 -o %BIN_PATH%/win-cuda-11-7/engines/cortex.llamacpp
|
||||
call .\node_modules\.bin\download %DOWNLOAD_URL%-noavx.tar.gz -e --strip 1 -o %BIN_PATH%/win-noavx/engines/cortex.llamacpp
|
||||
call .\node_modules\.bin\download %DOWNLOAD_URL%-avx.tar.gz -e --strip 1 -o %BIN_PATH%/win-avx/engines/cortex.llamacpp
|
||||
call .\node_modules\.bin\download %DOWNLOAD_URL%-avx2.tar.gz -e --strip 1 -o %BIN_PATH%/win-avx2/engines/cortex.llamacpp
|
||||
call .\node_modules\.bin\download %DOWNLOAD_URL%-avx512.tar.gz -e --strip 1 -o %BIN_PATH%/win-avx512/engines/cortex.llamacpp
|
||||
call .\node_modules\.bin\download %DOWNLOAD_URL%-vulkan.tar.gz -e --strip 1 -o %BIN_PATH%/win-vulkan/engines/cortex.llamacpp
|
||||
|
||||
@REM Loop through each folder and move DLLs (excluding engine.dll)
|
||||
for %%F in (%SUBFOLDERS%) do (
|
||||
echo Processing folder: %BIN_PATH%\%%F
|
||||
|
||||
@REM Move all .dll files except engine.dll
|
||||
for %%D in (%BIN_PATH%\%%F\engines\cortex.llamacpp\*.dll) do (
|
||||
if /I not "%%~nxD"=="engine.dll" (
|
||||
move "%%D" "%BIN_PATH%"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
echo DLL files moved successfully.
|
||||
41
extensions/inference-nitro-extension/download.sh
Executable file
41
extensions/inference-nitro-extension/download.sh
Executable file
@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Read CORTEX_VERSION
|
||||
CORTEX_VERSION=$(cat ./bin/version.txt)
|
||||
CORTEX_RELEASE_URL="https://github.com/janhq/cortex/releases/download"
|
||||
|
||||
# Detect platform
|
||||
OS_TYPE=$(uname)
|
||||
|
||||
if [ "$OS_TYPE" == "Linux" ]; then
|
||||
# Linux downloads
|
||||
download "${CORTEX_RELEASE_URL}/v${CORTEX_VERSION}/cortex-cpp-${CORTEX_VERSION}-linux-amd64.tar.gz" -e --strip 1 -o "./bin"
|
||||
chmod +x "./bin/cortex-cpp"
|
||||
|
||||
ENGINE_DOWNLOAD_URL="https://github.com/janhq/cortex.llamacpp/releases/download/v0.1.25/cortex.llamacpp-0.1.25-linux-amd64"
|
||||
|
||||
# Download engines for Linux
|
||||
download "${ENGINE_DOWNLOAD_URL}-noavx.tar.gz" -e --strip 1 -o "./bin/linux-noavx/engines/cortex.llamacpp" 1
|
||||
download "${ENGINE_DOWNLOAD_URL}-avx.tar.gz" -e --strip 1 -o "./bin/linux-avx/engines/cortex.llamacpp" 1
|
||||
download "${ENGINE_DOWNLOAD_URL}-avx2.tar.gz" -e --strip 1 -o "./bin/linux-avx2/engines/cortex.llamacpp" 1
|
||||
download "${ENGINE_DOWNLOAD_URL}-avx512.tar.gz" -e --strip 1 -o "./bin/linux-avx512/engines/cortex.llamacpp" 1
|
||||
download "${ENGINE_DOWNLOAD_URL}-avx2-cuda-12-0.tar.gz" -e --strip 1 -o "./bin/linux-cuda-12-0/engines/cortex.llamacpp" 1
|
||||
download "${ENGINE_DOWNLOAD_URL}-avx2-cuda-11-7.tar.gz" -e --strip 1 -o "./bin/linux-cuda-11-7/engines/cortex.llamacpp" 1
|
||||
download "${ENGINE_DOWNLOAD_URL}-vulkan.tar.gz" -e --strip 1 -o "./bin/linux-vulkan/engines/cortex.llamacpp" 1
|
||||
|
||||
elif [ "$OS_TYPE" == "Darwin" ]; then
|
||||
# macOS downloads
|
||||
download "${CORTEX_RELEASE_URL}/v${CORTEX_VERSION}/cortex-cpp-${CORTEX_VERSION}-mac-arm64.tar.gz" -e --strip 1 -o "./bin/mac-arm64" 1
|
||||
download "${CORTEX_RELEASE_URL}/v${CORTEX_VERSION}/cortex-cpp-${CORTEX_VERSION}-mac-amd64.tar.gz" -e --strip 1 -o "./bin/mac-x64" 1
|
||||
chmod +x "./bin/mac-arm64/cortex-cpp"
|
||||
chmod +x "./bin/mac-x64/cortex-cpp"
|
||||
|
||||
ENGINE_DOWNLOAD_URL="https://github.com/janhq/cortex.llamacpp/releases/download/v0.1.25/cortex.llamacpp-0.1.25-mac"
|
||||
# Download engines for macOS
|
||||
download "${ENGINE_DOWNLOAD_URL}-arm64.tar.gz" -e --strip 1 -o ./bin/mac-arm64/engines/cortex.llamacpp
|
||||
download "${ENGINE_DOWNLOAD_URL}-amd64.tar.gz" -e --strip 1 -o ./bin/mac-x64/engines/cortex.llamacpp
|
||||
|
||||
else
|
||||
echo "Unsupported operating system: $OS_TYPE"
|
||||
exit 1
|
||||
fi
|
||||
@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "@janhq/inference-cortex-extension",
|
||||
"productName": "Cortex Inference Engine",
|
||||
"version": "1.0.15",
|
||||
"description": "This extension embeds cortex.cpp, a lightweight inference engine written in C++. See https://nitro.jan.ai.\nAdditional dependencies could be installed to run without Cuda Toolkit installation.",
|
||||
"version": "1.0.16",
|
||||
"description": "This extension embeds cortex.cpp, a lightweight inference engine written in C++. See https://jan.ai.\nAdditional dependencies could be installed to run without Cuda Toolkit installation.",
|
||||
"main": "dist/index.js",
|
||||
"node": "dist/node/index.cjs.js",
|
||||
"author": "Jan <service@jan.ai>",
|
||||
@ -10,13 +10,11 @@
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"build": "tsc --module commonjs && rollup -c rollup.config.ts",
|
||||
"downloadnitro:linux": "CORTEX_VERSION=$(cat ./bin/version.txt) && download https://github.com/janhq/cortex/releases/download/v${CORTEX_VERSION}/cortex-cpp-${CORTEX_VERSION}-linux-amd64.tar.gz -e --strip 1 -o ./bin/linux-cpu && chmod +x ./bin/linux-cpu/cortex-cpp && download https://github.com/janhq/cortex/releases/download/v${CORTEX_VERSION}/cortex-cpp-${CORTEX_VERSION}-linux-amd64.tar.gz -e --strip 1 -o ./bin/linux-cuda-12-0 && chmod +x ./bin/linux-cuda-12-0/cortex-cpp && download https://github.com/janhq/cortex/releases/download/v${CORTEX_VERSION}/cortex-cpp-${CORTEX_VERSION}-linux-amd64.tar.gz -e --strip 1 -o ./bin/linux-cuda-11-7 && chmod +x ./bin/linux-cuda-11-7/cortex-cpp && download https://github.com/janhq/cortex/releases/download/v${CORTEX_VERSION}/cortex-cpp-${CORTEX_VERSION}-linux-amd64.tar.gz -e --strip 1 -o ./bin/linux-vulkan && chmod +x ./bin/linux-vulkan/cortex-cpp && download https://github.com/janhq/cortex.llamacpp/releases/download/v0.1.25/cortex.llamacpp-0.1.25-linux-amd64-noavx.tar.gz -e --strip 1 -o ./bin/linux-cpu/engines/cortex.llamacpp && download https://github.com/janhq/cortex.llamacpp/releases/download/v0.1.25/cortex.llamacpp-0.1.25-linux-amd64-noavx-cuda-12-0.tar.gz -e --strip 1 -o ./bin/linux-cuda-12-0/engines/cortex.llamacpp && download https://github.com/janhq/cortex.llamacpp/releases/download/v0.1.25/cortex.llamacpp-0.1.25-linux-amd64-noavx-cuda-11-7.tar.gz -e --strip 1 -o ./bin/linux-cuda-11-7/engines/cortex.llamacpp && download https://github.com/janhq/cortex.llamacpp/releases/download/v0.1.25/cortex.llamacpp-0.1.25-linux-amd64-vulkan.tar.gz -e --strip 1 -o ./bin/linux-vulkan/engines/cortex.llamacpp",
|
||||
"downloadnitro:darwin": "CORTEX_VERSION=$(cat ./bin/version.txt) && download https://github.com/janhq/cortex/releases/download/v${CORTEX_VERSION}/cortex-cpp-${CORTEX_VERSION}-mac-arm64.tar.gz -o ./bin/ && mkdir -p ./bin/mac-arm64 && tar -zxvf ./bin/cortex-cpp-${CORTEX_VERSION}-mac-arm64.tar.gz --strip-components=1 -C ./bin/mac-arm64 && rm -rf ./bin/cortex-cpp-${CORTEX_VERSION}-mac-arm64.tar.gz && chmod +x ./bin/mac-arm64/cortex-cpp && download https://github.com/janhq/cortex/releases/download/v${CORTEX_VERSION}/cortex-cpp-${CORTEX_VERSION}-mac-amd64.tar.gz -o ./bin/ && mkdir -p ./bin/mac-amd64 && tar -zxvf ./bin/cortex-cpp-${CORTEX_VERSION}-mac-amd64.tar.gz --strip-components=1 -C ./bin/mac-amd64 && rm -rf ./bin/cortex-cpp-${CORTEX_VERSION}-mac-amd64.tar.gz && chmod +x ./bin/mac-amd64/cortex-cpp && download https://github.com/janhq/cortex.llamacpp/releases/download/v0.1.25/cortex.llamacpp-0.1.25-mac-arm64.tar.gz -e --strip 1 -o ./bin/mac-arm64/engines/cortex.llamacpp && download https://github.com/janhq/cortex.llamacpp/releases/download/v0.1.25/cortex.llamacpp-0.1.25-mac-amd64.tar.gz -e --strip 1 -o ./bin/mac-amd64/engines/cortex.llamacpp",
|
||||
"downloadnitro:linux:darwin": "./download.sh",
|
||||
"downloadnitro:win32": "download.bat",
|
||||
"downloadnitro": "run-script-os",
|
||||
"build:publish:darwin": "rimraf *.tgz --glob && yarn build && npm run downloadnitro && ../../.github/scripts/auto-sign.sh && cpx \"bin/**\" \"dist/bin\" && npm pack && cpx *.tgz ../../pre-install",
|
||||
"build:publish:win32": "rimraf *.tgz --glob && yarn build && npm run downloadnitro && cpx \"bin/**\" \"dist/bin\" && npm pack && cpx *.tgz ../../pre-install",
|
||||
"build:publish:linux": "rimraf *.tgz --glob && yarn build && npm run downloadnitro && cpx \"bin/**\" \"dist/bin\" && npm pack && cpx *.tgz ../../pre-install",
|
||||
"build:publish:win32:linux": "rimraf *.tgz --glob && yarn build && npm run downloadnitro && cpx \"bin/**\" \"dist/bin\" && npm pack && cpx *.tgz ../../pre-install",
|
||||
"build:publish": "yarn test && run-script-os"
|
||||
},
|
||||
"exports": {
|
||||
@ -49,6 +47,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@janhq/core": "file:../../core",
|
||||
"cpu-instructions": "^0.0.13",
|
||||
"decompress": "^4.2.1",
|
||||
"fetch-retry": "^5.0.6",
|
||||
"rxjs": "^7.8.1",
|
||||
@ -68,6 +67,7 @@
|
||||
"tcp-port-used",
|
||||
"fetch-retry",
|
||||
"@janhq/core",
|
||||
"decompress"
|
||||
"decompress",
|
||||
"cpu-instructions"
|
||||
]
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
"id": "aya-23-8b",
|
||||
"object": "model",
|
||||
"name": "Aya 23 8B Q4",
|
||||
"version": "1.1",
|
||||
"version": "1.2",
|
||||
"description": "Aya 23 can talk upto 23 languages fluently.",
|
||||
"format": "gguf",
|
||||
"settings": {
|
||||
@ -28,7 +28,7 @@
|
||||
},
|
||||
"metadata": {
|
||||
"author": "CohereForAI",
|
||||
"tags": ["7B", "Finetuned","Featured"],
|
||||
"tags": ["7B", "Finetuned"],
|
||||
"size": 5056982144
|
||||
},
|
||||
"engine": "nitro"
|
||||
|
||||
@ -7,8 +7,8 @@
|
||||
],
|
||||
"id": "deepseek-coder-1.3b",
|
||||
"object": "model",
|
||||
"name": "Deepseek Coder 1.3B Q8",
|
||||
"version": "1.2",
|
||||
"name": "Deepseek Coder 1.3B Instruct Q8",
|
||||
"version": "1.3",
|
||||
"description": "Deepseek Coder excelled in project-level code completion with advanced capabilities across multiple programming languages.",
|
||||
"format": "gguf",
|
||||
"settings": {
|
||||
|
||||
@ -7,8 +7,8 @@
|
||||
],
|
||||
"id": "deepseek-coder-34b",
|
||||
"object": "model",
|
||||
"name": "Deepseek Coder 33B Q4",
|
||||
"version": "1.2",
|
||||
"name": "Deepseek Coder 33B Instruct Q4",
|
||||
"version": "1.3",
|
||||
"description": "Deepseek Coder excelled in project-level code completion with advanced capabilities across multiple programming languages.",
|
||||
"format": "gguf",
|
||||
"settings": {
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
"id": "gemma-1.1-7b-it",
|
||||
"object": "model",
|
||||
"name": "Gemma 1.1 7B Q4",
|
||||
"version": "1.2",
|
||||
"version": "1.3",
|
||||
"description": "Google's Gemma is built for multilingual purpose",
|
||||
"format": "gguf",
|
||||
"settings": {
|
||||
@ -28,7 +28,7 @@
|
||||
},
|
||||
"metadata": {
|
||||
"author": "Google",
|
||||
"tags": ["7B", "Finetuned", "Featured"],
|
||||
"tags": ["7B", "Finetuned"],
|
||||
"size": 5330000000
|
||||
},
|
||||
"engine": "nitro"
|
||||
|
||||
@ -7,8 +7,8 @@
|
||||
],
|
||||
"id": "llama3-8b-instruct",
|
||||
"object": "model",
|
||||
"name": "Llama 3 8B Q4",
|
||||
"version": "1.2",
|
||||
"name": "Llama 3 8B Instruct Q4",
|
||||
"version": "1.4",
|
||||
"description": "Meta's Llama 3 excels at general usage situations, including chat, general world knowledge, and coding.",
|
||||
"format": "gguf",
|
||||
"settings": {
|
||||
@ -28,7 +28,7 @@
|
||||
},
|
||||
"metadata": {
|
||||
"author": "MetaAI",
|
||||
"tags": ["8B", "Featured"],
|
||||
"tags": ["8B"],
|
||||
"size": 4920000000
|
||||
},
|
||||
"engine": "nitro"
|
||||
|
||||
@ -7,8 +7,8 @@
|
||||
],
|
||||
"id": "llama3.1-70b-instruct",
|
||||
"object": "model",
|
||||
"name": "Llama 3.1 70B Q4 Instruct",
|
||||
"version": "1.0",
|
||||
"name": "Llama 3.1 70B Instruct Q4",
|
||||
"version": "1.1",
|
||||
"description": "Meta's Llama 3.1 excels at general usage situations, including chat, general world knowledge, and coding.",
|
||||
"format": "gguf",
|
||||
"settings": {
|
||||
|
||||
@ -7,8 +7,8 @@
|
||||
],
|
||||
"id": "llama3.1-8b-instruct",
|
||||
"object": "model",
|
||||
"name": "Llama 3.1 8B Q4 Instruct",
|
||||
"version": "1.0",
|
||||
"name": "Llama 3.1 8B Instruct Q4",
|
||||
"version": "1.1",
|
||||
"description": "Meta's Llama 3.1 excels at general usage situations, including chat, general world knowledge, and coding.",
|
||||
"format": "gguf",
|
||||
"settings": {
|
||||
|
||||
@ -7,9 +7,9 @@
|
||||
],
|
||||
"id": "mistral-ins-7b-q4",
|
||||
"object": "model",
|
||||
"name": "Mistral Instruct 7B Q4",
|
||||
"version": "1.3",
|
||||
"description": "Mistral Instruct 7b model, specifically designed for a comprehensive understanding of the world.",
|
||||
"name": "Mistral 7B Instruct Q4",
|
||||
"version": "1.5",
|
||||
"description": "Mistral 7B Instruct model, specifically designed for a comprehensive understanding of the world.",
|
||||
"format": "gguf",
|
||||
"settings": {
|
||||
"ctx_len": 32768,
|
||||
@ -28,7 +28,7 @@
|
||||
},
|
||||
"metadata": {
|
||||
"author": "MistralAI",
|
||||
"tags": ["Featured", "7B", "Foundational Model"],
|
||||
"tags": ["7B", "Foundational Model"],
|
||||
"size": 4370000000,
|
||||
"cover": "https://raw.githubusercontent.com/janhq/jan/dev/models/mistral-ins-7b-q4/cover.png"
|
||||
},
|
||||
|
||||
@ -1,20 +1,20 @@
|
||||
{
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://huggingface.co/microsoft/Phi-3-mini-4k-instruct-gguf/resolve/main/Phi-3-mini-4k-instruct-q4.gguf",
|
||||
"filename": "Phi-3-mini-4k-instruct-q4.gguf"
|
||||
"url": "https://huggingface.co/cortexso/phi3/resolve/main/model.gguf",
|
||||
"filename": "model.gguf"
|
||||
}
|
||||
],
|
||||
"id": "phi3-3.8b",
|
||||
"object": "model",
|
||||
"name": "Phi-3 Mini",
|
||||
"version": "1.2",
|
||||
"name": "Phi-3 Mini Instruct Q4",
|
||||
"version": "1.3",
|
||||
"description": "Phi-3 Mini is Microsoft's newest, compact model designed for mobile use.",
|
||||
"format": "gguf",
|
||||
"settings": {
|
||||
"ctx_len": 4096,
|
||||
"prompt_template": "<|user|>\n{prompt}<|end|>\n<|assistant|>\n",
|
||||
"llama_model_path": "Phi-3-mini-4k-instruct-q4.gguf",
|
||||
"llama_model_path": "model.gguf",
|
||||
"ngl": 33
|
||||
},
|
||||
"parameters": {
|
||||
@ -35,4 +35,4 @@
|
||||
"size": 2320000000
|
||||
},
|
||||
"engine": "nitro"
|
||||
}
|
||||
}
|
||||
@ -7,13 +7,13 @@
|
||||
],
|
||||
"id": "phi3-medium",
|
||||
"object": "model",
|
||||
"name": "Phi-3 Medium",
|
||||
"version": "1.2",
|
||||
"name": "Phi-3 Medium Instruct Q4",
|
||||
"version": "1.3",
|
||||
"description": "Phi-3 Medium is Microsoft's latest SOTA model.",
|
||||
"format": "gguf",
|
||||
"settings": {
|
||||
"ctx_len": 128000,
|
||||
"prompt_template": "<|user|>\n{prompt}<|end|>\n<|assistant|>\n",
|
||||
"prompt_template": "<|user|> {prompt}<|end|><|assistant|><|end|>",
|
||||
"llama_model_path": "Phi-3-medium-128k-instruct-Q4_K_M.gguf",
|
||||
"ngl": 33
|
||||
},
|
||||
|
||||
@ -7,8 +7,8 @@
|
||||
],
|
||||
"id": "qwen2-7b",
|
||||
"object": "model",
|
||||
"name": "Qwen 2 Instruct 7B Q4",
|
||||
"version": "1.1",
|
||||
"name": "Qwen 2 7B Instruct Q4",
|
||||
"version": "1.2",
|
||||
"description": "Qwen is optimized at Chinese, ideal for everyday tasks.",
|
||||
"format": "gguf",
|
||||
"settings": {
|
||||
|
||||
@ -96,7 +96,7 @@ export default [
|
||||
llama3170bJson,
|
||||
gemma22bJson,
|
||||
gemma29bJson,
|
||||
gemma227bJson
|
||||
gemma227bJson,
|
||||
]),
|
||||
NODE: JSON.stringify(`${packageJson.name}/${packageJson.node}`),
|
||||
DEFAULT_SETTINGS: JSON.stringify(defaultSettingJson),
|
||||
@ -117,7 +117,10 @@ export default [
|
||||
// Allow json resolution
|
||||
json(),
|
||||
// Compile TypeScript files
|
||||
typescript({ useTsconfigDeclarationDir: true }),
|
||||
typescript({
|
||||
useTsconfigDeclarationDir: true,
|
||||
exclude: ['**/__tests__', '**/*.test.ts'],
|
||||
}),
|
||||
// Compile TypeScript files
|
||||
// Allow bundling cjs modules (unlike webpack, rollup doesn't understand cjs)
|
||||
commonjs(),
|
||||
@ -139,7 +142,7 @@ export default [
|
||||
{ file: 'dist/node/index.cjs.js', format: 'cjs', sourcemap: true },
|
||||
],
|
||||
// Indicate here external modules you don't wanna include in your bundle (i.e.: 'lodash')
|
||||
external: ['@janhq/core/node'],
|
||||
external: ['@janhq/core/node', 'cpu-instructions'],
|
||||
watch: {
|
||||
include: 'src/node/**',
|
||||
},
|
||||
@ -147,7 +150,10 @@ export default [
|
||||
// Allow json resolution
|
||||
json(),
|
||||
// Compile TypeScript files
|
||||
typescript({ useTsconfigDeclarationDir: true }),
|
||||
typescript({
|
||||
useTsconfigDeclarationDir: true,
|
||||
exclude: ['**/__tests__', '**/*.test.ts'],
|
||||
}),
|
||||
// Allow bundling cjs modules (unlike webpack, rollup doesn't understand cjs)
|
||||
commonjs(),
|
||||
// Allow node_modules resolution, so you can use 'external' to control
|
||||
@ -156,7 +162,6 @@ export default [
|
||||
resolve({
|
||||
extensions: ['.ts', '.js', '.json'],
|
||||
}),
|
||||
|
||||
// Resolve source maps to the original source
|
||||
sourceMaps(),
|
||||
],
|
||||
|
||||
@ -73,6 +73,7 @@ export default class JanInferenceNitroExtension extends LocalOAIEngine {
|
||||
this.registerModels(models)
|
||||
super.onLoad()
|
||||
|
||||
// Add additional dependencies PATH to the env
|
||||
executeOnMain(NODE, 'addAdditionalDependencies', {
|
||||
name: this.name,
|
||||
version: this.version,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from '@jest/globals'
|
||||
import { executableNitroFile } from './execute'
|
||||
import { GpuSetting } from '@janhq/core'
|
||||
import { sep } from 'path'
|
||||
import { cpuInfo } from 'cpu-instructions'
|
||||
|
||||
let testSettings: GpuSetting = {
|
||||
run_mode: 'cpu',
|
||||
@ -22,6 +22,14 @@ let testSettings: GpuSetting = {
|
||||
}
|
||||
const originalPlatform = process.platform
|
||||
|
||||
jest.mock('cpu-instructions', () => ({
|
||||
cpuInfo: {
|
||||
cpuInfo: jest.fn(),
|
||||
},
|
||||
}))
|
||||
let mock = cpuInfo.cpuInfo as jest.Mock
|
||||
mock.mockReturnValue([])
|
||||
|
||||
describe('test executable nitro file', () => {
|
||||
afterAll(function () {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
@ -38,17 +46,19 @@ describe('test executable nitro file', () => {
|
||||
})
|
||||
expect(executableNitroFile(testSettings)).toEqual(
|
||||
expect.objectContaining({
|
||||
executablePath: expect.stringContaining(`mac-arm64${sep}cortex-cpp`),
|
||||
enginePath: expect.stringContaining(`mac-arm64`),
|
||||
executablePath: originalPlatform === 'darwin' ? expect.stringContaining(`mac-arm64/cortex-cpp`) : expect.anything(),
|
||||
cudaVisibleDevices: '',
|
||||
vkVisibleDevices: '',
|
||||
})
|
||||
)
|
||||
Object.defineProperty(process, 'arch', {
|
||||
value: 'amd64',
|
||||
value: 'x64',
|
||||
})
|
||||
expect(executableNitroFile(testSettings)).toEqual(
|
||||
expect.objectContaining({
|
||||
executablePath: expect.stringContaining(`mac-amd64${sep}cortex-cpp`),
|
||||
enginePath: expect.stringContaining(`mac-x64`),
|
||||
executablePath: originalPlatform === 'darwin' ? expect.stringContaining(`mac-x64/cortex-cpp`) : expect.anything(),
|
||||
cudaVisibleDevices: '',
|
||||
vkVisibleDevices: '',
|
||||
})
|
||||
@ -62,14 +72,11 @@ describe('test executable nitro file', () => {
|
||||
const settings: GpuSetting = {
|
||||
...testSettings,
|
||||
run_mode: 'cpu',
|
||||
cuda: {
|
||||
exist: true,
|
||||
version: '11',
|
||||
},
|
||||
}
|
||||
expect(executableNitroFile(settings)).toEqual(
|
||||
expect.objectContaining({
|
||||
executablePath: expect.stringContaining(`win-cpu${sep}cortex-cpp.exe`),
|
||||
enginePath: expect.stringContaining(`win`),
|
||||
executablePath: expect.stringContaining(`cortex-cpp.exe`),
|
||||
cudaVisibleDevices: '',
|
||||
vkVisibleDevices: '',
|
||||
})
|
||||
@ -102,7 +109,8 @@ describe('test executable nitro file', () => {
|
||||
}
|
||||
expect(executableNitroFile(settings)).toEqual(
|
||||
expect.objectContaining({
|
||||
executablePath: expect.stringContaining(`win-cuda-11-7${sep}cortex-cpp.exe`),
|
||||
enginePath: expect.stringContaining(`win-cuda-11-7`),
|
||||
executablePath: expect.stringContaining(`cortex-cpp.exe`),
|
||||
cudaVisibleDevices: '0',
|
||||
vkVisibleDevices: '0',
|
||||
})
|
||||
@ -135,7 +143,8 @@ describe('test executable nitro file', () => {
|
||||
}
|
||||
expect(executableNitroFile(settings)).toEqual(
|
||||
expect.objectContaining({
|
||||
executablePath: expect.stringContaining(`win-cuda-12-0${sep}cortex-cpp.exe`),
|
||||
enginePath: expect.stringContaining(`win-cuda-12-0`),
|
||||
executablePath: expect.stringContaining(`cortex-cpp.exe`),
|
||||
cudaVisibleDevices: '0',
|
||||
vkVisibleDevices: '0',
|
||||
})
|
||||
@ -152,7 +161,8 @@ describe('test executable nitro file', () => {
|
||||
}
|
||||
expect(executableNitroFile(settings)).toEqual(
|
||||
expect.objectContaining({
|
||||
executablePath: expect.stringContaining(`linux-cpu${sep}cortex-cpp`),
|
||||
enginePath: expect.stringContaining(`linux`),
|
||||
executablePath: expect.stringContaining(`cortex-cpp`),
|
||||
cudaVisibleDevices: '',
|
||||
vkVisibleDevices: '',
|
||||
})
|
||||
@ -185,7 +195,8 @@ describe('test executable nitro file', () => {
|
||||
}
|
||||
expect(executableNitroFile(settings)).toEqual(
|
||||
expect.objectContaining({
|
||||
executablePath: expect.stringContaining(`linux-cuda-11-7${sep}cortex-cpp`),
|
||||
enginePath: expect.stringContaining(`linux-cuda-11-7`),
|
||||
executablePath: expect.stringContaining(`cortex-cpp`),
|
||||
cudaVisibleDevices: '0',
|
||||
vkVisibleDevices: '0',
|
||||
})
|
||||
@ -218,10 +229,203 @@ describe('test executable nitro file', () => {
|
||||
}
|
||||
expect(executableNitroFile(settings)).toEqual(
|
||||
expect.objectContaining({
|
||||
executablePath: expect.stringContaining(`linux-cuda-12-0${sep}cortex-cpp`),
|
||||
enginePath: expect.stringContaining(`linux-cuda-12-0`),
|
||||
executablePath: expect.stringContaining(`cortex-cpp`),
|
||||
cudaVisibleDevices: '0',
|
||||
vkVisibleDevices: '0',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
// Generate test for different cpu instructions on Linux
|
||||
it(`executes on Linux CPU with different instructions`, () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'linux',
|
||||
})
|
||||
const settings: GpuSetting = {
|
||||
...testSettings,
|
||||
run_mode: 'cpu',
|
||||
}
|
||||
|
||||
const cpuInstructions = ['avx512', 'avx2', 'avx', 'noavx']
|
||||
cpuInstructions.forEach((instruction) => {
|
||||
mock.mockReturnValue([instruction])
|
||||
|
||||
expect(executableNitroFile(settings)).toEqual(
|
||||
expect.objectContaining({
|
||||
enginePath: expect.stringContaining(`linux-${instruction}`),
|
||||
executablePath: expect.stringContaining(`cortex-cpp`),
|
||||
|
||||
cudaVisibleDevices: '',
|
||||
vkVisibleDevices: '',
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
// Generate test for different cpu instructions on Windows
|
||||
it(`executes on Windows CPU with different instructions`, () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'win32',
|
||||
})
|
||||
const settings: GpuSetting = {
|
||||
...testSettings,
|
||||
run_mode: 'cpu',
|
||||
}
|
||||
const cpuInstructions = ['avx512', 'avx2', 'avx', 'noavx']
|
||||
cpuInstructions.forEach((instruction) => {
|
||||
mock.mockReturnValue([instruction])
|
||||
expect(executableNitroFile(settings)).toEqual(
|
||||
expect.objectContaining({
|
||||
enginePath: expect.stringContaining(`win-${instruction}`),
|
||||
executablePath: expect.stringContaining(`cortex-cpp.exe`),
|
||||
cudaVisibleDevices: '',
|
||||
vkVisibleDevices: '',
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Generate test for different cpu instructions on Windows
|
||||
it(`executes on Windows GPU with different instructions`, () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'win32',
|
||||
})
|
||||
const settings: GpuSetting = {
|
||||
...testSettings,
|
||||
run_mode: 'gpu',
|
||||
cuda: {
|
||||
exist: true,
|
||||
version: '12',
|
||||
},
|
||||
nvidia_driver: {
|
||||
exist: true,
|
||||
version: '12',
|
||||
},
|
||||
gpus_in_use: ['0'],
|
||||
gpus: [
|
||||
{
|
||||
id: '0',
|
||||
name: 'NVIDIA GeForce GTX 1080',
|
||||
vram: '80000000',
|
||||
},
|
||||
],
|
||||
}
|
||||
const cpuInstructions = ['avx512', 'avx2', 'avx', 'noavx']
|
||||
cpuInstructions.forEach((instruction) => {
|
||||
mock.mockReturnValue([instruction])
|
||||
expect(executableNitroFile(settings)).toEqual(
|
||||
expect.objectContaining({
|
||||
enginePath: expect.stringContaining(`win-cuda-12-0`),
|
||||
executablePath: expect.stringContaining(`cortex-cpp.exe`),
|
||||
cudaVisibleDevices: '0',
|
||||
vkVisibleDevices: '0',
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Generate test for different cpu instructions on Linux
|
||||
it(`executes on Linux GPU with different instructions`, () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'linux',
|
||||
})
|
||||
const cpuInstructions = ['avx512', 'avx2', 'avx', 'noavx']
|
||||
const settings: GpuSetting = {
|
||||
...testSettings,
|
||||
run_mode: 'gpu',
|
||||
cuda: {
|
||||
exist: true,
|
||||
version: '12',
|
||||
},
|
||||
nvidia_driver: {
|
||||
exist: true,
|
||||
version: '12',
|
||||
},
|
||||
gpus_in_use: ['0'],
|
||||
gpus: [
|
||||
{
|
||||
id: '0',
|
||||
name: 'NVIDIA GeForce GTX 1080',
|
||||
vram: '80000000',
|
||||
},
|
||||
],
|
||||
}
|
||||
cpuInstructions.forEach((instruction) => {
|
||||
mock.mockReturnValue([instruction])
|
||||
expect(executableNitroFile(settings)).toEqual(
|
||||
expect.objectContaining({
|
||||
enginePath: expect.stringContaining(`linux-cuda-12-0`),
|
||||
executablePath: expect.stringContaining(`cortex-cpp`),
|
||||
cudaVisibleDevices: '0',
|
||||
vkVisibleDevices: '0',
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Generate test for different cpu instructions on Linux
|
||||
it(`executes on Linux Vulkan should not have CPU instructions included`, () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'linux',
|
||||
})
|
||||
const cpuInstructions = ['avx512', 'avx2', 'avx', 'noavx']
|
||||
const settings: GpuSetting = {
|
||||
...testSettings,
|
||||
run_mode: 'gpu',
|
||||
vulkan: true,
|
||||
cuda: {
|
||||
exist: true,
|
||||
version: '12',
|
||||
},
|
||||
nvidia_driver: {
|
||||
exist: true,
|
||||
version: '12',
|
||||
},
|
||||
gpus_in_use: ['0'],
|
||||
gpus: [
|
||||
{
|
||||
id: '0',
|
||||
name: 'NVIDIA GeForce GTX 1080',
|
||||
vram: '80000000',
|
||||
},
|
||||
],
|
||||
}
|
||||
cpuInstructions.forEach((instruction) => {
|
||||
mock.mockReturnValue([instruction])
|
||||
expect(executableNitroFile(settings)).toEqual(
|
||||
expect.objectContaining({
|
||||
enginePath: expect.stringContaining(`linux-vulkan`),
|
||||
executablePath: expect.stringContaining(`cortex-cpp`),
|
||||
cudaVisibleDevices: '0',
|
||||
vkVisibleDevices: '0',
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Generate test for different cpu instructions on MacOS
|
||||
it(`executes on MacOS with different instructions`, () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'darwin',
|
||||
})
|
||||
const cpuInstructions = ['avx512', 'avx2', 'avx', 'noavx']
|
||||
cpuInstructions.forEach(() => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'darwin',
|
||||
})
|
||||
const settings: GpuSetting = {
|
||||
...testSettings,
|
||||
run_mode: 'cpu',
|
||||
}
|
||||
mock.mockReturnValue([])
|
||||
expect(executableNitroFile(settings)).toEqual(
|
||||
expect.objectContaining({
|
||||
enginePath: expect.stringContaining(`mac-x64`),
|
||||
executablePath: originalPlatform === 'darwin' ? expect.stringContaining(`mac-x64/cortex-cpp`) : expect.anything(),
|
||||
cudaVisibleDevices: '',
|
||||
vkVisibleDevices: '',
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,37 +1,59 @@
|
||||
import { GpuSetting } from '@janhq/core'
|
||||
import * as path from 'path'
|
||||
import { cpuInfo } from 'cpu-instructions'
|
||||
|
||||
export interface NitroExecutableOptions {
|
||||
enginePath: string
|
||||
executablePath: string
|
||||
cudaVisibleDevices: string
|
||||
vkVisibleDevices: string
|
||||
}
|
||||
const runMode = (settings?: GpuSetting): string => {
|
||||
/**
|
||||
* The GPU runMode that will be set - either 'vulkan', 'cuda', or empty for cpu.
|
||||
* @param settings
|
||||
* @returns
|
||||
*/
|
||||
const gpuRunMode = (settings?: GpuSetting): string => {
|
||||
if (process.platform === 'darwin')
|
||||
// MacOS now has universal binaries
|
||||
return ''
|
||||
|
||||
if (!settings) return 'cpu'
|
||||
if (!settings) return ''
|
||||
|
||||
return settings.vulkan === true
|
||||
? 'vulkan'
|
||||
: settings.run_mode === 'cpu'
|
||||
? 'cpu'
|
||||
? ''
|
||||
: 'cuda'
|
||||
}
|
||||
|
||||
/**
|
||||
* The OS & architecture that the current process is running on.
|
||||
* @returns win, mac-x64, mac-arm64, or linux
|
||||
*/
|
||||
const os = (): string => {
|
||||
return process.platform === 'win32'
|
||||
? 'win'
|
||||
: process.platform === 'darwin'
|
||||
? process.arch === 'arm64' ? 'mac-arm64' : 'mac-amd64'
|
||||
? process.arch === 'arm64'
|
||||
? 'mac-arm64'
|
||||
: 'mac-x64'
|
||||
: 'linux'
|
||||
}
|
||||
|
||||
/**
|
||||
* The cortex.cpp extension based on the current platform.
|
||||
* @returns .exe if on Windows, otherwise an empty string.
|
||||
*/
|
||||
const extension = (): '.exe' | '' => {
|
||||
return process.platform === 'win32' ? '.exe' : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* The CUDA version that will be set - either '11-7' or '12-0'.
|
||||
* @param settings
|
||||
* @returns
|
||||
*/
|
||||
const cudaVersion = (settings?: GpuSetting): '11-7' | '12-0' | undefined => {
|
||||
const isUsingCuda =
|
||||
settings?.vulkan !== true && settings?.run_mode === 'gpu' && os() !== 'mac'
|
||||
@ -40,6 +62,21 @@ const cudaVersion = (settings?: GpuSetting): '11-7' | '12-0' | undefined => {
|
||||
return settings?.cuda?.version === '11' ? '11-7' : '12-0'
|
||||
}
|
||||
|
||||
/**
|
||||
* The CPU instructions that will be set - either 'avx512', 'avx2', 'avx', or 'noavx'.
|
||||
* @returns
|
||||
*/
|
||||
const cpuInstructions = () => {
|
||||
if (process.platform === 'darwin') return ''
|
||||
return cpuInfo.cpuInfo().some((e) => e.toUpperCase() === 'AVX512')
|
||||
? 'avx512'
|
||||
: cpuInfo.cpuInfo().some((e) => e.toUpperCase() === 'AVX2')
|
||||
? 'avx2'
|
||||
: cpuInfo.cpuInfo().some((e) => e.toUpperCase() === 'AVX')
|
||||
? 'avx'
|
||||
: 'noavx'
|
||||
}
|
||||
|
||||
/**
|
||||
* Find which executable file to run based on the current platform.
|
||||
* @returns The name of the executable file to run.
|
||||
@ -47,15 +84,26 @@ const cudaVersion = (settings?: GpuSetting): '11-7' | '12-0' | undefined => {
|
||||
export const executableNitroFile = (
|
||||
gpuSetting?: GpuSetting
|
||||
): NitroExecutableOptions => {
|
||||
let binaryFolder = [os(), runMode(gpuSetting), cudaVersion(gpuSetting)]
|
||||
let engineFolder = [
|
||||
os(),
|
||||
...(gpuSetting?.vulkan
|
||||
? []
|
||||
: [
|
||||
gpuRunMode(gpuSetting) !== 'cuda' ? cpuInstructions() : '',
|
||||
gpuRunMode(gpuSetting),
|
||||
cudaVersion(gpuSetting),
|
||||
]),
|
||||
gpuSetting?.vulkan ? 'vulkan' : undefined,
|
||||
]
|
||||
.filter((e) => !!e)
|
||||
.join('-')
|
||||
let cudaVisibleDevices = gpuSetting?.gpus_in_use.join(',') ?? ''
|
||||
let vkVisibleDevices = gpuSetting?.gpus_in_use.join(',') ?? ''
|
||||
let binaryName = `cortex-cpp${extension()}`
|
||||
let binaryName = `${process.platform === 'darwin' ? `${os()}/` : ''}cortex-cpp${extension()}`
|
||||
|
||||
return {
|
||||
executablePath: path.join(__dirname, '..', 'bin', binaryFolder, binaryName),
|
||||
enginePath: path.join(__dirname, '..', 'bin', engineFolder),
|
||||
executablePath: path.join(__dirname, '..', 'bin', binaryName),
|
||||
cudaVisibleDevices,
|
||||
vkVisibleDevices,
|
||||
}
|
||||
|
||||
465
extensions/inference-nitro-extension/src/node/index.test.ts
Normal file
465
extensions/inference-nitro-extension/src/node/index.test.ts
Normal file
@ -0,0 +1,465 @@
|
||||
jest.mock('fetch-retry', () => ({
|
||||
default: () => () => {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
model_loaded: true,
|
||||
}),
|
||||
text: () => Promise.resolve(''),
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('path', () => ({
|
||||
default: {
|
||||
isAbsolute: jest.fn(),
|
||||
join: jest.fn(),
|
||||
parse: () => {
|
||||
return { dir: 'dir' }
|
||||
},
|
||||
delimiter: { concat: () => '' },
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('decompress', () => ({
|
||||
default: () => {
|
||||
return Promise.resolve()
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@janhq/core/node', () => ({
|
||||
...jest.requireActual('@janhq/core/node'),
|
||||
getJanDataFolderPath: () => '',
|
||||
getSystemResourceInfo: () => {
|
||||
return {
|
||||
cpu: {
|
||||
cores: 1,
|
||||
logicalCores: 1,
|
||||
threads: 1,
|
||||
model: 'model',
|
||||
speed: 1,
|
||||
},
|
||||
memory: {
|
||||
total: 1,
|
||||
free: 1,
|
||||
},
|
||||
gpu: {
|
||||
model: 'model',
|
||||
memory: 1,
|
||||
cuda: {
|
||||
version: 'version',
|
||||
devices: 'devices',
|
||||
},
|
||||
vulkan: {
|
||||
version: 'version',
|
||||
devices: 'devices',
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('fs', () => ({
|
||||
default: {
|
||||
readdirSync: () => [],
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('child_process', () => ({
|
||||
exec: () => {
|
||||
return {
|
||||
stdout: { on: jest.fn() },
|
||||
stderr: { on: jest.fn() },
|
||||
on: jest.fn(),
|
||||
}
|
||||
},
|
||||
spawn: () => {
|
||||
return {
|
||||
stdout: { on: jest.fn() },
|
||||
stderr: { on: jest.fn() },
|
||||
on: jest.fn(),
|
||||
pid: '111',
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('tcp-port-used', () => ({
|
||||
default: {
|
||||
waitUntilFree: () => Promise.resolve(true),
|
||||
waitUntilUsed: () => Promise.resolve(true),
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('./execute', () => ({
|
||||
executableNitroFile: () => {
|
||||
return {
|
||||
enginePath: 'enginePath',
|
||||
executablePath: 'executablePath',
|
||||
cudaVisibleDevices: 'cudaVisibleDevices',
|
||||
vkVisibleDevices: 'vkVisibleDevices',
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('terminate', () => ({
|
||||
default: (id: String, func: Function) => {
|
||||
console.log(id)
|
||||
func()
|
||||
},
|
||||
}))
|
||||
|
||||
import * as execute from './execute'
|
||||
import index from './index'
|
||||
|
||||
let executeMock = execute
|
||||
|
||||
const modelInitOptions: any = {
|
||||
modelFolder: '/path/to/model',
|
||||
model: {
|
||||
id: 'test',
|
||||
name: 'test',
|
||||
engine: 'nitro',
|
||||
version: '0.0',
|
||||
format: 'GGUF',
|
||||
object: 'model',
|
||||
sources: [],
|
||||
created: 0,
|
||||
description: 'test',
|
||||
parameters: {},
|
||||
metadata: {
|
||||
author: '',
|
||||
tags: [],
|
||||
size: 0,
|
||||
},
|
||||
settings: {
|
||||
prompt_template: '{prompt}',
|
||||
llama_model_path: 'model.gguf',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
describe('loadModel', () => {
|
||||
it('should load a model successfully', async () => {
|
||||
// Mock the necessary parameters and system information
|
||||
|
||||
const systemInfo = {
|
||||
// Mock the system information if needed
|
||||
}
|
||||
|
||||
// Call the loadModel function
|
||||
const result = await index.loadModel(modelInitOptions, systemInfo)
|
||||
|
||||
// Assert that the result is as expected
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should reject with an error message if the model is not a nitro model', async () => {
|
||||
// Mock the necessary parameters and system information
|
||||
|
||||
const systemInfo = {
|
||||
// Mock the system information if needed
|
||||
}
|
||||
modelInitOptions.model.engine = 'not-nitro'
|
||||
// Call the loadModel function
|
||||
try {
|
||||
await index.loadModel(modelInitOptions, systemInfo)
|
||||
} catch (error) {
|
||||
// Assert that the error message is as expected
|
||||
expect(error).toBe('Not a cortex model')
|
||||
}
|
||||
modelInitOptions.model.engine = 'nitro'
|
||||
})
|
||||
|
||||
it('should reject if model load failed with an error message', async () => {
|
||||
// Mock the necessary parameters and system information
|
||||
|
||||
const systemInfo = {
|
||||
// Mock the system information if needed
|
||||
}
|
||||
// Mock the fetch-retry module to return a failed response
|
||||
jest.mock('fetch-retry', () => ({
|
||||
default: () => () => {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
model_loaded: false,
|
||||
}),
|
||||
text: () => Promise.resolve('Failed to load model'),
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
// Call the loadModel function
|
||||
try {
|
||||
await index.loadModel(modelInitOptions, systemInfo)
|
||||
} catch (error) {
|
||||
// Assert that the error message is as expected
|
||||
expect(error).toBe('Failed to load model')
|
||||
}
|
||||
})
|
||||
|
||||
it('should reject if port not available', async () => {
|
||||
// Mock the necessary parameters and system information
|
||||
|
||||
const systemInfo = {
|
||||
// Mock the system information if needed
|
||||
}
|
||||
|
||||
// Mock the tcp-port-used module to return false
|
||||
jest.mock('tcp-port-used', () => ({
|
||||
default: {
|
||||
waitUntilFree: () => Promise.resolve(false),
|
||||
waitUntilUsed: () => Promise.resolve(false),
|
||||
},
|
||||
}))
|
||||
|
||||
// Call the loadModel function
|
||||
try {
|
||||
await index.loadModel(modelInitOptions, systemInfo)
|
||||
} catch (error) {
|
||||
// Assert that the error message is as expected
|
||||
expect(error).toBe('Port not available')
|
||||
}
|
||||
})
|
||||
|
||||
it('should run on GPU model if ngl is set', async () => {
|
||||
const systemInfo: any = {
|
||||
gpuSetting: {
|
||||
run_mode: 'gpu',
|
||||
},
|
||||
}
|
||||
// Spy executableNitroFile
|
||||
jest.spyOn(executeMock, 'executableNitroFile').mockReturnValue({
|
||||
enginePath: '',
|
||||
executablePath: '',
|
||||
cudaVisibleDevices: '',
|
||||
vkVisibleDevices: '',
|
||||
})
|
||||
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
await index.loadModel(
|
||||
{
|
||||
...modelInitOptions,
|
||||
model: {
|
||||
...modelInitOptions.model,
|
||||
settings: {
|
||||
...modelInitOptions.model.settings,
|
||||
ngl: 40,
|
||||
},
|
||||
},
|
||||
},
|
||||
systemInfo
|
||||
)
|
||||
expect(executeMock.executableNitroFile).toHaveBeenCalledWith({
|
||||
run_mode: 'gpu',
|
||||
})
|
||||
})
|
||||
|
||||
it('should run on correct CPU instructions if ngl is not set', async () => {
|
||||
const systemInfo: any = {
|
||||
gpuSetting: {
|
||||
run_mode: 'gpu',
|
||||
},
|
||||
}
|
||||
// Spy executableNitroFile
|
||||
jest.spyOn(executeMock, 'executableNitroFile').mockReturnValue({
|
||||
enginePath: '',
|
||||
executablePath: '',
|
||||
cudaVisibleDevices: '',
|
||||
vkVisibleDevices: '',
|
||||
})
|
||||
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
await index.loadModel(
|
||||
{
|
||||
...modelInitOptions,
|
||||
model: {
|
||||
...modelInitOptions.model,
|
||||
settings: {
|
||||
...modelInitOptions.model.settings,
|
||||
ngl: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
systemInfo
|
||||
)
|
||||
expect(executeMock.executableNitroFile).toHaveBeenCalledWith({
|
||||
run_mode: 'cpu',
|
||||
})
|
||||
})
|
||||
|
||||
it('should run on correct CPU instructions if ngl is 0', async () => {
|
||||
const systemInfo: any = {
|
||||
gpuSetting: {
|
||||
run_mode: 'gpu',
|
||||
},
|
||||
}
|
||||
// Spy executableNitroFile
|
||||
jest.spyOn(executeMock, 'executableNitroFile').mockReturnValue({
|
||||
enginePath: '',
|
||||
executablePath: '',
|
||||
cudaVisibleDevices: '',
|
||||
vkVisibleDevices: '',
|
||||
})
|
||||
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
await index.loadModel(
|
||||
{
|
||||
...modelInitOptions,
|
||||
model: {
|
||||
...modelInitOptions.model,
|
||||
settings: {
|
||||
...modelInitOptions.model.settings,
|
||||
ngl: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
systemInfo
|
||||
)
|
||||
expect(executeMock.executableNitroFile).toHaveBeenCalledWith({
|
||||
run_mode: 'cpu',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('unloadModel', () => {
|
||||
it('should unload a model successfully', async () => {
|
||||
// Call the unloadModel function
|
||||
const result = await index.unloadModel()
|
||||
|
||||
// Assert that the result is as expected
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should reject with an error message if the model is not a nitro model', async () => {
|
||||
// Call the unloadModel function
|
||||
try {
|
||||
await index.unloadModel()
|
||||
} catch (error) {
|
||||
// Assert that the error message is as expected
|
||||
expect(error).toBe('Not a cortex model')
|
||||
}
|
||||
})
|
||||
|
||||
it('should reject if model unload failed with an error message', async () => {
|
||||
// Mock the fetch-retry module to return a failed response
|
||||
jest.mock('fetch-retry', () => ({
|
||||
default: () => () => {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
model_unloaded: false,
|
||||
}),
|
||||
text: () => Promise.resolve('Failed to unload model'),
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
// Call the unloadModel function
|
||||
try {
|
||||
await index.unloadModel()
|
||||
} catch (error) {
|
||||
// Assert that the error message is as expected
|
||||
expect(error).toBe('Failed to unload model')
|
||||
}
|
||||
})
|
||||
|
||||
it('should reject if port not available', async () => {
|
||||
// Mock the tcp-port-used module to return false
|
||||
jest.mock('tcp-port-used', () => ({
|
||||
default: {
|
||||
waitUntilFree: () => Promise.resolve(false),
|
||||
waitUntilUsed: () => Promise.resolve(false),
|
||||
},
|
||||
}))
|
||||
|
||||
// Call the unloadModel function
|
||||
try {
|
||||
await index.unloadModel()
|
||||
} catch (error) {
|
||||
// Assert that the error message is as expected
|
||||
expect(error).toBe('Port not available')
|
||||
}
|
||||
})
|
||||
})
|
||||
describe('dispose', () => {
|
||||
it('should dispose a model successfully on Mac', async () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'darwin',
|
||||
})
|
||||
|
||||
// Call the dispose function
|
||||
const result = await index.dispose()
|
||||
|
||||
// Assert that the result is as expected
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should kill the subprocess successfully on Windows', async () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'win32',
|
||||
})
|
||||
|
||||
// Call the killSubprocess function
|
||||
const result = await index.dispose()
|
||||
|
||||
// Assert that the result is as expected
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCurrentNitroProcessInfo', () => {
|
||||
it('should return the current nitro process info', async () => {
|
||||
// Call the getCurrentNitroProcessInfo function
|
||||
const result = await index.getCurrentNitroProcessInfo()
|
||||
|
||||
// Assert that the result is as expected
|
||||
expect(result).toEqual({
|
||||
isRunning: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('decompressRunner', () => {
|
||||
it('should decompress the runner successfully', async () => {
|
||||
jest.mock('decompress', () => ({
|
||||
default: () => {
|
||||
return Promise.resolve()
|
||||
},
|
||||
}))
|
||||
// Call the decompressRunner function
|
||||
const result = await index.decompressRunner('', '')
|
||||
|
||||
// Assert that the result is as expected
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
it('should not reject if decompression failed', async () => {
|
||||
jest.mock('decompress', () => ({
|
||||
default: () => {
|
||||
return Promise.reject('Failed to decompress')
|
||||
},
|
||||
}))
|
||||
// Call the decompressRunner function
|
||||
const result = await index.decompressRunner('', '')
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('addAdditionalDependencies', () => {
|
||||
it('should add additional dependencies successfully', async () => {
|
||||
// Call the addAdditionalDependencies function
|
||||
const result = await index.addAdditionalDependencies({
|
||||
name: 'name',
|
||||
version: 'version',
|
||||
})
|
||||
|
||||
// Assert that the result is as expected
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@ -263,10 +263,10 @@ async function validateModelStatus(modelId: string): Promise<void> {
|
||||
log(`[CORTEX]::Debug: Validating model ${modelId}`)
|
||||
return fetchRetry(NITRO_HTTP_VALIDATE_MODEL_URL, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
body: JSON.stringify({
|
||||
model: modelId,
|
||||
// TODO: force to use cortex llamacpp by default
|
||||
engine: 'cortex.llamacpp'
|
||||
engine: 'cortex.llamacpp',
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@ -365,14 +365,37 @@ function spawnNitroProcess(systemInfo?: SystemInformation): Promise<any> {
|
||||
log(`[CORTEX]::Debug: Spawning cortex subprocess...`)
|
||||
|
||||
return new Promise<void>(async (resolve, reject) => {
|
||||
let executableOptions = executableNitroFile(systemInfo?.gpuSetting)
|
||||
let executableOptions = executableNitroFile(
|
||||
// If ngl is not set or equal to 0, run on CPU with correct instructions
|
||||
systemInfo?.gpuSetting
|
||||
? {
|
||||
...systemInfo.gpuSetting,
|
||||
run_mode:
|
||||
currentSettings?.ngl === undefined || currentSettings.ngl === 0
|
||||
? 'cpu'
|
||||
: systemInfo.gpuSetting.run_mode,
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
|
||||
const args: string[] = ['1', LOCAL_HOST, PORT.toString()]
|
||||
// Execute the binary
|
||||
log(
|
||||
`[CORTEX]::Debug: Spawn cortex at path: ${executableOptions.executablePath}, and args: ${args}`
|
||||
)
|
||||
log(path.parse(executableOptions.executablePath).dir)
|
||||
log(`[CORTEX]::Debug: Cortex engine path: ${executableOptions.enginePath}`)
|
||||
|
||||
// Add engine path to the PATH and LD_LIBRARY_PATH
|
||||
process.env.PATH = (process.env.PATH || '').concat(
|
||||
path.delimiter,
|
||||
executableOptions.enginePath
|
||||
)
|
||||
log(`[CORTEX] PATH: ${process.env.PATH}`)
|
||||
process.env.LD_LIBRARY_PATH = (process.env.LD_LIBRARY_PATH || '').concat(
|
||||
path.delimiter,
|
||||
executableOptions.enginePath
|
||||
)
|
||||
|
||||
subprocess = spawn(
|
||||
executableOptions.executablePath,
|
||||
['1', LOCAL_HOST, PORT.toString()],
|
||||
@ -380,6 +403,7 @@ function spawnNitroProcess(systemInfo?: SystemInformation): Promise<any> {
|
||||
cwd: path.join(path.parse(executableOptions.executablePath).dir),
|
||||
env: {
|
||||
...process.env,
|
||||
ENGINE_PATH: executableOptions.enginePath,
|
||||
CUDA_VISIBLE_DEVICES: executableOptions.cudaVisibleDevices,
|
||||
// Vulkan - Support 1 device at a time for now
|
||||
...(executableOptions.vkVisibleDevices?.length > 0 && {
|
||||
@ -440,12 +464,19 @@ const getCurrentNitroProcessInfo = (): NitroProcessInfo => {
|
||||
}
|
||||
|
||||
const addAdditionalDependencies = (data: { name: string; version: string }) => {
|
||||
log(
|
||||
`[CORTEX]::Debug: Adding additional dependencies for ${data.name} ${data.version}`
|
||||
)
|
||||
const additionalPath = path.delimiter.concat(
|
||||
path.join(getJanDataFolderPath(), 'engines', data.name, data.version)
|
||||
)
|
||||
// Set the updated PATH
|
||||
process.env.PATH = (process.env.PATH || '').concat(additionalPath)
|
||||
process.env.PATH = (process.env.PATH || '').concat(
|
||||
path.delimiter,
|
||||
additionalPath
|
||||
)
|
||||
process.env.LD_LIBRARY_PATH = (process.env.LD_LIBRARY_PATH || '').concat(
|
||||
path.delimiter,
|
||||
additionalPath
|
||||
)
|
||||
}
|
||||
|
||||
@ -15,5 +15,6 @@
|
||||
"importHelpers": true,
|
||||
"typeRoots": ["node_modules/@types"]
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
|
||||
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",
|
||||
"scripts": {
|
||||
"dev": "rollup -c -w",
|
||||
"build": "rimraf ./dist && rollup -c"
|
||||
"build": "rimraf ./dist && rollup -c",
|
||||
"test": "jest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"class-variance-authority": "^0.7.0",
|
||||
@ -38,13 +39,23 @@
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"@types/jest": "^29.5.12",
|
||||
"autoprefixer": "10.4.16",
|
||||
"tailwindcss": "^3.4.1"
|
||||
"jest": "^29.7.0",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"ts-jest": "^29.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-node-resolve": "^15.2.3",
|
||||
"@rollup/plugin-terser": "^0.4.4",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.5.0",
|
||||
"@testing-library/react": "^16.0.1",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/jest": "^29.5.12",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-transform-css": "^6.0.1",
|
||||
"prettier": "^3.0.3",
|
||||
"prettier-plugin-tailwindcss": "^0.5.6",
|
||||
"rollup": "^4.12.0",
|
||||
|
||||
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'
|
||||
|
||||
const badgeVariants = cva('badge', {
|
||||
export const badgeConfig = {
|
||||
variants: {
|
||||
theme: {
|
||||
primary: 'badge--primary',
|
||||
@ -28,11 +28,13 @@ const badgeVariants = cva('badge', {
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
theme: 'primary',
|
||||
size: 'medium',
|
||||
variant: 'solid',
|
||||
theme: 'primary' as const,
|
||||
size: 'medium' as const,
|
||||
variant: 'solid' as const,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const badgeVariants = cva('badge', badgeConfig)
|
||||
|
||||
export interface BadgeProps
|
||||
extends HTMLAttributes<HTMLDivElement>,
|
||||
|
||||
90
joi/src/core/Button/Button.test.tsx
Normal file
90
joi/src/core/Button/Button.test.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
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('@joi/core/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('applies custom className', () => {
|
||||
render(<Button className="custom-class">Test Button</Button>)
|
||||
const badge = screen.getByText('Test Button')
|
||||
expect(badge).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
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('fails when a new theme is added without updating the test', () => {
|
||||
const expectedThemes = ['primary', 'ghost', 'icon', 'destructive']
|
||||
const actualThemes = Object.keys(buttonConfig.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(buttonConfig.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(buttonConfig.variants.size)
|
||||
expect(actualSizes).toEqual(expectedSizes)
|
||||
})
|
||||
|
||||
it('fails when a new variant CVA is added without updating the test', () => {
|
||||
const expectedVariantsCVA = ['theme', 'variant', 'size', 'block']
|
||||
const actualVariant = Object.keys(buttonConfig.variants)
|
||||
expect(actualVariant).toEqual(expectedVariantsCVA)
|
||||
})
|
||||
})
|
||||
@ -7,7 +7,7 @@ import { twMerge } from 'tailwind-merge'
|
||||
|
||||
import './styles.scss'
|
||||
|
||||
const buttonVariants = cva('btn', {
|
||||
export const buttonConfig = {
|
||||
variants: {
|
||||
theme: {
|
||||
primary: 'btn--primary',
|
||||
@ -30,12 +30,13 @@ const buttonVariants = cva('btn', {
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
theme: 'primary',
|
||||
size: 'medium',
|
||||
variant: 'solid',
|
||||
block: false,
|
||||
theme: 'primary' as const,
|
||||
size: 'medium' as const,
|
||||
variant: 'solid' as const,
|
||||
block: false as const,
|
||||
},
|
||||
})
|
||||
}
|
||||
const buttonVariants = cva('btn', buttonConfig)
|
||||
|
||||
export interface ButtonProps
|
||||
extends ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
|
||||
50
joi/src/core/Checkbox/Checkbox.test.tsx
Normal file
50
joi/src/core/Checkbox/Checkbox.test.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import React from 'react'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { Checkbox } from './index'
|
||||
|
||||
// Mock the styles
|
||||
jest.mock('./styles.scss', () => ({}))
|
||||
|
||||
describe('@joi/core/Checkbox', () => {
|
||||
it('renders correctly with label', () => {
|
||||
render(<Checkbox id="test-checkbox" label="Test Checkbox" />)
|
||||
expect(screen.getByLabelText('Test Checkbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with helper description', () => {
|
||||
render(<Checkbox id="test-checkbox" helperDescription="Helper text" />)
|
||||
expect(screen.getByText('Helper text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders error message when provided', () => {
|
||||
render(<Checkbox id="test-checkbox" errorMessage="Error occurred" />)
|
||||
expect(screen.getByText('Error occurred')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onChange when clicked', () => {
|
||||
const mockOnChange = jest.fn()
|
||||
render(
|
||||
<Checkbox
|
||||
id="test-checkbox"
|
||||
label="Test Checkbox"
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Test Checkbox'))
|
||||
expect(mockOnChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('applies custom className', () => {
|
||||
render(<Checkbox id="test-checkbox" className="custom-class" />)
|
||||
expect(screen.getByRole('checkbox').parentElement).toHaveClass(
|
||||
'custom-class'
|
||||
)
|
||||
})
|
||||
|
||||
it('disables the checkbox when disabled prop is true', () => {
|
||||
render(<Checkbox id="test-checkbox" label="Disabled Checkbox" disabled />)
|
||||
expect(screen.getByLabelText('Disabled Checkbox')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
53
joi/src/core/Input/Input.test.tsx
Normal file
53
joi/src/core/Input/Input.test.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import React from 'react'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { Input } from './index'
|
||||
|
||||
// Mock the styles import
|
||||
jest.mock('./styles.scss', () => ({}))
|
||||
|
||||
describe('@joi/core/Input', () => {
|
||||
it('renders correctly', () => {
|
||||
render(<Input placeholder="Test input" />)
|
||||
expect(screen.getByPlaceholderText('Test input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies custom className', () => {
|
||||
render(<Input className="custom-class" />)
|
||||
expect(screen.getByRole('textbox')).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('aligns text to the right when textAlign prop is set', () => {
|
||||
render(<Input textAlign="right" />)
|
||||
expect(screen.getByRole('textbox')).toHaveClass('text-right')
|
||||
})
|
||||
|
||||
it('renders prefix icon when provided', () => {
|
||||
render(<Input prefixIcon={<span data-testid="prefix-icon">Prefix</span>} />)
|
||||
expect(screen.getByTestId('prefix-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders suffix icon when provided', () => {
|
||||
render(<Input suffixIcon={<span data-testid="suffix-icon">Suffix</span>} />)
|
||||
expect(screen.getByTestId('suffix-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders clear icon when clearable is true', () => {
|
||||
render(<Input clearable />)
|
||||
expect(screen.getByTestId('cross-2-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onClick when input is clicked', () => {
|
||||
const onClick = jest.fn()
|
||||
render(<Input onClick={onClick} />)
|
||||
fireEvent.click(screen.getByRole('textbox'))
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls onClear when clear icon is clicked', () => {
|
||||
const onClear = jest.fn()
|
||||
render(<Input clearable onClear={onClear} />)
|
||||
fireEvent.click(screen.getByTestId('cross-2-icon'))
|
||||
expect(onClear).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -42,7 +42,7 @@ const Input = forwardRef<HTMLInputElement, Props>(
|
||||
)}
|
||||
{clearable && (
|
||||
<div className="input__clear-icon" onClick={onClear}>
|
||||
<Cross2Icon className="text-red-200" />
|
||||
<Cross2Icon data-testid="cross-2-icon" className="text-red-200" />
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
|
||||
78
joi/src/core/Modal/Modal.test.tsx
Normal file
78
joi/src/core/Modal/Modal.test.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import React from 'react'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { Modal } from './index'
|
||||
|
||||
// Mock the styles
|
||||
jest.mock('./styles.scss', () => ({}))
|
||||
|
||||
describe('Modal', () => {
|
||||
it('renders the modal with trigger and content', () => {
|
||||
render(
|
||||
<Modal
|
||||
trigger={<button>Open Modal</button>}
|
||||
content={<div>Modal Content</div>}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Open Modal')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('Open Modal'))
|
||||
expect(screen.getByText('Modal Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the modal with title', () => {
|
||||
render(
|
||||
<Modal
|
||||
trigger={<button>Open Modal</button>}
|
||||
content={<div>Modal Content</div>}
|
||||
title="Modal Title"
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Open Modal'))
|
||||
expect(screen.getByText('Modal Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders full page modal', () => {
|
||||
render(
|
||||
<Modal
|
||||
trigger={<button>Open Modal</button>}
|
||||
content={<div>Modal Content</div>}
|
||||
fullPage
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Open Modal'))
|
||||
expect(screen.getByRole('dialog')).toHaveClass('modal__content--fullpage')
|
||||
})
|
||||
|
||||
it('hides close button when hideClose is true', () => {
|
||||
render(
|
||||
<Modal
|
||||
trigger={<button>Open Modal</button>}
|
||||
content={<div>Modal Content</div>}
|
||||
hideClose
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Open Modal'))
|
||||
expect(screen.queryByLabelText('Close')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onOpenChange when opening and closing the modal', () => {
|
||||
const onOpenChangeMock = jest.fn()
|
||||
render(
|
||||
<Modal
|
||||
trigger={<button>Open Modal</button>}
|
||||
content={<div>Modal Content</div>}
|
||||
onOpenChange={onOpenChangeMock}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Open Modal'))
|
||||
expect(onOpenChangeMock).toHaveBeenCalledWith(true)
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Close'))
|
||||
expect(onOpenChangeMock).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
55
joi/src/core/Progress/Progress.test.tsx
Normal file
55
joi/src/core/Progress/Progress.test.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { Progress } from './index'
|
||||
|
||||
// Mock the styles
|
||||
jest.mock('./styles.scss', () => ({}))
|
||||
|
||||
describe('@joi/core/Progress', () => {
|
||||
it('renders with default props', () => {
|
||||
render(<Progress value={50} />)
|
||||
const progressElement = screen.getByRole('progressbar')
|
||||
expect(progressElement).toBeInTheDocument()
|
||||
expect(progressElement).toHaveClass('progress')
|
||||
expect(progressElement).toHaveClass('progress--medium')
|
||||
expect(progressElement).toHaveAttribute('aria-valuenow', '50')
|
||||
})
|
||||
|
||||
it('applies custom className', () => {
|
||||
render(<Progress value={50} className="custom-class" />)
|
||||
const progressElement = screen.getByRole('progressbar')
|
||||
expect(progressElement).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('renders with different sizes', () => {
|
||||
const { rerender } = render(<Progress value={50} size="small" />)
|
||||
let progressElement = screen.getByRole('progressbar')
|
||||
expect(progressElement).toHaveClass('progress--small')
|
||||
|
||||
rerender(<Progress value={50} size="large" />)
|
||||
progressElement = screen.getByRole('progressbar')
|
||||
expect(progressElement).toHaveClass('progress--large')
|
||||
})
|
||||
|
||||
it('sets the correct transform style based on value', () => {
|
||||
render(<Progress value={75} />)
|
||||
const progressElement = screen.getByRole('progressbar')
|
||||
const indicatorElement = progressElement.firstChild as HTMLElement
|
||||
expect(indicatorElement).toHaveStyle('transform: translateX(-25%)')
|
||||
})
|
||||
|
||||
it('handles edge cases for value', () => {
|
||||
const { rerender } = render(<Progress value={0} />)
|
||||
let progressElement = screen.getByRole('progressbar')
|
||||
let indicatorElement = progressElement.firstChild as HTMLElement
|
||||
expect(indicatorElement).toHaveStyle('transform: translateX(-100%)')
|
||||
expect(progressElement).toHaveAttribute('aria-valuenow', '0')
|
||||
|
||||
rerender(<Progress value={100} />)
|
||||
progressElement = screen.getByRole('progressbar')
|
||||
indicatorElement = progressElement.firstChild as HTMLElement
|
||||
expect(indicatorElement).toHaveStyle('transform: translateX(-0%)')
|
||||
expect(progressElement).toHaveAttribute('aria-valuenow', '100')
|
||||
})
|
||||
})
|
||||
@ -27,7 +27,14 @@ export interface ProgressProps
|
||||
|
||||
const Progress = ({ className, size, value, ...props }: ProgressProps) => {
|
||||
return (
|
||||
<div className={twMerge(progressVariants({ size, className }))} {...props}>
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuenow={value}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
className={twMerge(progressVariants({ size, className }))}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
className="progress--indicator"
|
||||
|
||||
47
joi/src/core/ScrollArea/ScrollArea.test.tsx
Normal file
47
joi/src/core/ScrollArea/ScrollArea.test.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { ScrollArea } from './index'
|
||||
|
||||
declare const global: typeof globalThis
|
||||
|
||||
// Mock the styles
|
||||
jest.mock('./styles.scss', () => ({}))
|
||||
|
||||
class ResizeObserverMock {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
global.ResizeObserver = ResizeObserverMock
|
||||
|
||||
describe('@joi/core/ScrollArea', () => {
|
||||
it('renders children correctly', () => {
|
||||
render(
|
||||
<ScrollArea>
|
||||
<div data-testid="child">Test Content</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
|
||||
const child = screen.getByTestId('child')
|
||||
expect(child).toBeInTheDocument()
|
||||
expect(child).toHaveTextContent('Test Content')
|
||||
})
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(<ScrollArea className="custom-class" />)
|
||||
|
||||
const root = container.firstChild as HTMLElement
|
||||
expect(root).toHaveClass('scroll-area__root')
|
||||
expect(root).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('forwards ref to the Viewport component', () => {
|
||||
const ref = React.createRef<HTMLDivElement>()
|
||||
render(<ScrollArea ref={ref} />)
|
||||
|
||||
expect(ref.current).toBeInstanceOf(HTMLDivElement)
|
||||
expect(ref.current).toHaveClass('scroll-area__viewport')
|
||||
})
|
||||
})
|
||||
107
joi/src/core/Select/Select.test.tsx
Normal file
107
joi/src/core/Select/Select.test.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Select } from './index'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
// Mock the styles
|
||||
jest.mock('./styles.scss', () => ({}))
|
||||
|
||||
jest.mock('tailwind-merge', () => ({
|
||||
twMerge: (...classes: string[]) => classes.filter(Boolean).join(' '),
|
||||
}))
|
||||
|
||||
const mockOnValueChange = jest.fn()
|
||||
jest.mock('@radix-ui/react-select', () => ({
|
||||
Root: ({
|
||||
children,
|
||||
onValueChange,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onValueChange?: (value: string) => void
|
||||
}) => {
|
||||
mockOnValueChange.mockImplementation(onValueChange)
|
||||
return <div data-testid="select-root">{children}</div>
|
||||
},
|
||||
Trigger: ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) => (
|
||||
<button data-testid="select-trigger" className={className}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Value: ({ placeholder }: { placeholder?: string }) => (
|
||||
<span data-testid="select-value">{placeholder}</span>
|
||||
),
|
||||
Icon: ({ children }: { children: React.ReactNode }) => (
|
||||
<span data-testid="select-icon">{children}</span>
|
||||
),
|
||||
Portal: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="select-portal">{children}</div>
|
||||
),
|
||||
Content: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="select-content">{children}</div>
|
||||
),
|
||||
Viewport: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="select-viewport">{children}</div>
|
||||
),
|
||||
Item: ({ children, value }: { children: React.ReactNode; value: string }) => (
|
||||
<div
|
||||
data-testid={`select-item-${value}`}
|
||||
onClick={() => mockOnValueChange(value)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
ItemText: ({ children }: { children: React.ReactNode }) => (
|
||||
<span data-testid="select-item-text">{children}</span>
|
||||
),
|
||||
ItemIndicator: ({ children }: { children: React.ReactNode }) => (
|
||||
<span data-testid="select-item-indicator">{children}</span>
|
||||
),
|
||||
Arrow: () => <div data-testid="select-arrow" />,
|
||||
}))
|
||||
describe('@joi/core/Select', () => {
|
||||
const options = [
|
||||
{ name: 'Option 1', value: 'option1' },
|
||||
{ name: 'Option 2', value: 'option2' },
|
||||
]
|
||||
|
||||
it('renders with placeholder', () => {
|
||||
render(<Select placeholder="Select an option" options={options} />)
|
||||
expect(screen.getByTestId('select-value')).toHaveTextContent(
|
||||
'Select an option'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders options', () => {
|
||||
render(<Select options={options} />)
|
||||
expect(screen.getByTestId('select-item-option1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('select-item-option2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onValueChange when an option is selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onValueChange = jest.fn()
|
||||
render(<Select options={options} onValueChange={onValueChange} />)
|
||||
|
||||
await user.click(screen.getByTestId('select-trigger'))
|
||||
await user.click(screen.getByTestId('select-item-option1'))
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledWith('option1')
|
||||
})
|
||||
|
||||
it('applies disabled class when disabled prop is true', () => {
|
||||
render(<Select options={options} disabled />)
|
||||
expect(screen.getByTestId('select-trigger')).toHaveClass('select__disabled')
|
||||
})
|
||||
|
||||
it('applies block class when block prop is true', () => {
|
||||
render(<Select options={options} block />)
|
||||
expect(screen.getByTestId('select-trigger')).toHaveClass('w-full')
|
||||
})
|
||||
})
|
||||
65
joi/src/core/Slider/Slider.test.tsx
Normal file
65
joi/src/core/Slider/Slider.test.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import React from 'react'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { Slider } from './index'
|
||||
|
||||
// Mock the styles
|
||||
jest.mock('./styles.scss', () => ({}))
|
||||
|
||||
// Mock Radix UI Slider
|
||||
jest.mock('@radix-ui/react-slider', () => ({
|
||||
Root: ({ children, onValueChange, ...props }: any) => (
|
||||
<div
|
||||
data-testid="slider-root"
|
||||
{...props}
|
||||
onChange={(e: any) =>
|
||||
onValueChange && onValueChange([parseInt(e.target.value)])
|
||||
}
|
||||
>
|
||||
<input type="range" {...props} />
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Track: ({ children }: any) => (
|
||||
<div data-testid="slider-track">{children}</div>
|
||||
),
|
||||
Range: () => <div data-testid="slider-range" />,
|
||||
Thumb: () => <div data-testid="slider-thumb" />,
|
||||
}))
|
||||
|
||||
describe('@joi/core/Slider', () => {
|
||||
it('renders correctly with default props', () => {
|
||||
render(<Slider />)
|
||||
expect(screen.getByTestId('slider-root')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('slider-track')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('slider-range')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('slider-thumb')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('passes props correctly to SliderPrimitive.Root', () => {
|
||||
const props = {
|
||||
name: 'test-slider',
|
||||
min: 0,
|
||||
max: 100,
|
||||
value: [50],
|
||||
step: 1,
|
||||
disabled: true,
|
||||
}
|
||||
render(<Slider {...props} />)
|
||||
const sliderRoot = screen.getByTestId('slider-root')
|
||||
expect(sliderRoot).toHaveAttribute('name', 'test-slider')
|
||||
expect(sliderRoot).toHaveAttribute('min', '0')
|
||||
expect(sliderRoot).toHaveAttribute('max', '100')
|
||||
expect(sliderRoot).toHaveAttribute('value', '50')
|
||||
expect(sliderRoot).toHaveAttribute('step', '1')
|
||||
expect(sliderRoot).toHaveAttribute('disabled', '')
|
||||
})
|
||||
|
||||
it('calls onValueChange when value changes', () => {
|
||||
const onValueChange = jest.fn()
|
||||
render(<Slider onValueChange={onValueChange} min={0} max={100} step={1} />)
|
||||
const input = screen.getByTestId('slider-root').querySelector('input')
|
||||
fireEvent.change(input!, { target: { value: '75' } })
|
||||
expect(onValueChange).toHaveBeenCalledWith([75])
|
||||
})
|
||||
})
|
||||
52
joi/src/core/Switch/Switch.test.tsx
Normal file
52
joi/src/core/Switch/Switch.test.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import React from 'react'
|
||||
import { render, fireEvent } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { Switch } from './index'
|
||||
|
||||
// Mock the styles
|
||||
jest.mock('./styles.scss', () => ({}))
|
||||
|
||||
describe('@joi/core/Switch', () => {
|
||||
it('renders correctly', () => {
|
||||
const { getByRole } = render(<Switch />)
|
||||
const checkbox = getByRole('checkbox')
|
||||
expect(checkbox).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(<Switch className="custom-class" />)
|
||||
expect(container.firstChild).toHaveClass('switch custom-class')
|
||||
})
|
||||
|
||||
it('can be checked and unchecked', () => {
|
||||
const { getByRole } = render(<Switch />)
|
||||
const checkbox = getByRole('checkbox') as HTMLInputElement
|
||||
|
||||
expect(checkbox.checked).toBe(false)
|
||||
fireEvent.click(checkbox)
|
||||
expect(checkbox.checked).toBe(true)
|
||||
fireEvent.click(checkbox)
|
||||
expect(checkbox.checked).toBe(false)
|
||||
})
|
||||
|
||||
it('can be disabled', () => {
|
||||
const { getByRole } = render(<Switch disabled />)
|
||||
const checkbox = getByRole('checkbox') as HTMLInputElement
|
||||
expect(checkbox).toBeDisabled()
|
||||
})
|
||||
|
||||
it('calls onChange when clicked', () => {
|
||||
const handleChange = jest.fn()
|
||||
const { getByRole } = render(<Switch onChange={handleChange} />)
|
||||
const checkbox = getByRole('checkbox')
|
||||
|
||||
fireEvent.click(checkbox)
|
||||
expect(handleChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('can have a default checked state', () => {
|
||||
const { getByRole } = render(<Switch defaultChecked />)
|
||||
const checkbox = getByRole('checkbox') as HTMLInputElement
|
||||
expect(checkbox.checked).toBe(true)
|
||||
})
|
||||
})
|
||||
99
joi/src/core/Tabs/Tabs.test.tsx
Normal file
99
joi/src/core/Tabs/Tabs.test.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import React from 'react'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { Tabs, TabsContent } from './index'
|
||||
|
||||
// Mock the Tooltip component
|
||||
jest.mock('../Tooltip', () => ({
|
||||
Tooltip: ({ children, content, trigger }) => (
|
||||
<div data-testid="mock-tooltip" data-tooltip-content={content}>
|
||||
{trigger || children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock the styles
|
||||
jest.mock('./styles.scss', () => ({}))
|
||||
|
||||
describe('@joi/core/Tabs', () => {
|
||||
const mockOptions = [
|
||||
{ name: 'Tab 1', value: 'tab1' },
|
||||
{ name: 'Tab 2', value: 'tab2' },
|
||||
{
|
||||
name: 'Tab 3',
|
||||
value: 'tab3',
|
||||
disabled: true,
|
||||
tooltipContent: 'Disabled tab',
|
||||
},
|
||||
]
|
||||
|
||||
it('renders tabs correctly', () => {
|
||||
render(
|
||||
<Tabs options={mockOptions} value="tab1" onValueChange={() => {}}>
|
||||
<TabsContent value="tab1">Content 1</TabsContent>
|
||||
<TabsContent value="tab2">Content 2</TabsContent>
|
||||
<TabsContent value="tab3">Content 3</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Tab 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Tab 2')).toBeInTheDocument()
|
||||
expect(screen.getByText('Tab 3')).toBeInTheDocument()
|
||||
expect(screen.getByText('Content 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('changes tab content when clicked', () => {
|
||||
const { rerender } = render(
|
||||
<Tabs options={mockOptions} value="tab1" onValueChange={() => {}}>
|
||||
<TabsContent value="tab1">Content 1</TabsContent>
|
||||
<TabsContent value="tab2">Content 2</TabsContent>
|
||||
<TabsContent value="tab3">Content 3</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Content 1')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Content 2')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Tab 2'))
|
||||
|
||||
// Rerender with the new value to simulate the state change
|
||||
rerender(
|
||||
<Tabs options={mockOptions} value="tab2" onValueChange={() => {}}>
|
||||
<TabsContent value="tab1">Content 1</TabsContent>
|
||||
<TabsContent value="tab2">Content 2</TabsContent>
|
||||
<TabsContent value="tab3">Content 3</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
|
||||
expect(screen.queryByText('Content 1')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Content 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables tab when specified', () => {
|
||||
render(
|
||||
<Tabs options={mockOptions} value="tab1" onValueChange={() => {}}>
|
||||
<TabsContent value="tab1">Content 1</TabsContent>
|
||||
<TabsContent value="tab2">Content 2</TabsContent>
|
||||
<TabsContent value="tab3">Content 3</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Tab 3')).toHaveAttribute('disabled')
|
||||
})
|
||||
|
||||
it('renders tooltip for disabled tab', () => {
|
||||
render(
|
||||
<Tabs options={mockOptions} value="tab1" onValueChange={() => {}}>
|
||||
<TabsContent value="tab1">Content 1</TabsContent>
|
||||
<TabsContent value="tab2">Content 2</TabsContent>
|
||||
<TabsContent value="tab3">Content 3</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
|
||||
const tooltipWrapper = screen.getByTestId('mock-tooltip')
|
||||
expect(tooltipWrapper).toHaveAttribute(
|
||||
'data-tooltip-content',
|
||||
'Disabled tab'
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -2,10 +2,18 @@ import React, { ReactNode } from 'react'
|
||||
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs'
|
||||
|
||||
import { Tooltip } from '../Tooltip'
|
||||
|
||||
import './styles.scss'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
type TabsProps = {
|
||||
options: { name: string; value: string }[]
|
||||
options: {
|
||||
name: string
|
||||
value: string
|
||||
disabled?: boolean
|
||||
tooltipContent?: string
|
||||
}[]
|
||||
children: ReactNode
|
||||
defaultValue?: string
|
||||
value: string
|
||||
@ -15,11 +23,15 @@ type TabsProps = {
|
||||
type TabsContentProps = {
|
||||
value: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
const TabsContent = ({ value, children }: TabsContentProps) => {
|
||||
const TabsContent = ({ value, children, className }: TabsContentProps) => {
|
||||
return (
|
||||
<TabsPrimitive.Content className="tabs__content" value={value}>
|
||||
<TabsPrimitive.Content
|
||||
className={twMerge('tabs__content', className)}
|
||||
value={value}
|
||||
>
|
||||
{children}
|
||||
</TabsPrimitive.Content>
|
||||
)
|
||||
@ -40,11 +52,27 @@ const Tabs = ({
|
||||
>
|
||||
<TabsPrimitive.List className="tabs__list">
|
||||
{options.map((option, i) => {
|
||||
return (
|
||||
return option.disabled ? (
|
||||
<Tooltip
|
||||
key={i}
|
||||
content={option.tooltipContent}
|
||||
trigger={
|
||||
<TabsPrimitive.Trigger
|
||||
key={i}
|
||||
className="tabs__trigger"
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{option.name}
|
||||
</TabsPrimitive.Trigger>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<TabsPrimitive.Trigger
|
||||
key={i}
|
||||
className="tabs__trigger"
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{option.name}
|
||||
</TabsPrimitive.Trigger>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user