Merge pull request #3664 from janhq/dev

release: Jan Release cut v0.5.4
This commit is contained in:
Louis 2024-09-16 13:50:28 +07:00 committed by GitHub
commit 6e0c582f1e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
141 changed files with 4701 additions and 891 deletions

View File

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

View File

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

View File

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

View File

@ -1,17 +0,0 @@
---
name: "📖 Documentation request"
about: Documentation requests
title: 'docs: TITLE'
labels: 'type: documentation'
assignees: ''
---
**Pages**
- Page(s) that need to be done
**Success Criteria**
Content that should be covered
**Additional context**
Examples, reference pages, resources

View File

@ -1,25 +0,0 @@
---
name: "💥 Epic request"
about: Suggest an idea for this project
title: 'epic: [DESCRIPTION]'
labels: 'type: epic'
assignees: ''
---
## Motivation
-
## Specs
-
## Designs
[Figma](link)
## Tasklist
- [ ]
## Not in Scope
-
## Appendix

View File

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

View 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)

View File

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

View File

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

View File

@ -37,6 +37,33 @@ on:
- '!README.md'
jobs:
base_branch_cov:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v3
with:
ref: ${{ github.base_ref }}
- name: Use Node.js v20.9.0
uses: actions/setup-node@v3
with:
node-version: v20.9.0
- name: Install dependencies
run: |
yarn
yarn build:core
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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -46,6 +46,8 @@
"eslint": "8.57.0",
"eslint-plugin-jest": "^27.9.0",
"jest": "^29.7.0",
"jest-junit": "^16.0.0",
"jest-runner": "^29.7.0",
"rimraf": "^3.0.2",
"rollup": "^2.38.5",
"rollup-plugin-commonjs": "^9.1.8",
@ -53,7 +55,7 @@
"rollup-plugin-node-resolve": "^5.2.0",
"rollup-plugin-sourcemaps": "^0.6.3",
"rollup-plugin-typescript2": "^0.36.0",
"ts-jest": "^29.1.2",
"ts-jest": "^29.2.5",
"tslib": "^2.6.2",
"typescript": "^5.3.3"
},

View File

@ -0,0 +1,98 @@
import { openExternalUrl } from './core';
import { joinPath } from './core';
import { openFileExplorer } from './core';
import { getJanDataFolderPath } from './core';
import { abortDownload } from './core';
import { getFileSize } from './core';
import { executeOnMain } from './core';
it('should open external url', async () => {
const url = 'http://example.com';
globalThis.core = {
api: {
openExternalUrl: jest.fn().mockResolvedValue('opened')
}
};
const result = await openExternalUrl(url);
expect(globalThis.core.api.openExternalUrl).toHaveBeenCalledWith(url);
expect(result).toBe('opened');
});
it('should join paths', async () => {
const paths = ['/path/one', '/path/two'];
globalThis.core = {
api: {
joinPath: jest.fn().mockResolvedValue('/path/one/path/two')
}
};
const result = await joinPath(paths);
expect(globalThis.core.api.joinPath).toHaveBeenCalledWith(paths);
expect(result).toBe('/path/one/path/two');
});
it('should open file explorer', async () => {
const path = '/path/to/open';
globalThis.core = {
api: {
openFileExplorer: jest.fn().mockResolvedValue('opened')
}
};
const result = await openFileExplorer(path);
expect(globalThis.core.api.openFileExplorer).toHaveBeenCalledWith(path);
expect(result).toBe('opened');
});
it('should get jan data folder path', async () => {
globalThis.core = {
api: {
getJanDataFolderPath: jest.fn().mockResolvedValue('/path/to/jan/data')
}
};
const result = await getJanDataFolderPath();
expect(globalThis.core.api.getJanDataFolderPath).toHaveBeenCalled();
expect(result).toBe('/path/to/jan/data');
});
it('should abort download', async () => {
const fileName = 'testFile';
globalThis.core = {
api: {
abortDownload: jest.fn().mockResolvedValue('aborted')
}
};
const result = await abortDownload(fileName);
expect(globalThis.core.api.abortDownload).toHaveBeenCalledWith(fileName);
expect(result).toBe('aborted');
});
it('should get file size', async () => {
const url = 'http://example.com/file';
globalThis.core = {
api: {
getFileSize: jest.fn().mockResolvedValue(1024)
}
};
const result = await getFileSize(url);
expect(globalThis.core.api.getFileSize).toHaveBeenCalledWith(url);
expect(result).toBe(1024);
});
it('should execute function on main process', async () => {
const extension = 'testExtension';
const method = 'testMethod';
const args = ['arg1', 'arg2'];
globalThis.core = {
api: {
invokeExtensionFunc: jest.fn().mockResolvedValue('result')
}
};
const result = await executeOnMain(extension, method, ...args);
expect(globalThis.core.api.invokeExtensionFunc).toHaveBeenCalledWith(extension, method, ...args);
expect(result).toBe('result');
});

View File

