diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 982e9f412..000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -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. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 419643e51..be0f34319 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -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] \ No newline at end of file + 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 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index e0c100daf..a4372656c 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -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!" \ No newline at end of file + about: "Get help, discuss features & roadmap, and share your projects" \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/documentation-request.md b/.github/ISSUE_TEMPLATE/documentation-request.md deleted file mode 100644 index 4d4dcdb0e..000000000 --- a/.github/ISSUE_TEMPLATE/documentation-request.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -name: "📖 Documentation request" -about: Documentation requests -title: 'docs: TITLE' -labels: 'type: documentation' -assignees: '' - ---- - -**Pages** -- Page(s) that need to be done - -**Success Criteria** -Content that should be covered - -**Additional context** -Examples, reference pages, resources diff --git a/.github/ISSUE_TEMPLATE/epic-request.md b/.github/ISSUE_TEMPLATE/epic-request.md deleted file mode 100644 index f86f379fa..000000000 --- a/.github/ISSUE_TEMPLATE/epic-request.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -name: "💥 Epic request" -about: Suggest an idea for this project -title: 'epic: [DESCRIPTION]' -labels: 'type: epic' -assignees: '' - ---- - -## Motivation -- - -## Specs -- - -## Designs -[Figma](link) - -## Tasklist -- [ ] - -## Not in Scope -- - -## Appendix diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 0f7f59f6c..b1a10e856 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -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!" \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/model_request.yml b/.github/ISSUE_TEMPLATE/model_request.yml new file mode 100644 index 000000000..7f7c4f63c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/model_request.yml @@ -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) diff --git a/.github/workflows/jan-electron-build-nightly.yml b/.github/workflows/jan-electron-build-nightly.yml index d666bdc56..af1a2baa9 100644 --- a/.github/workflows/jan-electron-build-nightly.yml +++ b/.github/workflows/jan-electron-build-nightly.yml @@ -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 diff --git a/.github/workflows/jan-electron-build.yml b/.github/workflows/jan-electron-build.yml index ab90f696f..095c0ed20 100644 --- a/.github/workflows/jan-electron-build.yml +++ b/.github/workflows/jan-electron-build.yml @@ -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 diff --git a/.github/workflows/jan-electron-linter-and-test.yml b/.github/workflows/jan-electron-linter-and-test.yml index 3a95e804e..f441f4cda 100644 --- a/.github/workflows/jan-electron-linter-and-test.yml +++ b/.github/workflows/jan-electron-linter-and-test.yml @@ -37,6 +37,33 @@ on: - '!README.md' jobs: + base_branch_cov: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v3 + with: + ref: ${{ github.base_ref }} + - name: Use Node.js v20.9.0 + uses: actions/setup-node@v3 + with: + node-version: v20.9.0 + + - name: Install dependencies + run: | + yarn + yarn build:core + 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 diff --git a/.github/workflows/nightly-integrate-cortex-cpp.yml b/.github/workflows/nightly-integrate-cortex-cpp.yml index e0b48bc46..8ddc40a11 100644 --- a/.github/workflows/nightly-integrate-cortex-cpp.yml +++ b/.github/workflows/nightly-integrate-cortex-cpp.yml @@ -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 diff --git a/.github/workflows/template-build-linux-x64.yml b/.github/workflows/template-build-linux-x64.yml index e64b23f40..386a0ee4f 100644 --- a/.github/workflows/template-build-linux-x64.yml +++ b/.github/workflows/template-build-linux-x64.yml @@ -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 diff --git a/.github/workflows/template-build-macos-arm64.yml b/.github/workflows/template-build-macos-arm64.yml index 753ad6793..40d40164f 100644 --- a/.github/workflows/template-build-macos-arm64.yml +++ b/.github/workflows/template-build-macos-arm64.yml @@ -148,13 +148,13 @@ jobs: - name: Upload Artifact if: inputs.public_provider != 'github' - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: jan-mac-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 \ No newline at end of file diff --git a/.github/workflows/template-build-macos-x64.yml b/.github/workflows/template-build-macos-x64.yml index 769e0f808..b7103fd06 100644 --- a/.github/workflows/template-build-macos-x64.yml +++ b/.github/workflows/template-build-macos-x64.yml @@ -148,13 +148,13 @@ jobs: - name: Upload Artifact if: inputs.public_provider != 'github' - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: jan-mac-x64-${{ inputs.new_version }} path: ./electron/dist/jan-mac-x64-${{ inputs.new_version }}.dmg - name: Upload Artifact - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: latest-mac-x64 path: ./electron/dist/latest-mac.yml \ No newline at end of file diff --git a/.github/workflows/template-build-windows-x64.yml b/.github/workflows/template-build-windows-x64.yml index 491a8f031..b1110e316 100644 --- a/.github/workflows/template-build-windows-x64.yml +++ b/.github/workflows/template-build-windows-x64.yml @@ -136,7 +136,7 @@ jobs: - name: Upload Artifact if: inputs.public_provider != 'github' - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: jan-win-x64-${{ inputs.new_version }} path: ./electron/dist/*.exe diff --git a/.gitignore b/.gitignore index d3b50445e..646e6842a 100644 --- a/.gitignore +++ b/.gitignore @@ -20,7 +20,7 @@ electron/themes electron/playwright-report server/pre-install package-lock.json - +coverage *.log core/lib/** @@ -41,3 +41,7 @@ extensions/*-extension/bin/vulkaninfo .turbo electron/test-data electron/test-results +core/test_results.html +coverage +.yarn +.yarnrc diff --git a/Makefile b/Makefile index 1687f8bbe..0228c52d7 100644 --- a/Makefile +++ b/Makefile @@ -104,7 +104,7 @@ endif # Testing test: lint yarn build:test - yarn test:unit + yarn test:coverage yarn test # Builds and publishes the app diff --git a/core/jest.config.js b/core/jest.config.js index c18f55091..6c805f1c9 100644 --- a/core/jest.config.js +++ b/core/jest.config.js @@ -4,4 +4,5 @@ module.exports = { moduleNameMapper: { '@/(.*)': '/src/$1', }, + runner: './testRunner.js', } diff --git a/core/package.json b/core/package.json index 9e4d8d69a..ac3305014 100644 --- a/core/package.json +++ b/core/package.json @@ -46,6 +46,8 @@ "eslint": "8.57.0", "eslint-plugin-jest": "^27.9.0", "jest": "^29.7.0", + "jest-junit": "^16.0.0", + "jest-runner": "^29.7.0", "rimraf": "^3.0.2", "rollup": "^2.38.5", "rollup-plugin-commonjs": "^9.1.8", @@ -53,7 +55,7 @@ "rollup-plugin-node-resolve": "^5.2.0", "rollup-plugin-sourcemaps": "^0.6.3", "rollup-plugin-typescript2": "^0.36.0", - "ts-jest": "^29.1.2", + "ts-jest": "^29.2.5", "tslib": "^2.6.2", "typescript": "^5.3.3" }, diff --git a/core/src/browser/core.test.ts b/core/src/browser/core.test.ts new file mode 100644 index 000000000..84250888e --- /dev/null +++ b/core/src/browser/core.test.ts @@ -0,0 +1,98 @@ +import { openExternalUrl } from './core'; +import { joinPath } from './core'; +import { openFileExplorer } from './core'; +import { getJanDataFolderPath } from './core'; +import { abortDownload } from './core'; +import { getFileSize } from './core'; +import { executeOnMain } from './core'; + +it('should open external url', async () => { + const url = 'http://example.com'; + globalThis.core = { + api: { + openExternalUrl: jest.fn().mockResolvedValue('opened') + } + }; + const result = await openExternalUrl(url); + expect(globalThis.core.api.openExternalUrl).toHaveBeenCalledWith(url); + expect(result).toBe('opened'); +}); + + +it('should join paths', async () => { + const paths = ['/path/one', '/path/two']; + globalThis.core = { + api: { + joinPath: jest.fn().mockResolvedValue('/path/one/path/two') + } + }; + const result = await joinPath(paths); + expect(globalThis.core.api.joinPath).toHaveBeenCalledWith(paths); + expect(result).toBe('/path/one/path/two'); +}); + + +it('should open file explorer', async () => { + const path = '/path/to/open'; + globalThis.core = { + api: { + openFileExplorer: jest.fn().mockResolvedValue('opened') + } + }; + const result = await openFileExplorer(path); + expect(globalThis.core.api.openFileExplorer).toHaveBeenCalledWith(path); + expect(result).toBe('opened'); +}); + + +it('should get jan data folder path', async () => { + globalThis.core = { + api: { + getJanDataFolderPath: jest.fn().mockResolvedValue('/path/to/jan/data') + } + }; + const result = await getJanDataFolderPath(); + expect(globalThis.core.api.getJanDataFolderPath).toHaveBeenCalled(); + expect(result).toBe('/path/to/jan/data'); +}); + + +it('should abort download', async () => { + const fileName = 'testFile'; + globalThis.core = { + api: { + abortDownload: jest.fn().mockResolvedValue('aborted') + } + }; + const result = await abortDownload(fileName); + expect(globalThis.core.api.abortDownload).toHaveBeenCalledWith(fileName); + expect(result).toBe('aborted'); +}); + + +it('should get file size', async () => { + const url = 'http://example.com/file'; + globalThis.core = { + api: { + getFileSize: jest.fn().mockResolvedValue(1024) + } + }; + const result = await getFileSize(url); + expect(globalThis.core.api.getFileSize).toHaveBeenCalledWith(url); + expect(result).toBe(1024); +}); + + +it('should execute function on main process', async () => { + const extension = 'testExtension'; + const method = 'testMethod'; + const args = ['arg1', 'arg2']; + globalThis.core = { + api: { + invokeExtensionFunc: jest.fn().mockResolvedValue('result') + } + }; + const result = await executeOnMain(extension, method, ...args); + expect(globalThis.core.api.invokeExtensionFunc).toHaveBeenCalledWith(extension, method, ...args); + expect(result).toBe('result'); +}); diff --git a/core/src/browser/events.test.ts b/core/src/browser/events.test.ts new file mode 100644 index 000000000..23b4d78d9 --- /dev/null +++ b/core/src/browser/events.test.ts @@ -0,0 +1,37 @@ +import { events } from './events'; +import { jest } from '@jest/globals'; + +it('should emit an event', () => { + const mockObject = { key: 'value' }; + globalThis.core = { + events: { + emit: jest.fn() + } + }; + events.emit('testEvent', mockObject); + expect(globalThis.core.events.emit).toHaveBeenCalledWith('testEvent', mockObject); +}); + + +it('should remove an observer for an event', () => { + const mockHandler = jest.fn(); + globalThis.core = { + events: { + off: jest.fn() + } + }; + events.off('testEvent', mockHandler); + expect(globalThis.core.events.off).toHaveBeenCalledWith('testEvent', mockHandler); +}); + + +it('should add an observer for an event', () => { + const mockHandler = jest.fn(); + globalThis.core = { + events: { + on: jest.fn() + } + }; + events.on('testEvent', mockHandler); + expect(globalThis.core.events.on).toHaveBeenCalledWith('testEvent', mockHandler); +}); diff --git a/core/src/browser/extension.test.ts b/core/src/browser/extension.test.ts new file mode 100644 index 000000000..6c1cd8579 --- /dev/null +++ b/core/src/browser/extension.test.ts @@ -0,0 +1,46 @@ +import { BaseExtension } from './extension' + +class TestBaseExtension extends BaseExtension { + onLoad(): void {} + onUnload(): void {} +} + +describe('BaseExtension', () => { + let baseExtension: TestBaseExtension + + beforeEach(() => { + baseExtension = new TestBaseExtension('https://example.com', 'TestExtension') + }) + + afterEach(() => { + jest.resetAllMocks() + }) + + it('should have the correct properties', () => { + expect(baseExtension.name).toBe('TestExtension') + expect(baseExtension.productName).toBeUndefined() + expect(baseExtension.url).toBe('https://example.com') + expect(baseExtension.active).toBeUndefined() + expect(baseExtension.description).toBeUndefined() + expect(baseExtension.version).toBeUndefined() + }) + + it('should return undefined for type()', () => { + expect(baseExtension.type()).toBeUndefined() + }) + + it('should have abstract methods onLoad() and onUnload()', () => { + expect(baseExtension.onLoad).toBeDefined() + expect(baseExtension.onUnload).toBeDefined() + }) + + it('should have installationState() return "NotRequired"', async () => { + const installationState = await baseExtension.installationState() + expect(installationState).toBe('NotRequired') + }) + + it('should install the extension', async () => { + await baseExtension.install() + // Add your assertions here + }) +}) diff --git a/core/src/browser/extensions/engines/helpers/sse.test.ts b/core/src/browser/extensions/engines/helpers/sse.test.ts new file mode 100644 index 000000000..cff5b93b3 --- /dev/null +++ b/core/src/browser/extensions/engines/helpers/sse.test.ts @@ -0,0 +1,60 @@ +import { lastValueFrom, Observable } from 'rxjs' +import { requestInference } from './sse' + +describe('requestInference', () => { + it('should send a request to the inference server and return an Observable', () => { + // Mock the fetch function + const mockFetch: any = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ choices: [{ message: { content: 'Generated response' } }] }), + headers: new Headers(), + redirected: false, + status: 200, + statusText: 'OK', + // Add other required properties here + }) + ) + jest.spyOn(global, 'fetch').mockImplementation(mockFetch) + + // Define the test inputs + const inferenceUrl = 'https://inference-server.com' + const requestBody = { message: 'Hello' } + const model = { id: 'model-id', parameters: { stream: false } } + + // Call the function + const result = requestInference(inferenceUrl, requestBody, model) + + // Assert the expected behavior + expect(result).toBeInstanceOf(Observable) + expect(lastValueFrom(result)).resolves.toEqual('Generated response') + }) + + it('returns 401 error', () => { + // Mock the fetch function + const mockFetch: any = jest.fn(() => + Promise.resolve({ + ok: false, + json: () => Promise.resolve({ error: { message: 'Wrong API Key', code: 'invalid_api_key' } }), + headers: new Headers(), + redirected: false, + status: 401, + statusText: 'invalid_api_key', + // Add other required properties here + }) + ) + jest.spyOn(global, 'fetch').mockImplementation(mockFetch) + + // Define the test inputs + const inferenceUrl = 'https://inference-server.com' + const requestBody = { message: 'Hello' } + const model = { id: 'model-id', parameters: { stream: false } } + + // Call the function + const result = requestInference(inferenceUrl, requestBody, model) + + // Assert the expected behavior + expect(result).toBeInstanceOf(Observable) + expect(lastValueFrom(result)).rejects.toEqual({ message: 'Wrong API Key', code: 'invalid_api_key' }) + }) +}) diff --git a/core/src/browser/index.test.ts b/core/src/browser/index.test.ts new file mode 100644 index 000000000..339cd9046 --- /dev/null +++ b/core/src/browser/index.test.ts @@ -0,0 +1,32 @@ +import * as Core from './core'; +import * as Events from './events'; +import * as FileSystem from './fs'; +import * as Extension from './extension'; +import * as Extensions from './extensions'; +import * as Tools from './tools'; + +describe('Module Tests', () => { + it('should export Core module', () => { + expect(Core).toBeDefined(); + }); + + it('should export Event module', () => { + expect(Events).toBeDefined(); + }); + + it('should export Filesystem module', () => { + expect(FileSystem).toBeDefined(); + }); + + it('should export Extension module', () => { + expect(Extension).toBeDefined(); + }); + + it('should export all base extensions', () => { + expect(Extensions).toBeDefined(); + }); + + it('should export all base tools', () => { + expect(Tools).toBeDefined(); + }); +}); \ No newline at end of file diff --git a/core/src/node/api/common/adapter.test.ts b/core/src/node/api/common/adapter.test.ts new file mode 100644 index 000000000..38fd2857f --- /dev/null +++ b/core/src/node/api/common/adapter.test.ts @@ -0,0 +1,10 @@ +import { RequestAdapter } from './adapter'; + +it('should return undefined for unknown route', () => { + const adapter = new RequestAdapter(); + const route = 'unknownRoute'; + + const result = adapter.process(route, 'arg1', 'arg2'); + + expect(result).toBeUndefined(); +}); diff --git a/core/src/node/api/common/handler.test.ts b/core/src/node/api/common/handler.test.ts new file mode 100644 index 000000000..bd55d41cc --- /dev/null +++ b/core/src/node/api/common/handler.test.ts @@ -0,0 +1,25 @@ +import { CoreRoutes } from '../../../types/api'; +import { RequestHandler } from './handler'; +import { RequestAdapter } from './adapter'; + +it('should not call handler if CoreRoutes is empty', () => { + const mockHandler = jest.fn(); + const mockObserver = jest.fn(); + const requestHandler = new RequestHandler(mockHandler, mockObserver); + + CoreRoutes.length = 0; // Ensure CoreRoutes is empty + + requestHandler.handle(); + + expect(mockHandler).not.toHaveBeenCalled(); +}); + + +it('should initialize handler and adapter correctly', () => { + const mockHandler = jest.fn(); + const mockObserver = jest.fn(); + const requestHandler = new RequestHandler(mockHandler, mockObserver); + + expect(requestHandler.handler).toBe(mockHandler); + expect(requestHandler.adapter).toBeInstanceOf(RequestAdapter); +}); diff --git a/core/src/node/api/processors/app.test.ts b/core/src/node/api/processors/app.test.ts new file mode 100644 index 000000000..3ada5df1e --- /dev/null +++ b/core/src/node/api/processors/app.test.ts @@ -0,0 +1,40 @@ +import { App } from './app'; + +it('should call stopServer', () => { + const app = new App(); + const stopServerMock = jest.fn().mockResolvedValue('Server stopped'); + jest.mock('@janhq/server', () => ({ + stopServer: stopServerMock + })); + const result = app.stopServer(); + expect(stopServerMock).toHaveBeenCalled(); +}); + +it('should correctly retrieve basename', () => { + const app = new App(); + const result = app.baseName('/path/to/file.txt'); + expect(result).toBe('file.txt'); +}); + +it('should correctly identify subdirectories', () => { + const app = new App(); + const basePath = process.platform === 'win32' ? 'C:\\path\\to' : '/path/to'; + const subPath = process.platform === 'win32' ? 'C:\\path\\to\\subdir' : '/path/to/subdir'; + const result = app.isSubdirectory(basePath, subPath); + expect(result).toBe(true); +}); + +it('should correctly join multiple paths', () => { + const app = new App(); + const result = app.joinPath(['path', 'to', 'file']); + const expectedPath = process.platform === 'win32' ? 'path\\to\\file' : 'path/to/file'; + expect(result).toBe(expectedPath); +}); + +it('should call correct function with provided arguments using process method', () => { + const app = new App(); + const mockFunc = jest.fn(); + app.joinPath = mockFunc; + app.process('joinPath', ['path1', 'path2']); + expect(mockFunc).toHaveBeenCalledWith(['path1', 'path2']); +}); diff --git a/core/src/node/api/processors/download.test.ts b/core/src/node/api/processors/download.test.ts new file mode 100644 index 000000000..1dc0eefb8 --- /dev/null +++ b/core/src/node/api/processors/download.test.ts @@ -0,0 +1,59 @@ +import { Downloader } from './download'; +import { DownloadEvent } from '../../../types/api'; +import { DownloadManager } from '../../helper/download'; + +it('should handle getFileSize errors correctly', async () => { + const observer = jest.fn(); + const url = 'http://example.com/file'; + + const downloader = new Downloader(observer); + const requestMock = jest.fn((options, callback) => { + callback(new Error('Test error'), null); + }); + jest.mock('request', () => requestMock); + + await expect(downloader.getFileSize(observer, url)).rejects.toThrow('Test error'); +}); + + +it('should pause download correctly', () => { + const observer = jest.fn(); + const fileName = process.platform === 'win32' ? 'C:\\path\\to\\file' : 'path/to/file'; + + const downloader = new Downloader(observer); + const pauseMock = jest.fn(); + DownloadManager.instance.networkRequests[fileName] = { pause: pauseMock }; + + downloader.pauseDownload(observer, fileName); + + expect(pauseMock).toHaveBeenCalled(); +}); + +it('should resume download correctly', () => { + const observer = jest.fn(); + const fileName = process.platform === 'win32' ? 'C:\\path\\to\\file' : 'path/to/file'; + + const downloader = new Downloader(observer); + const resumeMock = jest.fn(); + DownloadManager.instance.networkRequests[fileName] = { resume: resumeMock }; + + downloader.resumeDownload(observer, fileName); + + expect(resumeMock).toHaveBeenCalled(); +}); + +it('should handle aborting a download correctly', () => { + const observer = jest.fn(); + const fileName = process.platform === 'win32' ? 'C:\\path\\to\\file' : 'path/to/file'; + + const downloader = new Downloader(observer); + const abortMock = jest.fn(); + DownloadManager.instance.networkRequests[fileName] = { abort: abortMock }; + + downloader.abortDownload(observer, fileName); + + expect(abortMock).toHaveBeenCalled(); + expect(observer).toHaveBeenCalledWith(DownloadEvent.onFileDownloadError, expect.objectContaining({ + error: 'aborted' + })); +}); diff --git a/core/src/node/api/processors/extension.test.ts b/core/src/node/api/processors/extension.test.ts new file mode 100644 index 000000000..917883499 --- /dev/null +++ b/core/src/node/api/processors/extension.test.ts @@ -0,0 +1,9 @@ +import { Extension } from './extension'; + +it('should call function associated with key in process method', () => { + const mockFunc = jest.fn(); + const extension = new Extension(); + (extension as any).testKey = mockFunc; + extension.process('testKey', 'arg1', 'arg2'); + expect(mockFunc).toHaveBeenCalledWith('arg1', 'arg2'); +}); diff --git a/core/src/node/api/processors/fs.test.ts b/core/src/node/api/processors/fs.test.ts new file mode 100644 index 000000000..3cac2e2ff --- /dev/null +++ b/core/src/node/api/processors/fs.test.ts @@ -0,0 +1,18 @@ +import { FileSystem } from './fs'; + +it('should throw an error when the route does not exist in process', async () => { + const fileSystem = new FileSystem(); + await expect(fileSystem.process('nonExistentRoute', 'arg1')).rejects.toThrow(); +}); + + +it('should throw an error for invalid argument in mkdir', async () => { + const fileSystem = new FileSystem(); + expect(() => fileSystem.mkdir(123)).toThrow('mkdir error: Invalid argument [123]'); +}); + + +it('should throw an error for invalid argument in rm', async () => { + const fileSystem = new FileSystem(); + expect(() => fileSystem.rm(123)).toThrow('rm error: Invalid argument [123]'); +}); diff --git a/core/src/node/api/processors/fsExt.test.ts b/core/src/node/api/processors/fsExt.test.ts new file mode 100644 index 000000000..bfc54897a --- /dev/null +++ b/core/src/node/api/processors/fsExt.test.ts @@ -0,0 +1,34 @@ +import { FSExt } from './fsExt'; +import { defaultAppConfig } from '../../helper'; + +it('should handle errors in writeBlob', () => { + const fsExt = new FSExt(); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + fsExt.writeBlob('invalid-path', 'data'); + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); +}); + +it('should call correct function in process method', () => { + const fsExt = new FSExt(); + const mockFunction = jest.fn(); + (fsExt as any).mockFunction = mockFunction; + fsExt.process('mockFunction', 'arg1', 'arg2'); + expect(mockFunction).toHaveBeenCalledWith('arg1', 'arg2'); +}); + + +it('should return correct user home path', () => { + const fsExt = new FSExt(); + const userHomePath = fsExt.getUserHomePath(); + expect(userHomePath).toBe(defaultAppConfig().data_folder); +}); + + + +it('should return empty array when no files are provided', async () => { + const fsExt = new FSExt(); + const result = await fsExt.getGgufFiles([]); + expect(result.supportedFiles).toEqual([]); + expect(result.unsupportedFiles).toEqual([]); +}); diff --git a/core/src/node/api/processors/processor.test.ts b/core/src/node/api/processors/processor.test.ts new file mode 100644 index 000000000..e69de29bb diff --git a/core/src/node/api/restful/app/download.test.ts b/core/src/node/api/restful/app/download.test.ts new file mode 100644 index 000000000..b2af1bb0d --- /dev/null +++ b/core/src/node/api/restful/app/download.test.ts @@ -0,0 +1,62 @@ +import { HttpServer } from '../../HttpServer' +import { DownloadManager } from '../../../helper/download' + +describe('downloadRouter', () => { + let app: HttpServer + + beforeEach(() => { + app = { + register: jest.fn(), + post: jest.fn(), + get: jest.fn(), + patch: jest.fn(), + put: jest.fn(), + delete: jest.fn(), + } + }) + + it('should return download progress for a given modelId', async () => { + const modelId = '123' + const downloadProgress = { progress: 50 } + + DownloadManager.instance.downloadProgressMap[modelId] = downloadProgress as any + + const req = { params: { modelId } } + const res = { + status: jest.fn(), + send: jest.fn(), + } + + jest.spyOn(app, 'get').mockImplementation((path, handler) => { + if (path === `/download/getDownloadProgress/${modelId}`) { + res.status(200) + res.send(downloadProgress) + } + }) + + app.get(`/download/getDownloadProgress/${modelId}`, req as any) + expect(res.status).toHaveBeenCalledWith(200) + expect(res.send).toHaveBeenCalledWith(downloadProgress) + }) + + it('should return 404 if download progress is not found', async () => { + const modelId = '123' + + const req = { params: { modelId } } + const res = { + status: jest.fn(), + send: jest.fn(), + } + + + jest.spyOn(app, 'get').mockImplementation((path, handler) => { + if (path === `/download/getDownloadProgress/${modelId}`) { + res.status(404) + res.send({ message: 'Download progress not found' }) + } + }) + app.get(`/download/getDownloadProgress/${modelId}`, req as any) + expect(res.status).toHaveBeenCalledWith(404) + expect(res.send).toHaveBeenCalledWith({ message: 'Download progress not found' }) + }) +}) diff --git a/core/src/node/api/restful/app/handlers.test.ts b/core/src/node/api/restful/app/handlers.test.ts new file mode 100644 index 000000000..680623d86 --- /dev/null +++ b/core/src/node/api/restful/app/handlers.test.ts @@ -0,0 +1,16 @@ +// +import { jest } from '@jest/globals'; + +import { HttpServer } from '../../HttpServer'; +import { handleRequests } from './handlers'; +import { Handler, RequestHandler } from '../../common/handler'; + +it('should initialize RequestHandler and call handle', () => { + const mockHandle = jest.fn(); + jest.spyOn(RequestHandler.prototype, 'handle').mockImplementation(mockHandle); + + const mockApp = { post: jest.fn() }; + handleRequests(mockApp as unknown as HttpServer); + + expect(mockHandle).toHaveBeenCalled(); +}); diff --git a/core/src/node/api/restful/common.test.ts b/core/src/node/api/restful/common.test.ts new file mode 100644 index 000000000..b40f6606f --- /dev/null +++ b/core/src/node/api/restful/common.test.ts @@ -0,0 +1,21 @@ + +import { commonRouter } from './common'; +import { JanApiRouteConfiguration } from './helper/configuration'; + +test('commonRouter sets up routes for each key in JanApiRouteConfiguration', async () => { + const mockHttpServer = { + get: jest.fn(), + post: jest.fn(), + patch: jest.fn(), + put: jest.fn(), + delete: jest.fn(), + }; + await commonRouter(mockHttpServer as any); + + const expectedRoutes = Object.keys(JanApiRouteConfiguration); + expectedRoutes.forEach((key) => { + expect(mockHttpServer.get).toHaveBeenCalledWith(`/${key}`, expect.any(Function)); + expect(mockHttpServer.get).toHaveBeenCalledWith(`/${key}/:id`, expect.any(Function)); + expect(mockHttpServer.delete).toHaveBeenCalledWith(`/${key}/:id`, expect.any(Function)); + }); +}); diff --git a/core/src/node/api/restful/helper/configuration.test.ts b/core/src/node/api/restful/helper/configuration.test.ts new file mode 100644 index 000000000..ae002312a --- /dev/null +++ b/core/src/node/api/restful/helper/configuration.test.ts @@ -0,0 +1,24 @@ +import { JanApiRouteConfiguration } from './configuration' + +describe('JanApiRouteConfiguration', () => { + it('should have the correct models configuration', () => { + const modelsConfig = JanApiRouteConfiguration.models; + expect(modelsConfig.dirName).toBe('models'); + expect(modelsConfig.metadataFileName).toBe('model.json'); + expect(modelsConfig.delete.object).toBe('model'); + }); + + it('should have the correct assistants configuration', () => { + const assistantsConfig = JanApiRouteConfiguration.assistants; + expect(assistantsConfig.dirName).toBe('assistants'); + expect(assistantsConfig.metadataFileName).toBe('assistant.json'); + expect(assistantsConfig.delete.object).toBe('assistant'); + }); + + it('should have the correct threads configuration', () => { + const threadsConfig = JanApiRouteConfiguration.threads; + expect(threadsConfig.dirName).toBe('threads'); + expect(threadsConfig.metadataFileName).toBe('thread.json'); + expect(threadsConfig.delete.object).toBe('thread'); + }); +}); \ No newline at end of file diff --git a/core/src/node/api/restful/helper/startStopModel.ts b/core/src/node/api/restful/helper/startStopModel.ts index 8665850da..d1a23dca9 100644 --- a/core/src/node/api/restful/helper/startStopModel.ts +++ b/core/src/node/api/restful/helper/startStopModel.ts @@ -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 => { - 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 => { - 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 => { - // 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 => { - 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 => { + 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', - }) - ) - }) + ) } diff --git a/core/src/node/api/restful/v1.test.ts b/core/src/node/api/restful/v1.test.ts new file mode 100644 index 000000000..8e22496e9 --- /dev/null +++ b/core/src/node/api/restful/v1.test.ts @@ -0,0 +1,16 @@ + +import { v1Router } from './v1'; +import { commonRouter } from './common'; + +test('should define v1Router function', () => { + expect(v1Router).toBeDefined(); +}); + +test('should register commonRouter', () => { + const mockApp = { + register: jest.fn(), + }; + v1Router(mockApp as any); + expect(mockApp.register).toHaveBeenCalledWith(commonRouter); +}); + diff --git a/core/src/node/extension/extension.test.ts b/core/src/node/extension/extension.test.ts new file mode 100644 index 000000000..c43b5c0cb --- /dev/null +++ b/core/src/node/extension/extension.test.ts @@ -0,0 +1,122 @@ +import Extension from './extension'; +import { join } from 'path'; +import 'pacote'; + +it('should set active and call emitUpdate', () => { + const extension = new Extension(); + extension.emitUpdate = jest.fn(); + + extension.setActive(true); + + expect(extension._active).toBe(true); + expect(extension.emitUpdate).toHaveBeenCalled(); +}); + + +it('should return correct specifier', () => { + const origin = 'test-origin'; + const options = { version: '1.0.0' }; + const extension = new Extension(origin, options); + + expect(extension.specifier).toBe('test-origin@1.0.0'); +}); + + +it('should set origin and installOptions in constructor', () => { + const origin = 'test-origin'; + const options = { someOption: true }; + const extension = new Extension(origin, options); + + expect(extension.origin).toBe(origin); + expect(extension.installOptions.someOption).toBe(true); + expect(extension.installOptions.fullMetadata).toBe(true); // default option +}); + +it('should install extension and set url', async () => { + const origin = 'test-origin'; + const options = {}; + const extension = new Extension(origin, options); + + const mockManifest = { + name: 'test-name', + productName: 'Test Product', + version: '1.0.0', + main: 'index.js', + description: 'Test description' + }; + + jest.mock('pacote', () => ({ + manifest: jest.fn().mockResolvedValue(mockManifest), + extract: jest.fn().mockResolvedValue(null) + })); + + extension.emitUpdate = jest.fn(); + await extension._install(); + + expect(extension.url).toBe('extension://test-name/index.js'); + expect(extension.emitUpdate).toHaveBeenCalled(); +}); + + +it('should call all listeners in emitUpdate', () => { + const extension = new Extension(); + const callback1 = jest.fn(); + const callback2 = jest.fn(); + + extension.subscribe('listener1', callback1); + extension.subscribe('listener2', callback2); + + extension.emitUpdate(); + + expect(callback1).toHaveBeenCalledWith(extension); + expect(callback2).toHaveBeenCalledWith(extension); +}); + + +it('should remove listener in unsubscribe', () => { + const extension = new Extension(); + const callback = jest.fn(); + + extension.subscribe('testListener', callback); + extension.unsubscribe('testListener'); + + expect(extension.listeners['testListener']).toBeUndefined(); +}); + + +it('should add listener in subscribe', () => { + const extension = new Extension(); + const callback = jest.fn(); + + extension.subscribe('testListener', callback); + + expect(extension.listeners['testListener']).toBe(callback); +}); + + +it('should set properties from manifest', async () => { + const origin = 'test-origin'; + const options = {}; + const extension = new Extension(origin, options); + + const mockManifest = { + name: 'test-name', + productName: 'Test Product', + version: '1.0.0', + main: 'index.js', + description: 'Test description' + }; + + jest.mock('pacote', () => ({ + manifest: jest.fn().mockResolvedValue(mockManifest) + })); + + await extension.getManifest(); + + expect(extension.name).toBe('test-name'); + expect(extension.productName).toBe('Test Product'); + expect(extension.version).toBe('1.0.0'); + expect(extension.main).toBe('index.js'); + expect(extension.description).toBe('Test description'); +}); + diff --git a/core/src/node/extension/manager.test.ts b/core/src/node/extension/manager.test.ts new file mode 100644 index 000000000..1c8123d21 --- /dev/null +++ b/core/src/node/extension/manager.test.ts @@ -0,0 +1,28 @@ +import * as fs from 'fs'; +import { join } from 'path'; +import { ExtensionManager } from './manager'; + +it('should throw an error when an invalid path is provided', () => { + const manager = new ExtensionManager(); + jest.spyOn(fs, 'existsSync').mockReturnValue(false); + expect(() => manager.setExtensionsPath('')).toThrow('Invalid path provided to the extensions folder'); +}); + + +it('should return an empty string when extensionsPath is not set', () => { + const manager = new ExtensionManager(); + expect(manager.getExtensionsFile()).toBe(join('', 'extensions.json')); +}); + + +it('should return undefined if no path is set', () => { + const manager = new ExtensionManager(); + expect(manager.getExtensionsPath()).toBeUndefined(); +}); + + +it('should return the singleton instance', () => { + const instance1 = new ExtensionManager(); + const instance2 = new ExtensionManager(); + expect(instance1).toBe(instance2); +}); diff --git a/core/src/node/extension/store.test.ts b/core/src/node/extension/store.test.ts new file mode 100644 index 000000000..cbaa84f7c --- /dev/null +++ b/core/src/node/extension/store.test.ts @@ -0,0 +1,43 @@ +import { getAllExtensions } from './store'; +import { getActiveExtensions } from './store'; +import { getExtension } from './store'; + +test('should return empty array when no extensions added', () => { + expect(getAllExtensions()).toEqual([]); +}); + + +test('should throw error when extension does not exist', () => { + expect(() => getExtension('nonExistentExtension')).toThrow('Extension nonExistentExtension does not exist'); +}); + +import { addExtension } from './store'; +import Extension from './extension'; + +test('should return all extensions when multiple extensions added', () => { + const ext1 = new Extension('ext1'); + ext1.name = 'ext1'; + const ext2 = new Extension('ext2'); + ext2.name = 'ext2'; + + addExtension(ext1, false); + addExtension(ext2, false); + + expect(getAllExtensions()).toEqual([ext1, ext2]); +}); + + + +test('should return only active extensions', () => { + const ext1 = new Extension('ext1'); + ext1.name = 'ext1'; + ext1.setActive(true); + const ext2 = new Extension('ext2'); + ext2.name = 'ext2'; + ext2.setActive(false); + + addExtension(ext1, false); + addExtension(ext2, false); + + expect(getActiveExtensions()).toEqual([ext1]); +}); diff --git a/core/src/node/helper/config.test.ts b/core/src/node/helper/config.test.ts new file mode 100644 index 000000000..201a98141 --- /dev/null +++ b/core/src/node/helper/config.test.ts @@ -0,0 +1,14 @@ +import { getEngineConfiguration } from './config'; +import { getAppConfigurations, defaultAppConfig } from './config'; + +it('should return undefined for invalid engine ID', async () => { + const config = await getEngineConfiguration('invalid_engine'); + expect(config).toBeUndefined(); +}); + + +it('should return default config when CI is e2e', () => { + process.env.CI = 'e2e'; + const config = getAppConfigurations(); + expect(config).toEqual(defaultAppConfig()); +}); diff --git a/core/src/node/helper/download.test.ts b/core/src/node/helper/download.test.ts new file mode 100644 index 000000000..95cc553b5 --- /dev/null +++ b/core/src/node/helper/download.test.ts @@ -0,0 +1,11 @@ +import { DownloadManager } from './download'; + +it('should set a network request for a specific file', () => { + const downloadManager = new DownloadManager(); + const fileName = 'testFile'; + const request = { url: 'http://example.com' }; + + downloadManager.setRequest(fileName, request); + + expect(downloadManager.networkRequests[fileName]).toEqual(request); +}); diff --git a/core/src/node/helper/logger.test.ts b/core/src/node/helper/logger.test.ts new file mode 100644 index 000000000..0f44bfcd4 --- /dev/null +++ b/core/src/node/helper/logger.test.ts @@ -0,0 +1,47 @@ +import { Logger, LoggerManager } from './logger'; + + it('should flush queued logs to registered loggers', () => { + class TestLogger extends Logger { + name = 'testLogger'; + log(args: any): void { + console.log(args); + } + } + const loggerManager = new LoggerManager(); + const testLogger = new TestLogger(); + loggerManager.register(testLogger); + const logSpy = jest.spyOn(testLogger, 'log'); + loggerManager.log('test log'); + expect(logSpy).toHaveBeenCalledWith('test log'); + }); + + + it('should unregister a logger', () => { + class TestLogger extends Logger { + name = 'testLogger'; + log(args: any): void { + console.log(args); + } + } + const loggerManager = new LoggerManager(); + const testLogger = new TestLogger(); + loggerManager.register(testLogger); + loggerManager.unregister('testLogger'); + const retrievedLogger = loggerManager.get('testLogger'); + expect(retrievedLogger).toBeUndefined(); + }); + + + it('should register and retrieve a logger', () => { + class TestLogger extends Logger { + name = 'testLogger'; + log(args: any): void { + console.log(args); + } + } + const loggerManager = new LoggerManager(); + const testLogger = new TestLogger(); + loggerManager.register(testLogger); + const retrievedLogger = loggerManager.get('testLogger'); + expect(retrievedLogger).toBe(testLogger); + }); diff --git a/core/src/node/helper/module.test.ts b/core/src/node/helper/module.test.ts new file mode 100644 index 000000000..bb8327cbf --- /dev/null +++ b/core/src/node/helper/module.test.ts @@ -0,0 +1,23 @@ +import { ModuleManager } from './module'; + +it('should clear all imported modules', () => { + const moduleManager = new ModuleManager(); + moduleManager.setModule('module1', { key: 'value1' }); + moduleManager.setModule('module2', { key: 'value2' }); + moduleManager.clearImportedModules(); + expect(moduleManager.requiredModules).toEqual({}); +}); + + +it('should set a module correctly', () => { + const moduleManager = new ModuleManager(); + moduleManager.setModule('testModule', { key: 'value' }); + expect(moduleManager.requiredModules['testModule']).toEqual({ key: 'value' }); +}); + + +it('should return the singleton instance', () => { + const instance1 = new ModuleManager(); + const instance2 = new ModuleManager(); + expect(instance1).toBe(instance2); +}); diff --git a/core/src/node/helper/path.test.ts b/core/src/node/helper/path.test.ts new file mode 100644 index 000000000..f9a3b5766 --- /dev/null +++ b/core/src/node/helper/path.test.ts @@ -0,0 +1,29 @@ +import { normalizeFilePath } from './path' + +import { jest } from '@jest/globals' +describe('Test file normalize', () => { + test('returns no file protocol prefix on Unix', async () => { + expect(normalizeFilePath('file://test.txt')).toBe('test.txt') + expect(normalizeFilePath('file:/test.txt')).toBe('test.txt') + }) + test('returns no file protocol prefix on Windows', async () => { + expect(normalizeFilePath('file:\\\\test.txt')).toBe('test.txt') + expect(normalizeFilePath('file:\\test.txt')).toBe('test.txt') + }) + + test('returns correct path when Electron is available and app is not packaged', () => { + const electronMock = { + app: { + getAppPath: jest.fn().mockReturnValue('/mocked/path'), + isPackaged: false, + }, + protocol: {}, + } + jest.mock('electron', () => electronMock) + + const { appResourcePath } = require('./path') + + const expectedPath = process.platform === 'win32' ? '\\mocked\\path' : '/mocked/path' + expect(appResourcePath()).toBe(expectedPath) + }) +}) diff --git a/core/src/node/helper/resource.test.ts b/core/src/node/helper/resource.test.ts new file mode 100644 index 000000000..aaeab9d65 --- /dev/null +++ b/core/src/node/helper/resource.test.ts @@ -0,0 +1,15 @@ +import { getSystemResourceInfo } from './resource'; + +it('should return the correct system resource information with a valid CPU count', async () => { + const mockCpuCount = 4; + jest.spyOn(require('./config'), 'physicalCpuCount').mockResolvedValue(mockCpuCount); + const logSpy = jest.spyOn(require('./logger'), 'log').mockImplementation(() => {}); + + const result = await getSystemResourceInfo(); + + expect(result).toEqual({ + numCpuPhysicalCore: mockCpuCount, + memAvailable: 0, + }); + expect(logSpy).toHaveBeenCalledWith(`[CORTEX]::CPU information - ${mockCpuCount}`); +}); diff --git a/core/src/types/api/index.ts b/core/src/types/api/index.ts index e1d1b28da..bca11c0a8 100644 --- a/core/src/types/api/index.ts +++ b/core/src/types/api/index.ts @@ -55,6 +55,7 @@ export enum AppEvent { onSelectedText = 'onSelectedText', onDeepLink = 'onDeepLink', + onMainViewStateChange = 'onMainViewStateChange', } export enum DownloadRoute { diff --git a/core/src/types/assistant/assistantEvent.test.ts b/core/src/types/assistant/assistantEvent.test.ts new file mode 100644 index 000000000..4b1ed552c --- /dev/null +++ b/core/src/types/assistant/assistantEvent.test.ts @@ -0,0 +1,7 @@ +import { AssistantEvent } from './assistantEvent'; +it('dummy test', () => { expect(true).toBe(true); }); + +it('should contain OnAssistantsUpdate event', () => { + expect(AssistantEvent.OnAssistantsUpdate).toBe('OnAssistantsUpdate'); +}); + diff --git a/core/src/types/file/index.ts b/core/src/types/file/index.ts index d941987ef..1b36a5777 100644 --- a/core/src/types/file/index.ts +++ b/core/src/types/file/index.ts @@ -16,7 +16,7 @@ export type DownloadState = { error?: string extensionId?: string - downloadType?: DownloadType + downloadType?: DownloadType | string localPath?: string } @@ -40,7 +40,7 @@ export type DownloadRequest = { */ extensionId?: string - downloadType?: DownloadType + downloadType?: DownloadType | string } type DownloadTime = { diff --git a/core/testRunner.js b/core/testRunner.js new file mode 100644 index 000000000..b0d108160 --- /dev/null +++ b/core/testRunner.js @@ -0,0 +1,10 @@ +const jestRunner = require('jest-runner'); + +class EmptyTestFileRunner extends jestRunner.default { + async runTests(tests, watcher, onStart, onResult, onFailure, options) { + const nonEmptyTests = tests.filter(test => test.context.hasteFS.getSize(test.path) > 0); + return super.runTests(nonEmptyTests, watcher, onStart, onResult, onFailure, options); + } +} + +module.exports = EmptyTestFileRunner; \ No newline at end of file diff --git a/core/tests/node/path.test.ts b/core/tests/node/path.test.ts deleted file mode 100644 index 5390df119..000000000 --- a/core/tests/node/path.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { normalizeFilePath } from "../../src/node/helper/path"; - -describe("Test file normalize", () => { - test("returns no file protocol prefix on Unix", async () => { - expect(normalizeFilePath("file://test.txt")).toBe("test.txt"); - expect(normalizeFilePath("file:/test.txt")).toBe("test.txt"); - }); - test("returns no file protocol prefix on Windows", async () => { - expect(normalizeFilePath("file:\\\\test.txt")).toBe("test.txt"); - expect(normalizeFilePath("file:\\test.txt")).toBe("test.txt"); - }); -}); diff --git a/core/tsconfig.json b/core/tsconfig.json index daeb7eeff..02caf65e2 100644 --- a/core/tsconfig.json +++ b/core/tsconfig.json @@ -16,4 +16,5 @@ "types": ["@types/jest"], }, "include": ["src"], + "exclude": ["**/*.test.ts"] } diff --git a/electron/.eslintrc.js b/electron/.eslintrc.js index d252ec42b..8b270b52e 100644 --- a/electron/.eslintrc.js +++ b/electron/.eslintrc.js @@ -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'], } diff --git a/electron/managers/mainWindowConfig.ts b/electron/managers/mainWindowConfig.ts index 82c437106..997d081c3 100644 --- a/electron/managers/mainWindowConfig.ts +++ b/electron/managers/mainWindowConfig.ts @@ -6,9 +6,10 @@ export const mainWindowConfig: Electron.BrowserWindowConstructorOptions = { minWidth: DEFAULT_MIN_WIDTH, minHeight: DEFAULT_MIN_HEIGHT, show: true, - transparent: true, - frame: false, - titleBarStyle: 'hidden', + // we want to go frameless on windows and linux + transparent: process.platform === 'darwin', + frame: process.platform === 'darwin', + titleBarStyle: 'hiddenInset', vibrancy: 'fullscreen-ui', visualEffectState: 'active', backgroundMaterial: 'acrylic', diff --git a/electron/managers/window.ts b/electron/managers/window.ts index 3d5107b28..c9c43ea77 100644 --- a/electron/managers/window.ts +++ b/electron/managers/window.ts @@ -166,6 +166,15 @@ class WindowManager { }, 500) } + /** + * Send main view state to the main app. + */ + sendMainViewState(route: string) { + if (this.mainWindow && !this.mainWindow.isDestroyed()) { + this.mainWindow.webContents.send(AppEvent.onMainViewStateChange, route) + } + } + cleanUp(): void { if (!this.mainWindow?.isDestroyed()) { this.mainWindow?.close() diff --git a/electron/utils/menu.ts b/electron/utils/menu.ts index 3f838e5ca..553412faf 100644 --- a/electron/utils/menu.ts +++ b/electron/utils/menu.ts @@ -3,6 +3,7 @@ import { app, Menu, shell, dialog } from 'electron' import { autoUpdater } from 'electron-updater' import { log } from '@janhq/core/node' const isMac = process.platform === 'darwin' +import { windowManager } from '../managers/window' const template: (Electron.MenuItemConstructorOptions | Electron.MenuItem)[] = [ { @@ -43,6 +44,14 @@ const template: (Electron.MenuItemConstructorOptions | Electron.MenuItem)[] = [ { role: 'hide' }, { role: 'hideOthers' }, { role: 'unhide' }, + { + label: `Settings`, + accelerator: 'CmdOrCtrl+,', + click: () => { + windowManager.showMainWindow() + windowManager.sendMainViewState('Settings') + }, + }, { type: 'separator' }, { role: 'quit' }, ], diff --git a/extensions/inference-groq-extension/resources/models.json b/extensions/inference-groq-extension/resources/models.json index 81275f47c..6fce1c71b 100644 --- a/extensions/inference-groq-extension/resources/models.json +++ b/extensions/inference-groq-extension/resources/models.json @@ -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": { diff --git a/extensions/inference-nitro-extension/download.bat b/extensions/inference-nitro-extension/download.bat index b7fbd3252..7acd385d5 100644 --- a/extensions/inference-nitro-extension/download.bat +++ b/extensions/inference-nitro-extension/download.bat @@ -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. \ No newline at end of file diff --git a/extensions/inference-nitro-extension/download.sh b/extensions/inference-nitro-extension/download.sh new file mode 100755 index 000000000..98ed8504a --- /dev/null +++ b/extensions/inference-nitro-extension/download.sh @@ -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 diff --git a/extensions/inference-nitro-extension/package.json b/extensions/inference-nitro-extension/package.json index 7be4be69a..425e4b49c 100644 --- a/extensions/inference-nitro-extension/package.json +++ b/extensions/inference-nitro-extension/package.json @@ -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 ", @@ -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" ] } diff --git a/extensions/inference-nitro-extension/resources/models/aya-23-8b/model.json b/extensions/inference-nitro-extension/resources/models/aya-23-8b/model.json index b82cf2f39..163373014 100644 --- a/extensions/inference-nitro-extension/resources/models/aya-23-8b/model.json +++ b/extensions/inference-nitro-extension/resources/models/aya-23-8b/model.json @@ -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" diff --git a/extensions/inference-nitro-extension/resources/models/deepseek-coder-1.3b/model.json b/extensions/inference-nitro-extension/resources/models/deepseek-coder-1.3b/model.json index 53f7f43e9..36fceaad2 100644 --- a/extensions/inference-nitro-extension/resources/models/deepseek-coder-1.3b/model.json +++ b/extensions/inference-nitro-extension/resources/models/deepseek-coder-1.3b/model.json @@ -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": { diff --git a/extensions/inference-nitro-extension/resources/models/deepseek-coder-34b/model.json b/extensions/inference-nitro-extension/resources/models/deepseek-coder-34b/model.json index 0a3e58b48..103c4cbcb 100644 --- a/extensions/inference-nitro-extension/resources/models/deepseek-coder-34b/model.json +++ b/extensions/inference-nitro-extension/resources/models/deepseek-coder-34b/model.json @@ -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": { diff --git a/extensions/inference-nitro-extension/resources/models/gemma-1.1-7b/model.json b/extensions/inference-nitro-extension/resources/models/gemma-1.1-7b/model.json index a532c1dc3..b29043483 100644 --- a/extensions/inference-nitro-extension/resources/models/gemma-1.1-7b/model.json +++ b/extensions/inference-nitro-extension/resources/models/gemma-1.1-7b/model.json @@ -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" diff --git a/extensions/inference-nitro-extension/resources/models/llama3-8b-instruct/model.json b/extensions/inference-nitro-extension/resources/models/llama3-8b-instruct/model.json index ced7e1ca8..4d84b9967 100644 --- a/extensions/inference-nitro-extension/resources/models/llama3-8b-instruct/model.json +++ b/extensions/inference-nitro-extension/resources/models/llama3-8b-instruct/model.json @@ -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" diff --git a/extensions/inference-nitro-extension/resources/models/llama3.1-70b-instruct/model.json b/extensions/inference-nitro-extension/resources/models/llama3.1-70b-instruct/model.json index 4d8eab7e3..780ef8465 100644 --- a/extensions/inference-nitro-extension/resources/models/llama3.1-70b-instruct/model.json +++ b/extensions/inference-nitro-extension/resources/models/llama3.1-70b-instruct/model.json @@ -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": { diff --git a/extensions/inference-nitro-extension/resources/models/llama3.1-8b-instruct/model.json b/extensions/inference-nitro-extension/resources/models/llama3.1-8b-instruct/model.json index fe44b0b1c..9de8555be 100644 --- a/extensions/inference-nitro-extension/resources/models/llama3.1-8b-instruct/model.json +++ b/extensions/inference-nitro-extension/resources/models/llama3.1-8b-instruct/model.json @@ -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": { diff --git a/extensions/inference-nitro-extension/resources/models/mistral-ins-7b-q4/model.json b/extensions/inference-nitro-extension/resources/models/mistral-ins-7b-q4/model.json index d223306f8..88f701466 100644 --- a/extensions/inference-nitro-extension/resources/models/mistral-ins-7b-q4/model.json +++ b/extensions/inference-nitro-extension/resources/models/mistral-ins-7b-q4/model.json @@ -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" }, diff --git a/extensions/inference-nitro-extension/resources/models/phi3-3.8b/model.json b/extensions/inference-nitro-extension/resources/models/phi3-3.8b/model.json index 2a572db92..6459b049d 100644 --- a/extensions/inference-nitro-extension/resources/models/phi3-3.8b/model.json +++ b/extensions/inference-nitro-extension/resources/models/phi3-3.8b/model.json @@ -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" - } \ No newline at end of file +} \ No newline at end of file diff --git a/extensions/inference-nitro-extension/resources/models/phi3-medium/model.json b/extensions/inference-nitro-extension/resources/models/phi3-medium/model.json index ac83ca077..50944b9fe 100644 --- a/extensions/inference-nitro-extension/resources/models/phi3-medium/model.json +++ b/extensions/inference-nitro-extension/resources/models/phi3-medium/model.json @@ -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 }, diff --git a/extensions/inference-nitro-extension/resources/models/qwen2-7b/model.json b/extensions/inference-nitro-extension/resources/models/qwen2-7b/model.json index 8939a98f3..a7613982c 100644 --- a/extensions/inference-nitro-extension/resources/models/qwen2-7b/model.json +++ b/extensions/inference-nitro-extension/resources/models/qwen2-7b/model.json @@ -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": { diff --git a/extensions/inference-nitro-extension/rollup.config.ts b/extensions/inference-nitro-extension/rollup.config.ts index fdd11f961..4e1731a09 100644 --- a/extensions/inference-nitro-extension/rollup.config.ts +++ b/extensions/inference-nitro-extension/rollup.config.ts @@ -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(), ], diff --git a/extensions/inference-nitro-extension/src/index.ts b/extensions/inference-nitro-extension/src/index.ts index a027e8844..d79e076d4 100644 --- a/extensions/inference-nitro-extension/src/index.ts +++ b/extensions/inference-nitro-extension/src/index.ts @@ -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, diff --git a/extensions/inference-nitro-extension/src/node/execute.test.ts b/extensions/inference-nitro-extension/src/node/execute.test.ts index cf9e84acf..dfd8b35a9 100644 --- a/extensions/inference-nitro-extension/src/node/execute.test.ts +++ b/extensions/inference-nitro-extension/src/node/execute.test.ts @@ -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: '', + }) + ) + }) + }) }) diff --git a/extensions/inference-nitro-extension/src/node/execute.ts b/extensions/inference-nitro-extension/src/node/execute.ts index 417734afa..595063ed4 100644 --- a/extensions/inference-nitro-extension/src/node/execute.ts +++ b/extensions/inference-nitro-extension/src/node/execute.ts @@ -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, } diff --git a/extensions/inference-nitro-extension/src/node/index.test.ts b/extensions/inference-nitro-extension/src/node/index.test.ts new file mode 100644 index 000000000..6e64b4a06 --- /dev/null +++ b/extensions/inference-nitro-extension/src/node/index.test.ts @@ -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() + }) +}) diff --git a/extensions/inference-nitro-extension/src/node/index.ts b/extensions/inference-nitro-extension/src/node/index.ts index 77ac9af7a..edc2d013d 100644 --- a/extensions/inference-nitro-extension/src/node/index.ts +++ b/extensions/inference-nitro-extension/src/node/index.ts @@ -263,10 +263,10 @@ async function validateModelStatus(modelId: string): Promise { 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 { log(`[CORTEX]::Debug: Spawning cortex subprocess...`) return new Promise(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 { 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 ) } diff --git a/extensions/inference-nitro-extension/tsconfig.json b/extensions/inference-nitro-extension/tsconfig.json index bada43fc7..19d8572b5 100644 --- a/extensions/inference-nitro-extension/tsconfig.json +++ b/extensions/inference-nitro-extension/tsconfig.json @@ -15,5 +15,6 @@ "importHelpers": true, "typeRoots": ["node_modules/@types"] }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/**/*.test.ts"] } diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 000000000..a911a7f0a --- /dev/null +++ b/jest.config.js @@ -0,0 +1,3 @@ +module.exports = { + projects: ['/core', '/web', '/joi'], +} diff --git a/joi/jest.config.js b/joi/jest.config.js new file mode 100644 index 000000000..8543f24e3 --- /dev/null +++ b/joi/jest.config.js @@ -0,0 +1,8 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: ['**/*.test.*'], + setupFilesAfterEnv: ['/jest.setup.js'], + testEnvironment: 'jsdom', +} diff --git a/joi/jest.setup.js b/joi/jest.setup.js new file mode 100644 index 000000000..e69de29bb diff --git a/joi/package.json b/joi/package.json index 3f1bd07f7..576c33d72 100644 --- a/joi/package.json +++ b/joi/package.json @@ -21,7 +21,8 @@ "bugs": "https://github.com/codecentrum/piksel/issues", "scripts": { "dev": "rollup -c -w", - "build": "rimraf ./dist && rollup -c" + "build": "rimraf ./dist && rollup -c", + "test": "jest" }, "peerDependencies": { "class-variance-authority": "^0.7.0", @@ -38,13 +39,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", diff --git a/joi/src/core/Accordion/Accordion.test.tsx b/joi/src/core/Accordion/Accordion.test.tsx new file mode 100644 index 000000000..62b575ea3 --- /dev/null +++ b/joi/src/core/Accordion/Accordion.test.tsx @@ -0,0 +1,64 @@ +import React from 'react' +import '@testing-library/jest-dom' +import { render, screen, fireEvent } from '@testing-library/react' +import { Accordion, AccordionItem } from './index' + +// Mock the SCSS import +jest.mock('./styles.scss', () => ({})) + +describe('Accordion', () => { + it('renders accordion with items', () => { + render( + + + Content 1 + + + Content 2 + + + ) + + expect(screen.getByText('Item 1')).toBeInTheDocument() + expect(screen.getByText('Item 2')).toBeInTheDocument() + }) + + it('expands and collapses accordion items', () => { + render( + + + Content 1 + + + ) + + const trigger = screen.getByText('Item 1') + + // Initially, content should not be visible + expect(screen.queryByText('Content 1')).not.toBeInTheDocument() + + // Click to expand + fireEvent.click(trigger) + expect(screen.getByText('Content 1')).toBeInTheDocument() + + // Click to collapse + fireEvent.click(trigger) + expect(screen.queryByText('Content 1')).not.toBeInTheDocument() + }) + + it('respects defaultValue prop', () => { + render( + + + Content 1 + + + Content 2 + + + ) + + expect(screen.queryByText('Content 1')).not.toBeInTheDocument() + expect(screen.getByText('Content 2')).toBeInTheDocument() + }) +}) diff --git a/joi/src/core/Badge/Badge.test.tsx b/joi/src/core/Badge/Badge.test.tsx new file mode 100644 index 000000000..1d3192be7 --- /dev/null +++ b/joi/src/core/Badge/Badge.test.tsx @@ -0,0 +1,83 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import { Badge, badgeConfig } from './index' + +// Mock the styles +jest.mock('./styles.scss', () => ({})) + +describe('@joi/core/Badge', () => { + it('renders with default props', () => { + render(Test Badge) + const badge = screen.getByText('Test Badge') + expect(badge).toBeInTheDocument() + expect(badge).toHaveClass('badge') + expect(badge).toHaveClass('badge--primary') + expect(badge).toHaveClass('badge--medium') + expect(badge).toHaveClass('badge--solid') + }) + + it('applies custom className', () => { + render(Test Badge) + const badge = screen.getByText('Test Badge') + expect(badge).toHaveClass('custom-class') + }) + + it('renders with different themes', () => { + const themes = Object.keys(badgeConfig.variants.theme) + themes.forEach((theme) => { + render(Test Badge {theme}) + const badge = screen.getByText(`Test Badge ${theme}`) + expect(badge).toHaveClass(`badge--${theme}`) + }) + }) + + it('renders with different variants', () => { + const variants = Object.keys(badgeConfig.variants.variant) + variants.forEach((variant) => { + render(Test Badge {variant}) + const badge = screen.getByText(`Test Badge ${variant}`) + expect(badge).toHaveClass(`badge--${variant}`) + }) + }) + + it('renders with different sizes', () => { + const sizes = Object.keys(badgeConfig.variants.size) + sizes.forEach((size) => { + render(Test Badge {size}) + const badge = screen.getByText(`Test Badge ${size}`) + expect(badge).toHaveClass(`badge--${size}`) + }) + }) + + it('fails when a new theme is added without updating the test', () => { + const expectedThemes = [ + 'primary', + 'secondary', + 'warning', + 'success', + 'info', + 'destructive', + ] + const actualThemes = Object.keys(badgeConfig.variants.theme) + expect(actualThemes).toEqual(expectedThemes) + }) + + it('fails when a new variant is added without updating the test', () => { + const expectedVariant = ['solid', 'soft', 'outline'] + const actualVariants = Object.keys(badgeConfig.variants.variant) + expect(actualVariants).toEqual(expectedVariant) + }) + + it('fails when a new size is added without updating the test', () => { + const expectedSizes = ['small', 'medium', 'large'] + const actualSizes = Object.keys(badgeConfig.variants.size) + expect(actualSizes).toEqual(expectedSizes) + }) + + it('fails when a new variant CVA is added without updating the test', () => { + const expectedVariantsCVA = ['theme', 'variant', 'size'] + const actualVariant = Object.keys(badgeConfig.variants) + expect(actualVariant).toEqual(expectedVariantsCVA) + }) +}) diff --git a/joi/src/core/Badge/index.tsx b/joi/src/core/Badge/index.tsx index ffc34624f..d9b04fd2b 100644 --- a/joi/src/core/Badge/index.tsx +++ b/joi/src/core/Badge/index.tsx @@ -6,7 +6,7 @@ import { twMerge } from 'tailwind-merge' import './styles.scss' -const badgeVariants = cva('badge', { +export const badgeConfig = { variants: { theme: { primary: 'badge--primary', @@ -28,11 +28,13 @@ const badgeVariants = cva('badge', { }, }, defaultVariants: { - theme: 'primary', - size: 'medium', - variant: 'solid', + theme: 'primary' as const, + size: 'medium' as const, + variant: 'solid' as const, }, -}) +} + +const badgeVariants = cva('badge', badgeConfig) export interface BadgeProps extends HTMLAttributes, diff --git a/joi/src/core/Button/Button.test.tsx b/joi/src/core/Button/Button.test.tsx new file mode 100644 index 000000000..a4c679773 --- /dev/null +++ b/joi/src/core/Button/Button.test.tsx @@ -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() + 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() + const badge = screen.getByText('Test Button') + expect(badge).toHaveClass('custom-class') + }) + + it('renders as a child component when asChild is true', () => { + render( + + ) + const link = screen.getByRole('link', { name: /link button/i }) + expect(link).toBeInTheDocument() + expect(link).toHaveClass('btn btn--primary btn--medium btn--solid') + }) + + it.each(Object.keys(buttonConfig.variants.theme))( + 'renders with theme %s', + (theme) => { + render() + const button = screen.getByRole('button', { name: /theme button/i }) + expect(button).toHaveClass(`btn btn--${theme}`) + } + ) + + it.each(Object.keys(buttonConfig.variants.variant))( + 'renders with variant %s', + (variant) => { + render() + const button = screen.getByRole('button', { name: /variant button/i }) + expect(button).toHaveClass(`btn btn--${variant}`) + } + ) + + it.each(Object.keys(buttonConfig.variants.size))( + 'renders with size %s', + (size) => { + render() + const button = screen.getByRole('button', { name: /size button/i }) + expect(button).toHaveClass(`btn btn--${size}`) + } + ) + + it('renders with block prop', () => { + render() + const button = screen.getByRole('button', { name: /block button/i }) + expect(button).toHaveClass('btn btn--block') + }) + + it('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) + }) +}) diff --git a/joi/src/core/Button/index.tsx b/joi/src/core/Button/index.tsx index 014f534b0..9945eb4e9 100644 --- a/joi/src/core/Button/index.tsx +++ b/joi/src/core/Button/index.tsx @@ -7,7 +7,7 @@ import { twMerge } from 'tailwind-merge' import './styles.scss' -const buttonVariants = cva('btn', { +export const buttonConfig = { variants: { theme: { primary: 'btn--primary', @@ -30,12 +30,13 @@ const buttonVariants = cva('btn', { }, }, defaultVariants: { - theme: 'primary', - size: 'medium', - variant: 'solid', - block: false, + theme: 'primary' as const, + size: 'medium' as const, + variant: 'solid' as const, + block: false as const, }, -}) +} +const buttonVariants = cva('btn', buttonConfig) export interface ButtonProps extends ButtonHTMLAttributes, diff --git a/joi/src/core/Checkbox/Checkbox.test.tsx b/joi/src/core/Checkbox/Checkbox.test.tsx new file mode 100644 index 000000000..ce81132d9 --- /dev/null +++ b/joi/src/core/Checkbox/Checkbox.test.tsx @@ -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() + expect(screen.getByLabelText('Test Checkbox')).toBeInTheDocument() + }) + + it('renders with helper description', () => { + render() + expect(screen.getByText('Helper text')).toBeInTheDocument() + }) + + it('renders error message when provided', () => { + render() + expect(screen.getByText('Error occurred')).toBeInTheDocument() + }) + + it('calls onChange when clicked', () => { + const mockOnChange = jest.fn() + render( + + ) + + fireEvent.click(screen.getByLabelText('Test Checkbox')) + expect(mockOnChange).toHaveBeenCalledTimes(1) + }) + + it('applies custom className', () => { + render() + expect(screen.getByRole('checkbox').parentElement).toHaveClass( + 'custom-class' + ) + }) + + it('disables the checkbox when disabled prop is true', () => { + render() + expect(screen.getByLabelText('Disabled Checkbox')).toBeDisabled() + }) +}) diff --git a/joi/src/core/Input/Input.test.tsx b/joi/src/core/Input/Input.test.tsx new file mode 100644 index 000000000..55bed74bb --- /dev/null +++ b/joi/src/core/Input/Input.test.tsx @@ -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() + expect(screen.getByPlaceholderText('Test input')).toBeInTheDocument() + }) + + it('applies custom className', () => { + render() + expect(screen.getByRole('textbox')).toHaveClass('custom-class') + }) + + it('aligns text to the right when textAlign prop is set', () => { + render() + expect(screen.getByRole('textbox')).toHaveClass('text-right') + }) + + it('renders prefix icon when provided', () => { + render(Prefix} />) + expect(screen.getByTestId('prefix-icon')).toBeInTheDocument() + }) + + it('renders suffix icon when provided', () => { + render(Suffix} />) + expect(screen.getByTestId('suffix-icon')).toBeInTheDocument() + }) + + it('renders clear icon when clearable is true', () => { + render() + expect(screen.getByTestId('cross-2-icon')).toBeInTheDocument() + }) + + it('calls onClick when input is clicked', () => { + const onClick = jest.fn() + render() + fireEvent.click(screen.getByRole('textbox')) + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('calls onClear when clear icon is clicked', () => { + const onClear = jest.fn() + render() + fireEvent.click(screen.getByTestId('cross-2-icon')) + expect(onClear).toHaveBeenCalledTimes(1) + }) +}) diff --git a/joi/src/core/Input/index.tsx b/joi/src/core/Input/index.tsx index 99b7fe8ab..9f5e4c663 100644 --- a/joi/src/core/Input/index.tsx +++ b/joi/src/core/Input/index.tsx @@ -42,7 +42,7 @@ const Input = forwardRef( )} {clearable && (
- +
)} ({})) + +describe('Modal', () => { + it('renders the modal with trigger and content', () => { + render( + Open Modal} + content={
Modal Content
} + /> + ) + + 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( + Open Modal} + content={
Modal Content
} + title="Modal Title" + /> + ) + + fireEvent.click(screen.getByText('Open Modal')) + expect(screen.getByText('Modal Title')).toBeInTheDocument() + }) + + it('renders full page modal', () => { + render( + Open Modal} + content={
Modal Content
} + fullPage + /> + ) + + fireEvent.click(screen.getByText('Open Modal')) + expect(screen.getByRole('dialog')).toHaveClass('modal__content--fullpage') + }) + + it('hides close button when hideClose is true', () => { + render( + Open Modal} + content={
Modal Content
} + 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( + Open Modal} + content={
Modal Content
} + onOpenChange={onOpenChangeMock} + /> + ) + + fireEvent.click(screen.getByText('Open Modal')) + expect(onOpenChangeMock).toHaveBeenCalledWith(true) + + fireEvent.click(screen.getByLabelText('Close')) + expect(onOpenChangeMock).toHaveBeenCalledWith(false) + }) +}) diff --git a/joi/src/core/Progress/Progress.test.tsx b/joi/src/core/Progress/Progress.test.tsx new file mode 100644 index 000000000..9d18bf019 --- /dev/null +++ b/joi/src/core/Progress/Progress.test.tsx @@ -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() + 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() + const progressElement = screen.getByRole('progressbar') + expect(progressElement).toHaveClass('custom-class') + }) + + it('renders with different sizes', () => { + const { rerender } = render() + let progressElement = screen.getByRole('progressbar') + expect(progressElement).toHaveClass('progress--small') + + rerender() + progressElement = screen.getByRole('progressbar') + expect(progressElement).toHaveClass('progress--large') + }) + + it('sets the correct transform style based on value', () => { + render() + 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() + let progressElement = screen.getByRole('progressbar') + let indicatorElement = progressElement.firstChild as HTMLElement + expect(indicatorElement).toHaveStyle('transform: translateX(-100%)') + expect(progressElement).toHaveAttribute('aria-valuenow', '0') + + rerender() + progressElement = screen.getByRole('progressbar') + indicatorElement = progressElement.firstChild as HTMLElement + expect(indicatorElement).toHaveStyle('transform: translateX(-0%)') + expect(progressElement).toHaveAttribute('aria-valuenow', '100') + }) +}) diff --git a/joi/src/core/Progress/index.tsx b/joi/src/core/Progress/index.tsx index 51ea79c81..01aefbeb0 100644 --- a/joi/src/core/Progress/index.tsx +++ b/joi/src/core/Progress/index.tsx @@ -27,7 +27,14 @@ export interface ProgressProps const Progress = ({ className, size, value, ...props }: ProgressProps) => { return ( -
+
({})) + +class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} +} + +global.ResizeObserver = ResizeObserverMock + +describe('@joi/core/ScrollArea', () => { + it('renders children correctly', () => { + render( + +
Test Content
+
+ ) + + const child = screen.getByTestId('child') + expect(child).toBeInTheDocument() + expect(child).toHaveTextContent('Test Content') + }) + + it('applies custom className', () => { + const { container } = render() + + 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() + render() + + expect(ref.current).toBeInstanceOf(HTMLDivElement) + expect(ref.current).toHaveClass('scroll-area__viewport') + }) +}) diff --git a/joi/src/core/Select/Select.test.tsx b/joi/src/core/Select/Select.test.tsx new file mode 100644 index 000000000..1b450706b --- /dev/null +++ b/joi/src/core/Select/Select.test.tsx @@ -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
{children}
+ }, + Trigger: ({ + children, + className, + }: { + children: React.ReactNode + className?: string + }) => ( + + ), + Value: ({ placeholder }: { placeholder?: string }) => ( + {placeholder} + ), + Icon: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + Portal: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + Content: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + Viewport: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + Item: ({ children, value }: { children: React.ReactNode; value: string }) => ( +
mockOnValueChange(value)} + > + {children} +
+ ), + ItemText: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + ItemIndicator: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + Arrow: () =>
, +})) +describe('@joi/core/Select', () => { + const options = [ + { name: 'Option 1', value: 'option1' }, + { name: 'Option 2', value: 'option2' }, + ] + + it('renders with placeholder', () => { + render() + 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() + expect(screen.getByTestId('select-trigger')).toHaveClass('select__disabled') + }) + + it('applies block class when block prop is true', () => { + render( + {children} +
+ ), + Track: ({ children }: any) => ( +
{children}
+ ), + Range: () =>
, + Thumb: () =>
, +})) + +describe('@joi/core/Slider', () => { + it('renders correctly with default props', () => { + render() + 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() + 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() + const input = screen.getByTestId('slider-root').querySelector('input') + fireEvent.change(input!, { target: { value: '75' } }) + expect(onValueChange).toHaveBeenCalledWith([75]) + }) +}) diff --git a/joi/src/core/Switch/Switch.test.tsx b/joi/src/core/Switch/Switch.test.tsx new file mode 100644 index 000000000..72f3d8007 --- /dev/null +++ b/joi/src/core/Switch/Switch.test.tsx @@ -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() + const checkbox = getByRole('checkbox') + expect(checkbox).toBeInTheDocument() + }) + + it('applies custom className', () => { + const { container } = render() + expect(container.firstChild).toHaveClass('switch custom-class') + }) + + it('can be checked and unchecked', () => { + const { getByRole } = render() + 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() + const checkbox = getByRole('checkbox') as HTMLInputElement + expect(checkbox).toBeDisabled() + }) + + it('calls onChange when clicked', () => { + const handleChange = jest.fn() + const { getByRole } = render() + const checkbox = getByRole('checkbox') + + fireEvent.click(checkbox) + expect(handleChange).toHaveBeenCalledTimes(1) + }) + + it('can have a default checked state', () => { + const { getByRole } = render() + const checkbox = getByRole('checkbox') as HTMLInputElement + expect(checkbox.checked).toBe(true) + }) +}) diff --git a/joi/src/core/Tabs/Tabs.test.tsx b/joi/src/core/Tabs/Tabs.test.tsx new file mode 100644 index 000000000..b6dcf8a7b --- /dev/null +++ b/joi/src/core/Tabs/Tabs.test.tsx @@ -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 }) => ( +
+ {trigger || children} +
+ ), +})) + +// 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( + {}}> + Content 1 + Content 2 + Content 3 + + ) + + 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( + {}}> + Content 1 + Content 2 + Content 3 + + ) + + 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( + {}}> + Content 1 + Content 2 + Content 3 + + ) + + expect(screen.queryByText('Content 1')).not.toBeInTheDocument() + expect(screen.getByText('Content 2')).toBeInTheDocument() + }) + + it('disables tab when specified', () => { + render( + {}}> + Content 1 + Content 2 + Content 3 + + ) + + expect(screen.getByText('Tab 3')).toHaveAttribute('disabled') + }) + + it('renders tooltip for disabled tab', () => { + render( + {}}> + Content 1 + Content 2 + Content 3 + + ) + + const tooltipWrapper = screen.getByTestId('mock-tooltip') + expect(tooltipWrapper).toHaveAttribute( + 'data-tooltip-content', + 'Disabled tab' + ) + }) +}) diff --git a/joi/src/core/Tabs/index.tsx b/joi/src/core/Tabs/index.tsx index edec179f1..af004e2ba 100644 --- a/joi/src/core/Tabs/index.tsx +++ b/joi/src/core/Tabs/index.tsx @@ -2,10 +2,18 @@ import React, { ReactNode } from 'react' import * as TabsPrimitive from '@radix-ui/react-tabs' +import { Tooltip } from '../Tooltip' + import './styles.scss' +import { twMerge } from 'tailwind-merge' type TabsProps = { - options: { name: string; value: string }[] + options: { + name: string + value: string + disabled?: boolean + tooltipContent?: string + }[] children: ReactNode defaultValue?: string value: string @@ -15,11 +23,15 @@ type TabsProps = { type TabsContentProps = { value: string children: ReactNode + className?: string } -const TabsContent = ({ value, children }: TabsContentProps) => { +const TabsContent = ({ value, children, className }: TabsContentProps) => { return ( - + {children} ) @@ -40,11 +52,27 @@ const Tabs = ({ > {options.map((option, i) => { - return ( + return option.disabled ? ( + + {option.name} + + } + /> + ) : ( {option.name} diff --git a/joi/src/core/Tabs/styles.scss b/joi/src/core/Tabs/styles.scss index 86948ab5a..a24585b4e 100644 --- a/joi/src/core/Tabs/styles.scss +++ b/joi/src/core/Tabs/styles.scss @@ -21,6 +21,10 @@ &:focus { position: relative; } + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } } &__content { diff --git a/joi/src/core/TextArea/TextArea.test.tsx b/joi/src/core/TextArea/TextArea.test.tsx new file mode 100644 index 000000000..8bc64010f --- /dev/null +++ b/joi/src/core/TextArea/TextArea.test.tsx @@ -0,0 +1,34 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import { TextArea } from './index' + +// Mock the styles import +jest.mock('./styles.scss', () => ({})) + +describe('@joi/core/TextArea', () => { + it('renders correctly', () => { + render(