@ -0,0 +1,37 @@
import { events } from './events';
import { jest } from '@jest/globals';
it('should emit an event', () => {
const mockObject = { key: 'value' };
globalThis.core = {
events: {
emit: jest.fn()
}
};
events.emit('testEvent', mockObject);
expect(globalThis.core.events.emit).toHaveBeenCalledWith('testEvent', mockObject);
});
it('should remove an observer for an event', () => {
const mockHandler = jest.fn();
globalThis.core = {
events: {
off: jest.fn()
}
};
events.off('testEvent', mockHandler);
expect(globalThis.core.events.off).toHaveBeenCalledWith('testEvent', mockHandler);
});
it('should add an observer for an event', () => {
const mockHandler = jest.fn();
globalThis.core = {
events: {
on: jest.fn()
}
};
events.on('testEvent', mockHandler);
expect(globalThis.core.events.on).toHaveBeenCalledWith('testEvent', mockHandler);
});

View File

@ -0,0 +1,46 @@
import { BaseExtension } from './extension'
class TestBaseExtension extends BaseExtension {
onLoad(): void {}
onUnload(): void {}
}
describe('BaseExtension', () => {
let baseExtension: TestBaseExtension
beforeEach(() => {
baseExtension = new TestBaseExtension('https://example.com', 'TestExtension')
})
afterEach(() => {
jest.resetAllMocks()
})
it('should have the correct properties', () => {
expect(baseExtension.name).toBe('TestExtension')
expect(baseExtension.productName).toBeUndefined()
expect(baseExtension.url).toBe('https://example.com')
expect(baseExtension.active).toBeUndefined()
expect(baseExtension.description).toBeUndefined()
expect(baseExtension.version).toBeUndefined()
})
it('should return undefined for type()', () => {
expect(baseExtension.type()).toBeUndefined()
})
it('should have abstract methods onLoad() and onUnload()', () => {
expect(baseExtension.onLoad).toBeDefined()
expect(baseExtension.onUnload).toBeDefined()
})
it('should have installationState() return "NotRequired"', async () => {
const installationState = await baseExtension.installationState()
expect(installationState).toBe('NotRequired')
})
it('should install the extension', async () => {
await baseExtension.install()
// Add your assertions here
})
})

View File

@ -0,0 +1,60 @@
import { lastValueFrom, Observable } from 'rxjs'
import { requestInference } from './sse'
describe('requestInference', () => {
it('should send a request to the inference server and return an Observable', () => {
// Mock the fetch function
const mockFetch: any = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ choices: [{ message: { content: 'Generated response' } }] }),
headers: new Headers(),
redirected: false,
status: 200,
statusText: 'OK',
// Add other required properties here
})
)
jest.spyOn(global, 'fetch').mockImplementation(mockFetch)
// Define the test inputs
const inferenceUrl = 'https://inference-server.com'
const requestBody = { message: 'Hello' }
const model = { id: 'model-id', parameters: { stream: false } }
// Call the function
const result = requestInference(inferenceUrl, requestBody, model)
// Assert the expected behavior
expect(result).toBeInstanceOf(Observable)
expect(lastValueFrom(result)).resolves.toEqual('Generated response')
})
it('returns 401 error', () => {
// Mock the fetch function
const mockFetch: any = jest.fn(() =>
Promise.resolve({
ok: false,
json: () => Promise.resolve({ error: { message: 'Wrong API Key', code: 'invalid_api_key' } }),
headers: new Headers(),
redirected: false,
status: 401,
statusText: 'invalid_api_key',
// Add other required properties here
})
)
jest.spyOn(global, 'fetch').mockImplementation(mockFetch)
// Define the test inputs
const inferenceUrl = 'https://inference-server.com'
const requestBody = { message: 'Hello' }
const model = { id: 'model-id', parameters: { stream: false } }
// Call the function
const result = requestInference(inferenceUrl, requestBody, model)
// Assert the expected behavior
expect(result).toBeInstanceOf(Observable)
expect(lastValueFrom(result)).rejects.toEqual({ message: 'Wrong API Key', code: 'invalid_api_key' })
})
})

View File

@ -0,0 +1,32 @@
import * as Core from './core';
import * as Events from './events';
import * as FileSystem from './fs';
import * as Extension from './extension';
import * as Extensions from './extensions';
import * as Tools from './tools';
describe('Module Tests', () => {
it('should export Core module', () => {
expect(Core).toBeDefined();
});
it('should export Event module', () => {
expect(Events).toBeDefined();
});
it('should export Filesystem module', () => {
expect(FileSystem).toBeDefined();
});
it('should export Extension module', () => {
expect(Extension).toBeDefined();
});
it('should export all base extensions', () => {
expect(Extensions).toBeDefined();
});
it('should export all base tools', () => {
expect(Tools).toBeDefined();
});
});

View File

@ -0,0 +1,10 @@
import { RequestAdapter } from './adapter';
it('should return undefined for unknown route', () => {
const adapter = new RequestAdapter();
const route = 'unknownRoute';
const result = adapter.process(route, 'arg1', 'arg2');
expect(result).toBeUndefined();
});

View File

@ -0,0 +1,25 @@
import { CoreRoutes } from '../../../types/api';
import { RequestHandler } from './handler';
import { RequestAdapter } from './adapter';
it('should not call handler if CoreRoutes is empty', () => {
const mockHandler = jest.fn();
const mockObserver = jest.fn();
const requestHandler = new RequestHandler(mockHandler, mockObserver);
CoreRoutes.length = 0; // Ensure CoreRoutes is empty
requestHandler.handle();
expect(mockHandler).not.toHaveBeenCalled();
});
it('should initialize handler and adapter correctly', () => {
const mockHandler = jest.fn();
const mockObserver = jest.fn();
const requestHandler = new RequestHandler(mockHandler, mockObserver);
expect(requestHandler.handler).toBe(mockHandler);
expect(requestHandler.adapter).toBeInstanceOf(RequestAdapter);
});

View File

@ -0,0 +1,40 @@
import { App } from './app';
it('should call stopServer', () => {
const app = new App();
const stopServerMock = jest.fn().mockResolvedValue('Server stopped');
jest.mock('@janhq/server', () => ({
stopServer: stopServerMock
}));
const result = app.stopServer();
expect(stopServerMock).toHaveBeenCalled();
});
it('should correctly retrieve basename', () => {
const app = new App();
const result = app.baseName('/path/to/file.txt');
expect(result).toBe('file.txt');
});
it('should correctly identify subdirectories', () => {
const app = new App();
const basePath = process.platform === 'win32' ? 'C:\\path\\to' : '/path/to';
const subPath = process.platform === 'win32' ? 'C:\\path\\to\\subdir' : '/path/to/subdir';
const result = app.isSubdirectory(basePath, subPath);
expect(result).toBe(true);
});
it('should correctly join multiple paths', () => {
const app = new App();
const result = app.joinPath(['path', 'to', 'file']);
const expectedPath = process.platform === 'win32' ? 'path\\to\\file' : 'path/to/file';
expect(result).toBe(expectedPath);
});
it('should call correct function with provided arguments using process method', () => {
const app = new App();
const mockFunc = jest.fn();
app.joinPath = mockFunc;
app.process('joinPath', ['path1', 'path2']);
expect(mockFunc).toHaveBeenCalledWith(['path1', 'path2']);
});

View File

@ -0,0 +1,59 @@
import { Downloader } from './download';
import { DownloadEvent } from '../../../types/api';
import { DownloadManager } from '../../helper/download';
it('should handle getFileSize errors correctly', async () => {
const observer = jest.fn();
const url = 'http://example.com/file';
const downloader = new Downloader(observer);
const requestMock = jest.fn((options, callback) => {
callback(new Error('Test error'), null);
});
jest.mock('request', () => requestMock);
await expect(downloader.getFileSize(observer, url)).rejects.toThrow('Test error');
});
it('should pause download correctly', () => {
const observer = jest.fn();
const fileName = process.platform === 'win32' ? 'C:\\path\\to\\file' : 'path/to/file';
const downloader = new Downloader(observer);
const pauseMock = jest.fn();
DownloadManager.instance.networkRequests[fileName] = { pause: pauseMock };
downloader.pauseDownload(observer, fileName);
expect(pauseMock).toHaveBeenCalled();
});
it('should resume download correctly', () => {
const observer = jest.fn();
const fileName = process.platform === 'win32' ? 'C:\\path\\to\\file' : 'path/to/file';
const downloader = new Downloader(observer);
const resumeMock = jest.fn();
DownloadManager.instance.networkRequests[fileName] = { resume: resumeMock };
downloader.resumeDownload(observer, fileName);
expect(resumeMock).toHaveBeenCalled();
});
it('should handle aborting a download correctly', () => {
const observer = jest.fn();
const fileName = process.platform === 'win32' ? 'C:\\path\\to\\file' : 'path/to/file';
const downloader = new Downloader(observer);
const abortMock = jest.fn();
DownloadManager.instance.networkRequests[fileName] = { abort: abortMock };
downloader.abortDownload(observer, fileName);
expect(abortMock).toHaveBeenCalled();
expect(observer).toHaveBeenCalledWith(DownloadEvent.onFileDownloadError, expect.objectContaining({
error: 'aborted'
}));
});

View File

@ -0,0 +1,9 @@
import { Extension } from './extension';
it('should call function associated with key in process method', () => {
const mockFunc = jest.fn();
const extension = new Extension();
(extension as any).testKey = mockFunc;
extension.process('testKey', 'arg1', 'arg2');
expect(mockFunc).toHaveBeenCalledWith('arg1', 'arg2');
});

View File

@ -0,0 +1,18 @@
import { FileSystem } from './fs';
it('should throw an error when the route does not exist in process', async () => {
const fileSystem = new FileSystem();
await expect(fileSystem.process('nonExistentRoute', 'arg1')).rejects.toThrow();
});
it('should throw an error for invalid argument in mkdir', async () => {
const fileSystem = new FileSystem();
expect(() => fileSystem.mkdir(123)).toThrow('mkdir error: Invalid argument [123]');
});
it('should throw an error for invalid argument in rm', async () => {
const fileSystem = new FileSystem();
expect(() => fileSystem.rm(123)).toThrow('rm error: Invalid argument [123]');
});

View File

@ -0,0 +1,34 @@
import { FSExt } from './fsExt';
import { defaultAppConfig } from '../../helper';
it('should handle errors in writeBlob', () => {
const fsExt = new FSExt();
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
fsExt.writeBlob('invalid-path', 'data');
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
it('should call correct function in process method', () => {
const fsExt = new FSExt();
const mockFunction = jest.fn();
(fsExt as any).mockFunction = mockFunction;
fsExt.process('mockFunction', 'arg1', 'arg2');
expect(mockFunction).toHaveBeenCalledWith('arg1', 'arg2');
});
it('should return correct user home path', () => {
const fsExt = new FSExt();
const userHomePath = fsExt.getUserHomePath();
expect(userHomePath).toBe(defaultAppConfig().data_folder);
});
it('should return empty array when no files are provided', async () => {
const fsExt = new FSExt();
const result = await fsExt.getGgufFiles([]);
expect(result.supportedFiles).toEqual([]);
expect(result.unsupportedFiles).toEqual([]);
});

View File

@ -0,0 +1,62 @@
import { HttpServer } from '../../HttpServer'
import { DownloadManager } from '../../../helper/download'
describe('downloadRouter', () => {
let app: HttpServer
beforeEach(() => {
app = {
register: jest.fn(),
post: jest.fn(),
get: jest.fn(),
patch: jest.fn(),
put: jest.fn(),
delete: jest.fn(),
}
})
it('should return download progress for a given modelId', async () => {
const modelId = '123'
const downloadProgress = { progress: 50 }
DownloadManager.instance.downloadProgressMap[modelId] = downloadProgress as any
const req = { params: { modelId } }
const res = {
status: jest.fn(),
send: jest.fn(),
}
jest.spyOn(app, 'get').mockImplementation((path, handler) => {
if (path === `/download/getDownloadProgress/${modelId}`) {
res.status(200)
res.send(downloadProgress)
}
})
app.get(`/download/getDownloadProgress/${modelId}`, req as any)
expect(res.status).toHaveBeenCalledWith(200)
expect(res.send).toHaveBeenCalledWith(downloadProgress)
})
it('should return 404 if download progress is not found', async () => {
const modelId = '123'
const req = { params: { modelId } }
const res = {
status: jest.fn(),
send: jest.fn(),
}
jest.spyOn(app, 'get').mockImplementation((path, handler) => {
if (path === `/download/getDownloadProgress/${modelId}`) {
res.status(404)
res.send({ message: 'Download progress not found' })
}
})
app.get(`/download/getDownloadProgress/${modelId}`, req as any)
expect(res.status).toHaveBeenCalledWith(404)
expect(res.send).toHaveBeenCalledWith({ message: 'Download progress not found' })
})
})

View File

@ -0,0 +1,16 @@
//
import { jest } from '@jest/globals';
import { HttpServer } from '../../HttpServer';
import { handleRequests } from './handlers';
import { Handler, RequestHandler } from '../../common/handler';
it('should initialize RequestHandler and call handle', () => {
const mockHandle = jest.fn();
jest.spyOn(RequestHandler.prototype, 'handle').mockImplementation(mockHandle);
const mockApp = { post: jest.fn() };
handleRequests(mockApp as unknown as HttpServer);
expect(mockHandle).toHaveBeenCalled();
});

View File

@ -0,0 +1,21 @@
import { commonRouter } from './common';
import { JanApiRouteConfiguration } from './helper/configuration';
test('commonRouter sets up routes for each key in JanApiRouteConfiguration', async () => {
const mockHttpServer = {
get: jest.fn(),
post: jest.fn(),
patch: jest.fn(),
put: jest.fn(),
delete: jest.fn(),
};
await commonRouter(mockHttpServer as any);
const expectedRoutes = Object.keys(JanApiRouteConfiguration);
expectedRoutes.forEach((key) => {
expect(mockHttpServer.get).toHaveBeenCalledWith(`/${key}`, expect.any(Function));
expect(mockHttpServer.get).toHaveBeenCalledWith(`/${key}/:id`, expect.any(Function));
expect(mockHttpServer.delete).toHaveBeenCalledWith(`/${key}/:id`, expect.any(Function));
});
});

View File

@ -0,0 +1,24 @@
import { JanApiRouteConfiguration } from './configuration'
describe('JanApiRouteConfiguration', () => {
it('should have the correct models configuration', () => {
const modelsConfig = JanApiRouteConfiguration.models;
expect(modelsConfig.dirName).toBe('models');
expect(modelsConfig.metadataFileName).toBe('model.json');
expect(modelsConfig.delete.object).toBe('model');
});
it('should have the correct assistants configuration', () => {
const assistantsConfig = JanApiRouteConfiguration.assistants;
expect(assistantsConfig.dirName).toBe('assistants');
expect(assistantsConfig.metadataFileName).toBe('assistant.json');
expect(assistantsConfig.delete.object).toBe('assistant');
});
it('should have the correct threads configuration', () => {
const threadsConfig = JanApiRouteConfiguration.threads;
expect(threadsConfig.dirName).toBe('threads');
expect(threadsConfig.metadataFileName).toBe('thread.json');
expect(threadsConfig.delete.object).toBe('thread');
});
});

View File

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

View File

@ -0,0 +1,16 @@
import { v1Router } from './v1';
import { commonRouter } from './common';
test('should define v1Router function', () => {
expect(v1Router).toBeDefined();
});
test('should register commonRouter', () => {
const mockApp = {
register: jest.fn(),
};
v1Router(mockApp as any);
expect(mockApp.register).toHaveBeenCalledWith(commonRouter);
});

View File

@ -0,0 +1,122 @@
import Extension from './extension';
import { join } from 'path';
import 'pacote';
it('should set active and call emitUpdate', () => {
const extension = new Extension();
extension.emitUpdate = jest.fn();
extension.setActive(true);
expect(extension._active).toBe(true);
expect(extension.emitUpdate).toHaveBeenCalled();
});
it('should return correct specifier', () => {
const origin = 'test-origin';
const options = { version: '1.0.0' };
const extension = new Extension(origin, options);
expect(extension.specifier).toBe('test-origin@1.0.0');
});
it('should set origin and installOptions in constructor', () => {
const origin = 'test-origin';
const options = { someOption: true };
const extension = new Extension(origin, options);
expect(extension.origin).toBe(origin);
expect(extension.installOptions.someOption).toBe(true);
expect(extension.installOptions.fullMetadata).toBe(true); // default option
});
it('should install extension and set url', async () => {
const origin = 'test-origin';
const options = {};
const extension = new Extension(origin, options);
const mockManifest = {
name: 'test-name',
productName: 'Test Product',
version: '1.0.0',
main: 'index.js',
description: 'Test description'
};
jest.mock('pacote', () => ({
manifest: jest.fn().mockResolvedValue(mockManifest),
extract: jest.fn().mockResolvedValue(null)
}));
extension.emitUpdate = jest.fn();
await extension._install();
expect(extension.url).toBe('extension://test-name/index.js');
expect(extension.emitUpdate).toHaveBeenCalled();
});
it('should call all listeners in emitUpdate', () => {
const extension = new Extension();
const callback1 = jest.fn();
const callback2 = jest.fn();
extension.subscribe('listener1', callback1);
extension.subscribe('listener2', callback2);
extension.emitUpdate();
expect(callback1).toHaveBeenCalledWith(extension);
expect(callback2).toHaveBeenCalledWith(extension);
});
it('should remove listener in unsubscribe', () => {
const extension = new Extension();
const callback = jest.fn();
extension.subscribe('testListener', callback);
extension.unsubscribe('testListener');
expect(extension.listeners['testListener']).toBeUndefined();
});
it('should add listener in subscribe', () => {
const extension = new Extension();
const callback = jest.fn();
extension.subscribe('testListener', callback);
expect(extension.listeners['testListener']).toBe(callback);
});
it('should set properties from manifest', async () => {
const origin = 'test-origin';
const options = {};
const extension = new Extension(origin, options);
const mockManifest = {
name: 'test-name',
productName: 'Test Product',
version: '1.0.0',
main: 'index.js',
description: 'Test description'
};
jest.mock('pacote', () => ({
manifest: jest.fn().mockResolvedValue(mockManifest)
}));
await extension.getManifest();
expect(extension.name).toBe('test-name');
expect(extension.productName).toBe('Test Product');
expect(extension.version).toBe('1.0.0');
expect(extension.main).toBe('index.js');
expect(extension.description).toBe('Test description');
});

View File

@ -0,0 +1,28 @@
import * as fs from 'fs';
import { join } from 'path';
import { ExtensionManager } from './manager';
it('should throw an error when an invalid path is provided', () => {
const manager = new ExtensionManager();
jest.spyOn(fs, 'existsSync').mockReturnValue(false);
expect(() => manager.setExtensionsPath('')).toThrow('Invalid path provided to the extensions folder');
});
it('should return an empty string when extensionsPath is not set', () => {
const manager = new ExtensionManager();
expect(manager.getExtensionsFile()).toBe(join('', 'extensions.json'));
});
it('should return undefined if no path is set', () => {
const manager = new ExtensionManager();
expect(manager.getExtensionsPath()).toBeUndefined();
});
it('should return the singleton instance', () => {
const instance1 = new ExtensionManager();
const instance2 = new ExtensionManager();
expect(instance1).toBe(instance2);
});

View File

@ -0,0 +1,43 @@
import { getAllExtensions } from './store';
import { getActiveExtensions } from './store';
import { getExtension } from './store';
test('should return empty array when no extensions added', () => {
expect(getAllExtensions()).toEqual([]);
});
test('should throw error when extension does not exist', () => {
expect(() => getExtension('nonExistentExtension')).toThrow('Extension nonExistentExtension does not exist');
});
import { addExtension } from './store';
import Extension from './extension';
test('should return all extensions when multiple extensions added', () => {
const ext1 = new Extension('ext1');
ext1.name = 'ext1';
const ext2 = new Extension('ext2');
ext2.name = 'ext2';
addExtension(ext1, false);
addExtension(ext2, false);
expect(getAllExtensions()).toEqual([ext1, ext2]);
});
test('should return only active extensions', () => {
const ext1 = new Extension('ext1');
ext1.name = 'ext1';
ext1.setActive(true);
const ext2 = new Extension('ext2');
ext2.name = 'ext2';
ext2.setActive(false);
addExtension(ext1, false);
addExtension(ext2, false);
expect(getActiveExtensions()).toEqual([ext1]);
});

View File

@ -0,0 +1,14 @@
import { getEngineConfiguration } from './config';
import { getAppConfigurations, defaultAppConfig } from './config';
it('should return undefined for invalid engine ID', async () => {
const config = await getEngineConfiguration('invalid_engine');
expect(config).toBeUndefined();
});
it('should return default config when CI is e2e', () => {
process.env.CI = 'e2e';
const config = getAppConfigurations();
expect(config).toEqual(defaultAppConfig());
});

View File

@ -0,0 +1,11 @@
import { DownloadManager } from './download';
it('should set a network request for a specific file', () => {
const downloadManager = new DownloadManager();
const fileName = 'testFile';
const request = { url: 'http://example.com' };
downloadManager.setRequest(fileName, request);
expect(downloadManager.networkRequests[fileName]).toEqual(request);
});

View File

@ -0,0 +1,47 @@
import { Logger, LoggerManager } from './logger';
it('should flush queued logs to registered loggers', () => {
class TestLogger extends Logger {
name = 'testLogger';
log(args: any): void {
console.log(args);
}
}
const loggerManager = new LoggerManager();
const testLogger = new TestLogger();
loggerManager.register(testLogger);
const logSpy = jest.spyOn(testLogger, 'log');
loggerManager.log('test log');
expect(logSpy).toHaveBeenCalledWith('test log');
});
it('should unregister a logger', () => {
class TestLogger extends Logger {
name = 'testLogger';
log(args: any): void {
console.log(args);
}
}
const loggerManager = new LoggerManager();
const testLogger = new TestLogger();
loggerManager.register(testLogger);
loggerManager.unregister('testLogger');
const retrievedLogger = loggerManager.get('testLogger');
expect(retrievedLogger).toBeUndefined();
});
it('should register and retrieve a logger', () => {
class TestLogger extends Logger {
name = 'testLogger';
log(args: any): void {
console.log(args);
}
}
const loggerManager = new LoggerManager();
const testLogger = new TestLogger();
loggerManager.register(testLogger);
const retrievedLogger = loggerManager.get('testLogger');
expect(retrievedLogger).toBe(testLogger);
});

View File

@ -0,0 +1,23 @@
import { ModuleManager } from './module';
it('should clear all imported modules', () => {
const moduleManager = new ModuleManager();
moduleManager.setModule('module1', { key: 'value1' });
moduleManager.setModule('module2', { key: 'value2' });
moduleManager.clearImportedModules();
expect(moduleManager.requiredModules).toEqual({});
});
it('should set a module correctly', () => {
const moduleManager = new ModuleManager();
moduleManager.setModule('testModule', { key: 'value' });
expect(moduleManager.requiredModules['testModule']).toEqual({ key: 'value' });
});
it('should return the singleton instance', () => {
const instance1 = new ModuleManager();
const instance2 = new ModuleManager();
expect(instance1).toBe(instance2);
});

View File

@ -0,0 +1,29 @@
import { normalizeFilePath } from './path'
import { jest } from '@jest/globals'
describe('Test file normalize', () => {
test('returns no file protocol prefix on Unix', async () => {
expect(normalizeFilePath('file://test.txt')).toBe('test.txt')
expect(normalizeFilePath('file:/test.txt')).toBe('test.txt')
})
test('returns no file protocol prefix on Windows', async () => {
expect(normalizeFilePath('file:\\\\test.txt')).toBe('test.txt')
expect(normalizeFilePath('file:\\test.txt')).toBe('test.txt')
})
test('returns correct path when Electron is available and app is not packaged', () => {
const electronMock = {
app: {
getAppPath: jest.fn().mockReturnValue('/mocked/path'),
isPackaged: false,
},
protocol: {},
}
jest.mock('electron', () => electronMock)
const { appResourcePath } = require('./path')
const expectedPath = process.platform === 'win32' ? '\\mocked\\path' : '/mocked/path'
expect(appResourcePath()).toBe(expectedPath)
})
})

View File

@ -0,0 +1,15 @@
import { getSystemResourceInfo } from './resource';
it('should return the correct system resource information with a valid CPU count', async () => {
const mockCpuCount = 4;
jest.spyOn(require('./config'), 'physicalCpuCount').mockResolvedValue(mockCpuCount);
const logSpy = jest.spyOn(require('./logger'), 'log').mockImplementation(() => {});
const result = await getSystemResourceInfo();
expect(result).toEqual({
numCpuPhysicalCore: mockCpuCount,
memAvailable: 0,
});
expect(logSpy).toHaveBeenCalledWith(`[CORTEX]::CPU information - ${mockCpuCount}`);
});

View File

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

View File

@ -0,0 +1,7 @@
import { AssistantEvent } from './assistantEvent';
it('dummy test', () => { expect(true).toBe(true); });
it('should contain OnAssistantsUpdate event', () => {
expect(AssistantEvent.OnAssistantsUpdate).toBe('OnAssistantsUpdate');
});

View File

@ -16,7 +16,7 @@ export type DownloadState = {
error?: string
extensionId?: string
downloadType?: DownloadType
downloadType?: DownloadType | string
localPath?: string
}
@ -40,7 +40,7 @@ export type DownloadRequest = {
*/
extensionId?: string
downloadType?: DownloadType
downloadType?: DownloadType | string
}
type DownloadTime = {

10
core/testRunner.js Normal file
View File

@ -0,0 +1,10 @@
const jestRunner = require('jest-runner');
class EmptyTestFileRunner extends jestRunner.default {
async runTests(tests, watcher, onStart, onResult, onFailure, options) {
const nonEmptyTests = tests.filter(test => test.context.hasteFS.getSize(test.path) > 0);
return super.runTests(nonEmptyTests, watcher, onStart, onResult, onFailure, options);
}
}
module.exports = EmptyTestFileRunner;

View File

@ -1,12 +0,0 @@
import { normalizeFilePath } from "../../src/node/helper/path";
describe("Test file normalize", () => {
test("returns no file protocol prefix on Unix", async () => {
expect(normalizeFilePath("file://test.txt")).toBe("test.txt");
expect(normalizeFilePath("file:/test.txt")).toBe("test.txt");
});
test("returns no file protocol prefix on Windows", async () => {
expect(normalizeFilePath("file:\\\\test.txt")).toBe("test.txt");
expect(normalizeFilePath("file:\\test.txt")).toBe("test.txt");
});
});

View File

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

View File

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

View File

@ -6,9 +6,10 @@ export const mainWindowConfig: Electron.BrowserWindowConstructorOptions = {
minWidth: DEFAULT_MIN_WIDTH,
minHeight: DEFAULT_MIN_HEIGHT,
show: true,
transparent: true,
frame: false,
titleBarStyle: 'hidden',
// we want to go frameless on windows and linux
transparent: process.platform === 'darwin',
frame: process.platform === 'darwin',
titleBarStyle: 'hiddenInset',
vibrancy: 'fullscreen-ui',
visualEffectState: 'active',
backgroundMaterial: 'acrylic',

View File

@ -166,6 +166,15 @@ class WindowManager {
}, 500)
}
/**
* Send main view state to the main app.
*/
sendMainViewState(route: string) {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send(AppEvent.onMainViewStateChange, route)
}
}
cleanUp(): void {
if (!this.mainWindow?.isDestroyed()) {
this.mainWindow?.close()

View File

@ -3,6 +3,7 @@ import { app, Menu, shell, dialog } from 'electron'
import { autoUpdater } from 'electron-updater'
import { log } from '@janhq/core/node'
const isMac = process.platform === 'darwin'
import { windowManager } from '../managers/window'
const template: (Electron.MenuItemConstructorOptions | Electron.MenuItem)[] = [
{
@ -43,6 +44,14 @@ const template: (Electron.MenuItemConstructorOptions | Electron.MenuItem)[] = [
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{
label: `Settings`,
accelerator: 'CmdOrCtrl+,',
click: () => {
windowManager.showMainWindow()
windowManager.sendMainViewState('Settings')
},
},
{ type: 'separator' },
{ role: 'quit' },
],

View File

@ -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": {

View File

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

View 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

View File

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

View File

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

View File

@ -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": {

View File

@ -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": {

View File

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

View File

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

View File

@ -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": {

View File

@ -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": {

View File

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

View File

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

View File

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

View File

@ -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": {

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@ -15,5 +15,6 @@
"importHelpers": true,
"typeRoots": ["node_modules/@types"]
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}

3
jest.config.js Normal file
View File

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

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

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

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

View File

@ -21,7 +21,8 @@
"bugs": "https://github.com/codecentrum/piksel/issues",
"scripts": {
"dev": "rollup -c -w",
"build": "rimraf ./dist && rollup -c"
"build": "rimraf ./dist && rollup -c",
"test": "jest"
},
"peerDependencies": {
"class-variance-authority": "^0.7.0",
@ -38,13 +39,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",

View File

@ -0,0 +1,64 @@
import React from 'react'
import '@testing-library/jest-dom'
import { render, screen, fireEvent } from '@testing-library/react'
import { Accordion, AccordionItem } from './index'
// Mock the SCSS import
jest.mock('./styles.scss', () => ({}))
describe('Accordion', () => {
it('renders accordion with items', () => {
render(
<Accordion defaultValue={['item1']}>
<AccordionItem value="item1" title="Item 1">
Content 1
</AccordionItem>
<AccordionItem value="item2" title="Item 2">
Content 2
</AccordionItem>
</Accordion>
)
expect(screen.getByText('Item 1')).toBeInTheDocument()
expect(screen.getByText('Item 2')).toBeInTheDocument()
})
it('expands and collapses accordion items', () => {
render(
<Accordion defaultValue={[]}>
<AccordionItem value="item1" title="Item 1">
Content 1
</AccordionItem>
</Accordion>
)
const trigger = screen.getByText('Item 1')
// Initially, content should not be visible
expect(screen.queryByText('Content 1')).not.toBeInTheDocument()
// Click to expand
fireEvent.click(trigger)
expect(screen.getByText('Content 1')).toBeInTheDocument()
// Click to collapse
fireEvent.click(trigger)
expect(screen.queryByText('Content 1')).not.toBeInTheDocument()
})
it('respects defaultValue prop', () => {
render(
<Accordion defaultValue={['item2']}>
<AccordionItem value="item1" title="Item 1">
Content 1
</AccordionItem>
<AccordionItem value="item2" title="Item 2">
Content 2
</AccordionItem>
</Accordion>
)
expect(screen.queryByText('Content 1')).not.toBeInTheDocument()
expect(screen.getByText('Content 2')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,83 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { Badge, badgeConfig } from './index'
// Mock the styles
jest.mock('./styles.scss', () => ({}))
describe('@joi/core/Badge', () => {
it('renders with default props', () => {
render(<Badge>Test Badge</Badge>)
const badge = screen.getByText('Test Badge')
expect(badge).toBeInTheDocument()
expect(badge).toHaveClass('badge')
expect(badge).toHaveClass('badge--primary')
expect(badge).toHaveClass('badge--medium')
expect(badge).toHaveClass('badge--solid')
})
it('applies custom className', () => {
render(<Badge className="custom-class">Test Badge</Badge>)
const badge = screen.getByText('Test Badge')
expect(badge).toHaveClass('custom-class')
})
it('renders with different themes', () => {
const themes = Object.keys(badgeConfig.variants.theme)
themes.forEach((theme) => {
render(<Badge theme={theme as any}>Test Badge {theme}</Badge>)
const badge = screen.getByText(`Test Badge ${theme}`)
expect(badge).toHaveClass(`badge--${theme}`)
})
})
it('renders with different variants', () => {
const variants = Object.keys(badgeConfig.variants.variant)
variants.forEach((variant) => {
render(<Badge variant={variant as any}>Test Badge {variant}</Badge>)
const badge = screen.getByText(`Test Badge ${variant}`)
expect(badge).toHaveClass(`badge--${variant}`)
})
})
it('renders with different sizes', () => {
const sizes = Object.keys(badgeConfig.variants.size)
sizes.forEach((size) => {
render(<Badge size={size as any}>Test Badge {size}</Badge>)
const badge = screen.getByText(`Test Badge ${size}`)
expect(badge).toHaveClass(`badge--${size}`)
})
})
it('fails when a new theme is added without updating the test', () => {
const expectedThemes = [
'primary',
'secondary',
'warning',
'success',
'info',
'destructive',
]
const actualThemes = Object.keys(badgeConfig.variants.theme)
expect(actualThemes).toEqual(expectedThemes)
})
it('fails when a new variant is added without updating the test', () => {
const expectedVariant = ['solid', 'soft', 'outline']
const actualVariants = Object.keys(badgeConfig.variants.variant)
expect(actualVariants).toEqual(expectedVariant)
})
it('fails when a new size is added without updating the test', () => {
const expectedSizes = ['small', 'medium', 'large']
const actualSizes = Object.keys(badgeConfig.variants.size)
expect(actualSizes).toEqual(expectedSizes)
})
it('fails when a new variant CVA is added without updating the test', () => {
const expectedVariantsCVA = ['theme', 'variant', 'size']
const actualVariant = Object.keys(badgeConfig.variants)
expect(actualVariant).toEqual(expectedVariantsCVA)
})
})

View File

@ -6,7 +6,7 @@ import { twMerge } from 'tailwind-merge'
import './styles.scss'
const badgeVariants = cva('badge', {
export const badgeConfig = {
variants: {
theme: {
primary: 'badge--primary',
@ -28,11 +28,13 @@ const badgeVariants = cva('badge', {
},
},
defaultVariants: {
theme: 'primary',
size: 'medium',
variant: 'solid',
theme: 'primary' as const,
size: 'medium' as const,
variant: 'solid' as const,
},
})
}
const badgeVariants = cva('badge', badgeConfig)
export interface BadgeProps
extends HTMLAttributes<HTMLDivElement>,

View File

@ -0,0 +1,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)
})
})

View File

@ -7,7 +7,7 @@ import { twMerge } from 'tailwind-merge'
import './styles.scss'
const buttonVariants = cva('btn', {
export const buttonConfig = {
variants: {
theme: {
primary: 'btn--primary',
@ -30,12 +30,13 @@ const buttonVariants = cva('btn', {
},
},
defaultVariants: {
theme: 'primary',
size: 'medium',
variant: 'solid',
block: false,
theme: 'primary' as const,
size: 'medium' as const,
variant: 'solid' as const,
block: false as const,
},
})
}
const buttonVariants = cva('btn', buttonConfig)
export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,

View File

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

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

View File

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

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

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

View File

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

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

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

View 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])
})
})

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

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

View File

@ -2,10 +2,18 @@ import React, { ReactNode } from 'react'
import * as TabsPrimitive from '@radix-ui/react-tabs'
import { Tooltip } from '../Tooltip'
import './styles.scss'
import { twMerge } from 'tailwind-merge'
type TabsProps = {
options: { name: string; value: string }[]
options: {
name: string
value: string
disabled?: boolean
tooltipContent?: string
}[]
children: ReactNode
defaultValue?: string
value: string
@ -15,11 +23,15 @@ type TabsProps = {
type TabsContentProps = {
value: string
children: ReactNode
className?: string
}
const TabsContent = ({ value, children }: TabsContentProps) => {
const TabsContent = ({ value, children, className }: TabsContentProps) => {
return (
<TabsPrimitive.Content className="tabs__content" value={value}>
<TabsPrimitive.Content
className={twMerge('tabs__content', className)}
value={value}
>
{children}
</TabsPrimitive.Content>
)
@ -40,11 +52,27 @@ const Tabs = ({
>
<TabsPrimitive.List className="tabs__list">
{options.map((option, i) => {
return (
return option.disabled ? (
<Tooltip
key={i}
content={option.tooltipContent}
trigger={
<TabsPrimitive.Trigger
key={i}
className="tabs__trigger"
value={option.value}
disabled={option.disabled}
>
{option.name}
</TabsPrimitive.Trigger>
}
/>
) : (
<TabsPrimitive.Trigger
key={i}
className="tabs__trigger"
value={option.value}
disabled={option.disabled}
>
{option.name}
</TabsPrimitive.Trigger>

Some files were not shown because too many files have changed in this diff Show More