From 49166d020980bf9d8fb93beb1d6adbacbe3fe458 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Wed, 28 Aug 2024 20:20:12 +0700 Subject: [PATCH 01/31] feat: track last state left and right panel (#3477) --- web/containers/Providers/Responsive.tsx | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/web/containers/Providers/Responsive.tsx b/web/containers/Providers/Responsive.tsx index 4c9e87ab4..940cb68fb 100644 --- a/web/containers/Providers/Responsive.tsx +++ b/web/containers/Providers/Responsive.tsx @@ -1,24 +1,33 @@ -import { Fragment, PropsWithChildren, useEffect } from 'react' +import { Fragment, PropsWithChildren, useEffect, useRef } from 'react' import { useMediaQuery } from '@janhq/joi' - -import { useSetAtom } from 'jotai' +import { useAtom } from 'jotai' import { showLeftPanelAtom, showRightPanelAtom } from '@/helpers/atoms/App.atom' const Responsive = ({ children }: PropsWithChildren) => { const matches = useMediaQuery('(max-width: 880px)') - const setShowLeftPanel = useSetAtom(showLeftPanelAtom) - const setShowRightPanel = useSetAtom(showRightPanelAtom) + const [showLeftPanel, setShowLeftPanel] = useAtom(showLeftPanelAtom) + const [showRightPanel, setShowRightPanel] = useAtom(showRightPanelAtom) + + // Refs to store the last known state of the panels + const lastLeftPanelState = useRef(true) + const lastRightPanelState = useRef(true) useEffect(() => { if (matches) { + // Store the last known state before closing the panels + lastLeftPanelState.current = showLeftPanel + lastRightPanelState.current = showRightPanel + setShowLeftPanel(false) setShowRightPanel(false) } else { - setShowLeftPanel(true) - setShowRightPanel(true) + // Restore the last known state when the screen is resized back + setShowLeftPanel(lastLeftPanelState.current) + setShowRightPanel(lastRightPanelState.current) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [matches, setShowLeftPanel, setShowRightPanel]) return {children} From e572feff91c929b9ae5a366110bf0f5b278d5114 Mon Sep 17 00:00:00 2001 From: Van Pham <64197333+Van-QA@users.noreply.github.com> Date: Fri, 30 Aug 2024 17:09:29 +0700 Subject: [PATCH 02/31] Delete .github/ISSUE_TEMPLATE/documentation-request.md and epic-request.md (#3490) * Delete .github/ISSUE_TEMPLATE/documentation-request.md * Delete .github/ISSUE_TEMPLATE/epic-request.md * Update feature_request.yml * Update feature_request.yml --- .../ISSUE_TEMPLATE/documentation-request.md | 17 ------------- .github/ISSUE_TEMPLATE/epic-request.md | 25 ------------------- .github/ISSUE_TEMPLATE/feature_request.yml | 18 ++++++------- 3 files changed, 8 insertions(+), 52 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/documentation-request.md delete mode 100644 .github/ISSUE_TEMPLATE/epic-request.md 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..6328a2ce2 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -19,8 +19,8 @@ body: 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: "Background" + description: "Briefly describe the context or problem that led to this feature request" placeholder: | I'm always frustrated when ... @@ -28,17 +28,15 @@ body: validations: required: true attributes: - label: "Describe the solution" - description: "Description of what you want to happen. Add any considered drawbacks" + label: "Feature request" + description: "Clearly describe the feature or improvement you're proposing" - 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?" + label: "Proposed Implementation" + description: "If you have ideas on how this could be implemented, describe them here" - 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: "Additional Notes" + description: "Any other relevant information, considerations, or screenshots" From 7d12de1c4ae96402f441a8540c407106764a4fec Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Tue, 3 Sep 2024 09:53:15 +0700 Subject: [PATCH 03/31] fix: electron window on windows (#3480) --- electron/managers/mainWindowConfig.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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', From 7d6fd658f4164bd83d3191fceb28b0a4c071c33f Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Tue, 3 Sep 2024 09:53:29 +0700 Subject: [PATCH 04/31] feat: starter screen (#3494) * feat: starter screen * chore: fix unused import * chore: update see all color --- web/containers/Layout/RibbonPanel/index.tsx | 13 +- .../ChatBody/EmptyModel/index.tsx | 323 ++++++++++++++++-- .../ThreadCenterPanel/ChatBody/index.tsx | 5 +- web/screens/Thread/index.tsx | 81 ++++- 4 files changed, 395 insertions(+), 27 deletions(-) diff --git a/web/containers/Layout/RibbonPanel/index.tsx b/web/containers/Layout/RibbonPanel/index.tsx index b9b1434ae..41ceea8e3 100644 --- a/web/containers/Layout/RibbonPanel/index.tsx +++ b/web/containers/Layout/RibbonPanel/index.tsx @@ -12,9 +12,12 @@ import { twMerge } from 'tailwind-merge' import { MainViewState } from '@/constants/screens' +import { localEngines } from '@/utils/modelEngine' + import { mainViewStateAtom, showLeftPanelAtom } from '@/helpers/atoms/App.atom' import { editMessageAtom } from '@/helpers/atoms/ChatMessage.atom' import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom' +import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom' import { reduceTransparentAtom, selectedSettingAtom, @@ -28,6 +31,7 @@ export default function RibbonPanel() { const matches = useMediaQuery('(max-width: 880px)') const reduceTransparent = useAtomValue(reduceTransparentAtom) const setSelectedSetting = useSetAtom(selectedSettingAtom) + const downloadedModels = useAtomValue(downloadedModelsAtom) const onMenuClick = (state: MainViewState) => { if (mainViewState === state) return @@ -37,6 +41,10 @@ export default function RibbonPanel() { setEditMessage('') } + const isDownloadALocalModel = downloadedModels.some((x) => + localEngines.includes(x.engine) + ) + const RibbonNavMenus = [ { name: 'Thread', @@ -77,7 +85,10 @@ export default function RibbonPanel() { 'border-none', !showLeftPanel && !reduceTransparent && 'border-none', matches && !reduceTransparent && 'border-none', - reduceTransparent && ' bg-[hsla(var(--ribbon-panel-bg))]' + reduceTransparent && ' bg-[hsla(var(--ribbon-panel-bg))]', + mainViewState === MainViewState.Thread && + !isDownloadALocalModel && + 'border-none' )} > {RibbonNavMenus.filter((menu) => !!menu).map((menu, i) => { diff --git a/web/screens/Thread/ThreadCenterPanel/ChatBody/EmptyModel/index.tsx b/web/screens/Thread/ThreadCenterPanel/ChatBody/EmptyModel/index.tsx index 77913c991..2b179e744 100644 --- a/web/screens/Thread/ThreadCenterPanel/ChatBody/EmptyModel/index.tsx +++ b/web/screens/Thread/ThreadCenterPanel/ChatBody/EmptyModel/index.tsx @@ -1,32 +1,317 @@ -import { memo } from 'react' +import React, { Fragment, useState } from 'react' -import { Button } from '@janhq/joi' -import { useSetAtom } from 'jotai' +import Image from 'next/image' + +import { InferenceEngine } from '@janhq/core/.' +import { Button, Input, Progress, ScrollArea } from '@janhq/joi' + +import { useAtomValue, useSetAtom } from 'jotai' +import { SearchIcon, DownloadCloudIcon } from 'lucide-react' + +import { twMerge } from 'tailwind-merge' import LogoMark from '@/containers/Brand/Logo/Mark' +import CenterPanelContainer from '@/containers/CenterPanelContainer' + +import ProgressCircle from '@/containers/Loader/ProgressCircle' + +import ModelLabel from '@/containers/ModelLabel' import { MainViewState } from '@/constants/screens' -import { mainViewStateAtom } from '@/helpers/atoms/App.atom' +import useDownloadModel from '@/hooks/useDownloadModel' -const EmptyModel = () => { +import { modelDownloadStateAtom } from '@/hooks/useDownloadState' + +import { formatDownloadPercentage, toGibibytes } from '@/utils/converter' +import { + getLogoEngine, + getTitleByEngine, + localEngines, +} from '@/utils/modelEngine' + +import { mainViewStateAtom } from '@/helpers/atoms/App.atom' +import { + configuredModelsAtom, + getDownloadingModelAtom, +} from '@/helpers/atoms/Model.atom' +import { selectedSettingAtom } from '@/helpers/atoms/Setting.atom' + +type Props = { + extensionHasSettings: { + name?: string + setting: string + apiKey: string + provider: string + }[] +} + +const OnDeviceStarterScreen = ({ extensionHasSettings }: Props) => { + const [searchValue, setSearchValue] = useState('') + const downloadingModels = useAtomValue(getDownloadingModelAtom) + const { downloadModel } = useDownloadModel() + const downloadStates = useAtomValue(modelDownloadStateAtom) + const setSelectedSetting = useSetAtom(selectedSettingAtom) + + const configuredModels = useAtomValue(configuredModelsAtom) const setMainViewState = useSetAtom(mainViewStateAtom) + const featuredModel = configuredModels.filter((x) => + x.metadata.tags.includes('Featured') + ) + + const remoteModel = configuredModels.filter( + (x) => !localEngines.includes(x.engine) + ) + + const filteredModels = configuredModels.filter((model) => { + return ( + localEngines.includes(model.engine) && + model.name.toLowerCase().includes(searchValue.toLowerCase()) + ) + }) + + const remoteModelEngine = remoteModel.map((x) => x.engine) + const groupByEngine = remoteModelEngine.filter(function (item, index) { + if (remoteModelEngine.indexOf(item) === index) return item + }) + + const itemsPerRow = 5 + + const getRows = (array: string[], itemsPerRow: number) => { + const rows = [] + for (let i = 0; i < array.length; i += itemsPerRow) { + rows.push(array.slice(i, i + itemsPerRow)) + } + return rows + } + + const rows = getRows(groupByEngine, itemsPerRow) + + const [visibleRows, setVisibleRows] = useState(1) + return ( -
- -

Welcome!

-

- You need to download your first model -

- -
+ + +
+
+ +

Select a model to start

+
+ +
+ setSearchValue(e.target.value)} + placeholder="Search..." + prefixIcon={} + /> +
+ {!filteredModels.length ? ( +
+

+ No Result Found +

+
+ ) : ( + filteredModels.map((model) => { + const isDownloading = downloadingModels.some( + (md) => md.id === model.id + ) + return ( +
+
+

+ {model.name} +

+ +
+
+ + {toGibibytes(model.metadata.size)} + + {!isDownloading ? ( + downloadModel(model)} + /> + ) : ( + Object.values(downloadStates) + .filter((x) => x.modelId === model.id) + .map((item) => ( + + )) + )} +
+
+ ) + }) + )} +
+
+
+

+ On-device Models +

+

{ + setMainViewState(MainViewState.Hub) + }} + > + See All +

+
+ + {featuredModel.slice(0, 2).map((featModel) => { + const isDownloading = downloadingModels.some( + (md) => md.id === featModel.id + ) + return ( +
+
+
{featModel.name}
+

+ {featModel.metadata.author} +

+
+ + {isDownloading ? ( +
+ {Object.values(downloadStates).map((item, i) => ( +
+ +
+
+ + {formatDownloadPercentage(item?.percent)} + +
+
+
+ ))} +
+ ) : ( + + )} +
+ ) + })} + +
+

+ Cloud Models +

+
+ +
+ {rows.slice(0, visibleRows).map((row, rowIndex) => { + return ( +
+ {row.map((remoteEngine) => { + const engineLogo = getLogoEngine( + remoteEngine as InferenceEngine + ) + + return ( +
{ + setMainViewState(MainViewState.Settings) + setSelectedSetting( + extensionHasSettings.find((x) => + x.name?.toLowerCase().includes(remoteEngine) + )?.setting as string + ) + }} + > + {engineLogo && ( + Engine logo + )} + +

+ {getTitleByEngine( + remoteEngine as InferenceEngine + )} +

+
+ ) + })} +
+ ) + })} +
+ {visibleRows < rows.length && ( + + )} +
+
+
+
+
+
) } -export default memo(EmptyModel) +export default OnDeviceStarterScreen diff --git a/web/screens/Thread/ThreadCenterPanel/ChatBody/index.tsx b/web/screens/Thread/ThreadCenterPanel/ChatBody/index.tsx index 5b5218bb9..6b3f4150a 100644 --- a/web/screens/Thread/ThreadCenterPanel/ChatBody/index.tsx +++ b/web/screens/Thread/ThreadCenterPanel/ChatBody/index.tsx @@ -11,18 +11,15 @@ import ChatItem from '../ChatItem' import LoadModelError from '../LoadModelError' -import EmptyModel from './EmptyModel' import EmptyThread from './EmptyThread' import { getCurrentChatMessagesAtom } from '@/helpers/atoms/ChatMessage.atom' -import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom' const ChatBody = () => { const messages = useAtomValue(getCurrentChatMessagesAtom) - const downloadedModels = useAtomValue(downloadedModelsAtom) + const loadModelError = useAtomValue(loadModelErrorAtom) - if (!downloadedModels.length) return if (!messages.length) return return ( diff --git a/web/screens/Thread/index.tsx b/web/screens/Thread/index.tsx index ef125e924..8b4db95ec 100644 --- a/web/screens/Thread/index.tsx +++ b/web/screens/Thread/index.tsx @@ -1,17 +1,92 @@ +import { useEffect, useState } from 'react' + +import { useAtomValue } from 'jotai' + import ThreadLeftPanel from '@/screens/Thread/ThreadLeftPanel' +import { localEngines } from '@/utils/modelEngine' + import ThreadCenterPanel from './ThreadCenterPanel' +import OnDeviceStarterScreen from './ThreadCenterPanel/ChatBody/EmptyModel' import ModalCleanThread from './ThreadLeftPanel/ModalCleanThread' import ModalDeleteThread from './ThreadLeftPanel/ModalDeleteThread' import ModalEditTitleThread from './ThreadLeftPanel/ModalEditTitleThread' import ThreadRightPanel from './ThreadRightPanel' +import { extensionManager } from '@/extension' +import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom' +import { threadsAtom } from '@/helpers/atoms/Thread.atom' + const ThreadScreen = () => { + const downloadedModels = useAtomValue(downloadedModelsAtom) + const threads = useAtomValue(threadsAtom) + + const isDownloadALocalModel = downloadedModels.some((x) => + localEngines.includes(x.engine) + ) + + const [extensionHasSettings, setExtensionHasSettings] = useState< + { name?: string; setting: string; apiKey: string; provider: string }[] + >([]) + + useEffect(() => { + const getAllSettings = async () => { + const extensionsMenu: { + name?: string + setting: string + apiKey: string + provider: string + }[] = [] + const extensions = extensionManager.getAll() + + for (const extension of extensions) { + if (typeof extension.getSettings === 'function') { + const settings = await extension.getSettings() + + if ( + (settings && settings.length > 0) || + (await extension.installationState()) !== 'NotRequired' + ) { + extensionsMenu.push({ + name: extension.productName, + setting: extension.name, + apiKey: + 'apiKey' in extension && typeof extension.apiKey === 'string' + ? extension.apiKey + : '', + provider: + 'provider' in extension && + typeof extension.provider === 'string' + ? extension.provider + : '', + }) + } + } + } + setExtensionHasSettings(extensionsMenu) + } + getAllSettings() + }, []) + + const isAnyRemoteModelConfigured = extensionHasSettings.some( + (x) => x.apiKey.length > 1 + ) + return (
- - - + {!isAnyRemoteModelConfigured && + !isDownloadALocalModel && + !threads.length ? ( + <> + + + ) : ( + <> + + + + + )} {/* Showing variant modal action for thread screen */} From 4e524106730010c0274f70423700f88df01aba99 Mon Sep 17 00:00:00 2001 From: Louis Date: Wed, 4 Sep 2024 20:43:35 +0700 Subject: [PATCH 05/31] chore: add test:coverage step --- jest.config.js | 3 +++ package.json | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 jest.config.js diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 000000000..eb1aab657 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,3 @@ +module.exports = { + projects: ['/core', '/web'], +} diff --git a/package.json b/package.json index c86405182..2785ee3b5 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ }, "scripts": { "lint": "yarn workspace jan lint && yarn workspace @janhq/web lint", - "test:unit": "yarn workspace @janhq/core test", + "test:unit": "jest", + "test:coverage": "jest --coverage --collectCoverageFrom='src/**/*.{ts,tsx}'", "test": "yarn workspace jan test:e2e", "test-local": "yarn lint && yarn build:test && yarn test", "pre-install:darwin": "find extensions -type f -path \"**/*.tgz\" -exec cp {} pre-install \\;", From 28c6ba10f198110ef20bf4d70967b172fd9c1bf2 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Wed, 4 Sep 2024 22:58:01 +0700 Subject: [PATCH 06/31] fix: using type text instead number for avoid region issue (#3538) --- web/containers/SliderRightPanel/index.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/web/containers/SliderRightPanel/index.tsx b/web/containers/SliderRightPanel/index.tsx index 3b2ef8d47..2a42ade61 100644 --- a/web/containers/SliderRightPanel/index.tsx +++ b/web/containers/SliderRightPanel/index.tsx @@ -28,8 +28,10 @@ const SliderRightPanel = ({ onValueChanged, }: Props) => { const [showTooltip, setShowTooltip] = useState({ max: false, min: false }) + const [val, setVal] = useState(value.toString()) useClickOutside(() => setShowTooltip({ max: false, min: false }), null, []) + return (
@@ -48,7 +50,10 @@ const SliderRightPanel = ({
onValueChanged?.(e[0])} + onValueChange={(e) => { + onValueChanged?.(e[0]) + setVal(e[0].toString()) + }} min={min} max={max} step={step} @@ -63,24 +68,29 @@ const SliderRightPanel = ({ open={showTooltip.max || showTooltip.min} trigger={ { if (Number(e.target.value) > Number(max)) { onValueChanged?.(Number(max)) + setVal(max.toString()) setShowTooltip({ max: true, min: false }) } else if (Number(e.target.value) < Number(min)) { onValueChanged?.(Number(min)) + setVal(min.toString()) setShowTooltip({ max: false, min: true }) } }} onChange={(e) => { - onValueChanged?.(Number(e.target.value)) + onValueChanged?.(e.target.value) + if (/^\d*\.?\d*$/.test(e.target.value)) { + setVal(e.target.value) + } }} /> } From e9b657ae4e87ef3074660a6b0aca6059c057c2d5 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Thu, 5 Sep 2024 09:49:47 +0700 Subject: [PATCH 07/31] feat: add setting options to dropdown menu (#3554) * feat: add setting options to dropdown menu * chore: update comment --- core/src/types/api/index.ts | 1 + electron/managers/window.ts | 9 +++++++++ electron/utils/menu.ts | 9 +++++++++ web/containers/Layout/index.tsx | 10 ++++++++++ 4 files changed, 29 insertions(+) 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/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/web/containers/Layout/index.tsx b/web/containers/Layout/index.tsx index 3b3f57a21..8a3f417f4 100644 --- a/web/containers/Layout/index.tsx +++ b/web/containers/Layout/index.tsx @@ -46,6 +46,16 @@ const BaseLayout = () => { } }, [setMainViewState]) + useEffect(() => { + window.electronAPI?.onMainViewStateChange( + (_event: string, route: string) => { + if (route === 'Settings') { + setMainViewState(MainViewState.Settings) + } + } + ) + }, [setMainViewState]) + return (
Date: Thu, 5 Sep 2024 09:54:03 +0700 Subject: [PATCH 08/31] fix: disabled UI RAG and tools (#3514) * fix: UI RAG & tools do not support for remote models * chore: update dependencies hooks --- joi/src/core/Tabs/index.tsx | 36 ++++++++++++++++--- joi/src/core/Tabs/styles.scss | 4 +++ web/containers/ModelDropdown/index.tsx | 34 +++++++++++++++++- .../ThreadCenterPanel/ChatInput/index.tsx | 18 ++++++---- web/screens/Thread/ThreadRightPanel/index.tsx | 22 ++++++++++-- 5 files changed, 100 insertions(+), 14 deletions(-) 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/web/containers/ModelDropdown/index.tsx b/web/containers/ModelDropdown/index.tsx index f8920a6dd..d57119b13 100644 --- a/web/containers/ModelDropdown/index.tsx +++ b/web/containers/ModelDropdown/index.tsx @@ -2,7 +2,7 @@ import { useState, useMemo, useEffect, useCallback, useRef } from 'react' import Image from 'next/image' -import { InferenceEngine } from '@janhq/core' +import { InferenceEngine, Model } from '@janhq/core' import { Badge, Button, @@ -28,6 +28,7 @@ import ModelLabel from '@/containers/ModelLabel' import SetupRemoteModel from '@/containers/SetupRemoteModel' +import { useCreateNewThread } from '@/hooks/useCreateNewThread' import useDownloadModel from '@/hooks/useDownloadModel' import { modelDownloadStateAtom } from '@/hooks/useDownloadState' import useRecommendedModel from '@/hooks/useRecommendedModel' @@ -92,6 +93,8 @@ const ModelDropdown = ({ ) const preserveModelSettings = useAtomValue(preserveModelSettingsAtom) + const { updateThreadMetadata } = useCreateNewThread() + useClickOutside(() => !filterOptionsOpen && setOpen(false), null, [ dropdownOptions, toggle, @@ -101,6 +104,13 @@ const ModelDropdown = ({ showEngineListModelAtom ) + const isModelSupportRagAndTools = useCallback((model: Model) => { + return ( + model?.engine === InferenceEngine.openai || + localEngines.includes(model?.engine as InferenceEngine) + ) + }, []) + const filteredDownloadedModels = useMemo( () => configuredModels @@ -161,6 +171,26 @@ const ModelDropdown = ({ setOpen(false) if (activeThread) { + // Change assistand tools based on model support RAG + updateThreadMetadata({ + ...activeThread, + assistants: [ + { + ...activeThread.assistants[0], + tools: [ + { + type: 'retrieval', + enabled: isModelSupportRagAndTools(model as Model), + settings: { + ...(activeThread.assistants[0].tools && + activeThread.assistants[0].tools[0]?.settings), + }, + }, + ], + }, + ], + }) + // Default setting ctx_len for the model for a better onboarding experience // TODO: When Cortex support hardware instructions, we should remove this const defaultContextLength = preserveModelSettings @@ -201,8 +231,10 @@ const ModelDropdown = ({ downloadedModels, activeThread, setSelectedModel, + isModelSupportRagAndTools, setThreadModelParams, updateModelParameter, + updateThreadMetadata, preserveModelSettings, ] ) diff --git a/web/screens/Thread/ThreadCenterPanel/ChatInput/index.tsx b/web/screens/Thread/ThreadCenterPanel/ChatInput/index.tsx index e3d469a97..141c13fbe 100644 --- a/web/screens/Thread/ThreadCenterPanel/ChatInput/index.tsx +++ b/web/screens/Thread/ThreadCenterPanel/ChatInput/index.tsx @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { useEffect, useRef, useState } from 'react' -import { MessageStatus } from '@janhq/core' +import { InferenceEngine, MessageStatus } from '@janhq/core' import { TextArea, Button, Tooltip, useClickOutside, Badge } from '@janhq/joi' import { useAtom, useAtomValue } from 'jotai' @@ -24,12 +24,15 @@ import { useActiveModel } from '@/hooks/useActiveModel' import useSendChatMessage from '@/hooks/useSendChatMessage' +import { localEngines } from '@/utils/modelEngine' + import FileUploadPreview from '../FileUploadPreview' import ImageUploadPreview from '../ImageUploadPreview' import { showRightPanelAtom } from '@/helpers/atoms/App.atom' import { experimentalFeatureEnabledAtom } from '@/helpers/atoms/AppConfig.atom' import { getCurrentChatMessagesAtom } from '@/helpers/atoms/ChatMessage.atom' +import { selectedModelAtom } from '@/helpers/atoms/Model.atom' import { spellCheckAtom } from '@/helpers/atoms/Setting.atom' import { activeSettingInputBoxAtom, @@ -53,6 +56,7 @@ const ChatInput = () => { activeSettingInputBoxAtom ) const { sendChatMessage } = useSendChatMessage() + const selectedModel = useAtomValue(selectedModelAtom) const activeThreadId = useAtomValue(getActiveThreadIdAtom) const [isWaitingToSend, setIsWaitingToSend] = useAtom(waitingToSendMessage) @@ -124,6 +128,10 @@ const ChatInput = () => { stopInference() } + const isModelSupportRagAndTools = + selectedModel?.engine === InferenceEngine.openai || + localEngines.includes(selectedModel?.engine as InferenceEngine) + /** * Handles the change event of the extension file input element by setting the file name state. * Its to be used to display the extension file name of the selected file. @@ -198,6 +206,7 @@ const ChatInput = () => { } disabled={ + isModelSupportRagAndTools && activeThread?.assistants[0].tools && activeThread?.assistants[0].tools[0]?.enabled } @@ -217,12 +226,7 @@ const ChatInput = () => { )} {activeThread?.assistants[0].tools && activeThread?.assistants[0].tools[0]?.enabled === - false && ( - - Turn on Retrieval in Assistant Settings to use - this feature. - - )} + false && Not supported for this model} ))} diff --git a/web/screens/Thread/ThreadRightPanel/index.tsx b/web/screens/Thread/ThreadRightPanel/index.tsx index e374cb362..c3cb7591d 100644 --- a/web/screens/Thread/ThreadRightPanel/index.tsx +++ b/web/screens/Thread/ThreadRightPanel/index.tsx @@ -1,6 +1,10 @@ import { memo, useCallback, useMemo } from 'react' -import { SettingComponentProps, SliderComponentProps } from '@janhq/core/.' +import { + InferenceEngine, + SettingComponentProps, + SliderComponentProps, +} from '@janhq/core' import { Tabs, TabsContent, @@ -24,6 +28,7 @@ import { useCreateNewThread } from '@/hooks/useCreateNewThread' import useUpdateModelParameters from '@/hooks/useUpdateModelParameters' import { getConfigurationsData } from '@/utils/componentSettings' +import { localEngines } from '@/utils/modelEngine' import { toRuntimeParams, toSettingParams } from '@/utils/modelParam' import PromptTemplateSetting from './PromptTemplateSetting' @@ -49,6 +54,10 @@ const ThreadRightPanel = () => { const { updateThreadMetadata } = useCreateNewThread() const experimentalFeature = useAtomValue(experimentalFeatureEnabledAtom) + const isModelSupportRagAndTools = + selectedModel?.engine === InferenceEngine.openai || + localEngines.includes(selectedModel?.engine as InferenceEngine) + const setEngineParamsUpdate = useSetAtom(engineParamsUpdateAtom) const { stopModel } = useActiveModel() const { updateModelParameter } = useUpdateModelParameters() @@ -189,7 +198,16 @@ const ThreadRightPanel = () => { options={[ { name: 'Assistant', value: 'assistant' }, { name: 'Model', value: 'model' }, - ...(experimentalFeature ? [{ name: 'Tools', value: 'tools' }] : []), + ...(experimentalFeature + ? [ + { + name: 'Tools', + value: 'tools', + disabled: !isModelSupportRagAndTools, + tooltipContent: 'Not supported for this model', + }, + ] + : []), ]} value={activeTabThreadRightPanel as string} onValueChange={(value) => setActiveTabThreadRightPanel(value)} From edf5c77dd630296b70052a2fcf3f999d2b0f8863 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Thu, 5 Sep 2024 09:54:52 +0700 Subject: [PATCH 09/31] chore: copy edit for model setting (#3553) * chore: copy edit for model setting * chore: make a const for copy accordion --- web/screens/Thread/ThreadRightPanel/index.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/web/screens/Thread/ThreadRightPanel/index.tsx b/web/screens/Thread/ThreadRightPanel/index.tsx index c3cb7591d..9e7cdf7d8 100644 --- a/web/screens/Thread/ThreadRightPanel/index.tsx +++ b/web/screens/Thread/ThreadRightPanel/index.tsx @@ -44,6 +44,10 @@ import { import { activeTabThreadRightPanelAtom } from '@/helpers/atoms/ThreadRightPanel.atom' +const INFERENCE_SETTINGS = 'Inference Settings' +const MODEL_SETTINGS = 'Model Settings' +const ENGINE_SETTINGS = 'Engine Settings' + const ThreadRightPanel = () => { const activeThread = useAtomValue(activeThreadAtom) const activeModelParams = useAtomValue(getActiveThreadModelParamsAtom) @@ -239,8 +243,8 @@ const ThreadRightPanel = () => { {settings.runtimeSettings.length !== 0 && ( { )} {promptTemplateSettings.length !== 0 && ( - + )} {settings.engineSettings.length !== 0 && ( - + Date: Thu, 5 Sep 2024 11:41:15 +0700 Subject: [PATCH 10/31] chore: setup jest for unit test hooks and component from joi (#3540) * chore: setup jest for unit test hooks and component from joi * chore: update gitignore * chore: exclude jest setup file from tsconfig --- .gitignore | 2 +- joi/jest.config.js | 8 ++ joi/jest.setup.js | 0 joi/package.json | 16 ++- joi/src/core/Accordion/Accordion.test.tsx | 64 +++++++++++ joi/src/core/Badge/Badge.test.tsx | 83 ++++++++++++++ joi/src/core/Badge/index.tsx | 12 ++- joi/src/core/Button/Button.test.tsx | 68 ++++++++++++ joi/src/core/Button/index.tsx | 13 +-- .../useClickOutside/useClickOutside.test.tsx | 55 ++++++++++ .../hooks/useClipboard/useClipboard.test.ts | 102 ++++++++++++++++++ .../hooks/useMediaQuery/useMediaQuery.test.ts | 90 ++++++++++++++++ joi/src/hooks/useOs/useOs.test.ts | 39 +++++++ .../hooks/usePageLeave/usePageLeave.test.ts | 32 ++++++ .../useTextSelection/useTextSelection.test.ts | 56 ++++++++++ joi/tsconfig.json | 1 + 16 files changed, 626 insertions(+), 15 deletions(-) create mode 100644 joi/jest.config.js create mode 100644 joi/jest.setup.js create mode 100644 joi/src/core/Accordion/Accordion.test.tsx create mode 100644 joi/src/core/Badge/Badge.test.tsx create mode 100644 joi/src/core/Button/Button.test.tsx create mode 100644 joi/src/hooks/useClickOutside/useClickOutside.test.tsx create mode 100644 joi/src/hooks/useClipboard/useClipboard.test.ts create mode 100644 joi/src/hooks/useMediaQuery/useMediaQuery.test.ts create mode 100644 joi/src/hooks/useOs/useOs.test.ts create mode 100644 joi/src/hooks/usePageLeave/usePageLeave.test.ts create mode 100644 joi/src/hooks/useTextSelection/useTextSelection.test.ts diff --git a/.gitignore b/.gitignore index d3b50445e..4c0884dec 100644 --- a/.gitignore +++ b/.gitignore @@ -20,7 +20,7 @@ electron/themes electron/playwright-report server/pre-install package-lock.json - +coverage *.log core/lib/** 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..c336cce12 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,22 @@ "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-tooltip": "^1.0.7", - "tailwind-merge": "^2.2.0", + "@types/jest": "^29.5.12", "autoprefixer": "10.4.16", - "tailwindcss": "^3.4.1" + "jest": "^29.7.0", + "tailwind-merge": "^2.2.0", + "tailwindcss": "^3.4.1", + "ts-jest": "^29.2.5" }, "devDependencies": { "@rollup/plugin-node-resolve": "^15.2.3", "@rollup/plugin-terser": "^0.4.4", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.5.0", + "@testing-library/react": "^16.0.1", + "@types/jest": "^29.5.12", + "jest-environment-jsdom": "^29.7.0", + "jest-transform-css": "^6.0.1", "prettier": "^3.0.3", "prettier-plugin-tailwindcss": "^0.5.6", "rollup": "^4.12.0", 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..3ff76143c --- /dev/null +++ b/joi/src/core/Button/Button.test.tsx @@ -0,0 +1,68 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import { Button, buttonConfig } from './index' + +// Mock the styles +jest.mock('./styles.scss', () => ({})) + +describe('Button', () => { + it('renders with default props', () => { + render() + const button = screen.getByRole('button', { name: /click me/i }) + expect(button).toBeInTheDocument() + expect(button).toHaveClass('btn btn--primary btn--medium btn--solid') + }) + + it('renders as a child component when asChild is true', () => { + render( + + ) + 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('merges custom className with generated classes', () => { + render() + const button = screen.getByRole('button', { name: /custom class button/i }) + expect(button).toHaveClass( + 'btn btn--primary btn--medium btn--solid custom-class' + ) + }) +}) 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/hooks/useClickOutside/useClickOutside.test.tsx b/joi/src/hooks/useClickOutside/useClickOutside.test.tsx new file mode 100644 index 000000000..ac73b280a --- /dev/null +++ b/joi/src/hooks/useClickOutside/useClickOutside.test.tsx @@ -0,0 +1,55 @@ +import React from 'react' +import { render, fireEvent, act } from '@testing-library/react' +import { useClickOutside } from './index' + +// Mock component to test the hook +const TestComponent: React.FC<{ onClickOutside: () => void }> = ({ + onClickOutside, +}) => { + const ref = useClickOutside(onClickOutside) + return
}>Test
+} + +describe('@joi/hooks/useClickOutside', () => { + it('should call handler when clicking outside', () => { + const handleClickOutside = jest.fn() + const { container } = render( + + ) + + act(() => { + fireEvent.mouseDown(document.body) + }) + + expect(handleClickOutside).toHaveBeenCalledTimes(1) + }) + + it('should not call handler when clicking inside', () => { + const handleClickOutside = jest.fn() + const { getByText } = render( + + ) + + act(() => { + fireEvent.mouseDown(getByText('Test')) + }) + + expect(handleClickOutside).not.toHaveBeenCalled() + }) + + it('should work with custom events', () => { + const handleClickOutside = jest.fn() + const TestComponentWithCustomEvent: React.FC = () => { + const ref = useClickOutside(handleClickOutside, ['click']) + return
}>Test
+ } + + render() + + act(() => { + fireEvent.click(document.body) + }) + + expect(handleClickOutside).toHaveBeenCalledTimes(1) + }) +}) diff --git a/joi/src/hooks/useClipboard/useClipboard.test.ts b/joi/src/hooks/useClipboard/useClipboard.test.ts new file mode 100644 index 000000000..53b4ccd27 --- /dev/null +++ b/joi/src/hooks/useClipboard/useClipboard.test.ts @@ -0,0 +1,102 @@ +import { renderHook, act } from '@testing-library/react' +import { useClipboard } from './index' + +// Mock the navigator.clipboard +const mockClipboard = { + writeText: jest.fn(() => Promise.resolve()), +} +Object.assign(navigator, { clipboard: mockClipboard }) + +describe('@joi/hooks/useClipboard', () => { + beforeEach(() => { + jest.useFakeTimers() + jest.spyOn(window, 'setTimeout') + jest.spyOn(window, 'clearTimeout') + mockClipboard.writeText.mockClear() + }) + + afterEach(() => { + jest.useRealTimers() + jest.clearAllMocks() + }) + + it('should copy text to clipboard', async () => { + const { result } = renderHook(() => useClipboard()) + + await act(async () => { + result.current.copy('Test text') + }) + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('Test text') + expect(result.current.copied).toBe(true) + expect(result.current.error).toBe(null) + }) + + it('should set error if clipboard write fails', async () => { + mockClipboard.writeText.mockRejectedValueOnce( + new Error('Clipboard write failed') + ) + + const { result } = renderHook(() => useClipboard()) + + await act(async () => { + result.current.copy('Test text') + }) + + expect(result.current.error).toBeInstanceOf(Error) + expect(result.current.error?.message).toBe('Clipboard write failed') + }) + + it('should set error if clipboard is not supported', async () => { + const originalClipboard = navigator.clipboard + // @ts-ignore + delete navigator.clipboard + + const { result } = renderHook(() => useClipboard()) + + await act(async () => { + result.current.copy('Test text') + }) + + expect(result.current.error).toBeInstanceOf(Error) + expect(result.current.error?.message).toBe( + 'useClipboard: navigator.clipboard is not supported' + ) + + // Restore clipboard support + Object.assign(navigator, { clipboard: originalClipboard }) + }) + + it('should reset copied state after timeout', async () => { + const { result } = renderHook(() => useClipboard({ timeout: 1000 })) + + await act(async () => { + result.current.copy('Test text') + }) + + expect(result.current.copied).toBe(true) + + act(() => { + jest.advanceTimersByTime(1000) + }) + + expect(result.current.copied).toBe(false) + }) + + it('should reset state when reset is called', async () => { + const { result } = renderHook(() => useClipboard()) + + await act(async () => { + result.current.copy('Test text') + }) + + expect(result.current.copied).toBe(true) + + act(() => { + result.current.reset() + }) + + expect(result.current.copied).toBe(false) + expect(result.current.error).toBe(null) + }) +}) diff --git a/joi/src/hooks/useMediaQuery/useMediaQuery.test.ts b/joi/src/hooks/useMediaQuery/useMediaQuery.test.ts new file mode 100644 index 000000000..5813bd41d --- /dev/null +++ b/joi/src/hooks/useMediaQuery/useMediaQuery.test.ts @@ -0,0 +1,90 @@ +import { renderHook, act } from '@testing-library/react' +import { useMediaQuery } from './index' + +describe('@joi/hooks/useMediaQuery', () => { + const matchMediaMock = jest.fn() + + beforeAll(() => { + window.matchMedia = matchMediaMock + }) + + afterEach(() => { + matchMediaMock.mockClear() + }) + + it('should return initial value when getInitialValueInEffect is true', () => { + matchMediaMock.mockImplementation(() => ({ + matches: true, + addListener: jest.fn(), + removeListener: jest.fn(), + })) + + const { result } = renderHook(() => + useMediaQuery('(min-width: 768px)', true, { + getInitialValueInEffect: true, + }) + ) + + expect(result.current).toBe(true) + }) + + it('should return correct value based on media query', () => { + matchMediaMock.mockImplementation(() => ({ + matches: true, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + })) + + const { result } = renderHook(() => useMediaQuery('(min-width: 768px)')) + + expect(result.current).toBe(true) + }) + + it('should update value when media query changes', () => { + let listener: ((event: { matches: boolean }) => void) | null = null + + matchMediaMock.mockImplementation(() => ({ + matches: false, + addEventListener: (_, cb) => { + listener = cb + }, + removeEventListener: jest.fn(), + })) + + const { result } = renderHook(() => useMediaQuery('(min-width: 768px)')) + + expect(result.current).toBe(false) + + act(() => { + if (listener) { + listener({ matches: true }) + } + }) + + expect(result.current).toBe(true) + }) + + it('should handle older browsers without addEventListener', () => { + let listener: ((event: { matches: boolean }) => void) | null = null + + matchMediaMock.mockImplementation(() => ({ + matches: false, + addListener: (cb) => { + listener = cb + }, + removeListener: jest.fn(), + })) + + const { result } = renderHook(() => useMediaQuery('(min-width: 768px)')) + + expect(result.current).toBe(false) + + act(() => { + if (listener) { + listener({ matches: true }) + } + }) + + expect(result.current).toBe(true) + }) +}) diff --git a/joi/src/hooks/useOs/useOs.test.ts b/joi/src/hooks/useOs/useOs.test.ts new file mode 100644 index 000000000..037640b5e --- /dev/null +++ b/joi/src/hooks/useOs/useOs.test.ts @@ -0,0 +1,39 @@ +import { renderHook } from '@testing-library/react' +import { useOs } from './index' + +const platforms = { + windows: [ + 'Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0', + ], + macos: [ + 'Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0', + ], + linux: [ + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36', + ], + ios: [ + 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.1 Mobile/15E148 Safari/604.1', + ], + android: [ + 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36', + ], + undetermined: ['UNKNOWN'], +} as const + +describe('@joi/hooks/useOS', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + Object.entries(platforms).forEach(([os, userAgents]) => { + it.each(userAgents)(`should detect %s platform on ${os}`, (userAgent) => { + jest + .spyOn(window.navigator, 'userAgent', 'get') + .mockReturnValueOnce(userAgent) + + const { result } = renderHook(() => useOs()) + + expect(result.current).toBe(os) + }) + }) +}) diff --git a/joi/src/hooks/usePageLeave/usePageLeave.test.ts b/joi/src/hooks/usePageLeave/usePageLeave.test.ts new file mode 100644 index 000000000..093ae31c1 --- /dev/null +++ b/joi/src/hooks/usePageLeave/usePageLeave.test.ts @@ -0,0 +1,32 @@ +import { renderHook } from '@testing-library/react' +import { fireEvent } from '@testing-library/react' +import { usePageLeave } from './index' + +describe('@joi/hooks/usePageLeave', () => { + it('should call onPageLeave when mouse leaves the document', () => { + const onPageLeaveMock = jest.fn() + const { result } = renderHook(() => usePageLeave(onPageLeaveMock)) + + fireEvent.mouseLeave(document.documentElement) + + expect(onPageLeaveMock).toHaveBeenCalledTimes(1) + }) + + it('should remove event listener on unmount', () => { + const onPageLeaveMock = jest.fn() + const removeEventListenerSpy = jest.spyOn( + document.documentElement, + 'removeEventListener' + ) + + const { unmount } = renderHook(() => usePageLeave(onPageLeaveMock)) + + unmount() + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'mouseleave', + expect.any(Function) + ) + removeEventListenerSpy.mockRestore() + }) +}) diff --git a/joi/src/hooks/useTextSelection/useTextSelection.test.ts b/joi/src/hooks/useTextSelection/useTextSelection.test.ts new file mode 100644 index 000000000..26efa23e7 --- /dev/null +++ b/joi/src/hooks/useTextSelection/useTextSelection.test.ts @@ -0,0 +1,56 @@ +import { renderHook, act } from '@testing-library/react' +import { useTextSelection } from './index' + +describe('@joi/hooks/useTextSelection', () => { + let mockSelection: Selection + + beforeEach(() => { + mockSelection = { + toString: jest.fn(), + removeAllRanges: jest.fn(), + addRange: jest.fn(), + } as unknown as Selection + + jest.spyOn(document, 'getSelection').mockReturnValue(mockSelection) + jest.spyOn(document, 'addEventListener') + jest.spyOn(document, 'removeEventListener') + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('should return the initial selection', () => { + const { result } = renderHook(() => useTextSelection()) + expect(result.current).toBe(mockSelection) + }) + + it('should add and remove event listener', () => { + const { unmount } = renderHook(() => useTextSelection()) + + expect(document.addEventListener).toHaveBeenCalledWith( + 'selectionchange', + expect.any(Function) + ) + + unmount() + + expect(document.removeEventListener).toHaveBeenCalledWith( + 'selectionchange', + expect.any(Function) + ) + }) + + it('should update selection when selectionchange event is triggered', () => { + const { result } = renderHook(() => useTextSelection()) + + const newMockSelection = { toString: jest.fn() } as unknown as Selection + jest.spyOn(document, 'getSelection').mockReturnValue(newMockSelection) + + act(() => { + document.dispatchEvent(new Event('selectionchange')) + }) + + expect(result.current).toBe(newMockSelection) + }) +}) diff --git a/joi/tsconfig.json b/joi/tsconfig.json index f72e8151e..25e2ee66f 100644 --- a/joi/tsconfig.json +++ b/joi/tsconfig.json @@ -3,6 +3,7 @@ "target": "esnext", "declaration": true, "declarationDir": "dist/types", + "types": ["jest", "@testing-library/jest-dom"], "module": "esnext", "lib": ["es6", "dom", "es2016", "es2017"], "sourceMap": true, From f759fae55fb46279f7c82535c6f2d06a7bffe6d0 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Thu, 5 Sep 2024 14:10:41 +0700 Subject: [PATCH 11/31] feat: enable model dropdown search by configured model (#3466) --- web/containers/ModelDropdown/index.tsx | 256 +++++++++++-------------- 1 file changed, 109 insertions(+), 147 deletions(-) diff --git a/web/containers/ModelDropdown/index.tsx b/web/containers/ModelDropdown/index.tsx index d57119b13..92d8addd0 100644 --- a/web/containers/ModelDropdown/index.tsx +++ b/web/containers/ModelDropdown/index.tsx @@ -465,158 +465,73 @@ const ModelDropdown = ({ {engine === InferenceEngine.nitro && !isDownloadALocalModel && - showModel && ( - <> - {!searchText.length ? ( -
    - {featuredModel.map((model) => { - const isDownloading = downloadingModels.some( - (md) => md.id === model.id - ) - return ( -
  • + {featuredModel.map((model) => { + const isDownloading = downloadingModels.some( + (md) => md.id === model.id + ) + return ( +
  • +
    +

    -

    -

    - {model.name} -

    - -
    -
    - - {toGibibytes(model.metadata.size)} - - {!isDownloading ? ( - downloadModel(model)} - /> - ) : ( - Object.values(downloadStates) - .filter((x) => x.modelId === model.id) - .map((item) => ( - + +
    +
    + + {toGibibytes(model.metadata.size)} + + {!isDownloading ? ( + downloadModel(model)} + /> + ) : ( + Object.values(downloadStates) + .filter((x) => x.modelId === model.id) + .map((item) => ( + - )) - )} -
    -
  • - ) - })} -
- ) : ( - <> - {filteredDownloadedModels - .filter( - (x) => x.engine === InferenceEngine.nitro - ) - .filter((x) => { - if (searchText.length === 0) { - return downloadedModels.find( - (c) => c.id === x.id - ) - } else { - return x - } - }) - .map((model) => { - const isDownloading = downloadingModels.some( - (md) => md.id === model.id - ) - const isdDownloaded = downloadedModels.some( - (c) => c.id === model.id - ) - return ( -
  • { - if (isdDownloaded) { - onClickModelItem(model.id) - } - }} - > -
    -

    - {model.name} -

    - -
    -
    - {!isdDownloaded && ( - - {toGibibytes(model.metadata.size)} - - )} - {!isDownloading && !isdDownloaded ? ( - downloadModel(model)} - /> - ) : ( - Object.values(downloadStates) - .filter( - (x) => x.modelId === model.id - ) - .map((item) => ( - - )) - )} -
    -
  • - ) - })} - - )} - + )) + )} +
    + + ) + })} + )}
      {filteredDownloadedModels .filter((x) => x.engine === engine) .filter((y) => { - if (localEngines.includes(y.engine)) { + if ( + localEngines.includes(y.engine) && + !searchText.length + ) { return downloadedModels.find((c) => c.id === y.id) } else { return y @@ -624,11 +539,17 @@ const ModelDropdown = ({ }) .map((model) => { if (!showModel) return null + const isDownloading = downloadingModels.some( + (md) => md.id === model.id + ) + const isdDownloaded = downloadedModels.some( + (c) => c.id === model.id + ) return (
    • -
      -

      +

      +

      {model.name}

      + +
      +
      + {!isdDownloaded && ( + + {toGibibytes(model.metadata.size)} + + )} + {!isDownloading && !isdDownloaded ? ( + downloadModel(model)} + /> + ) : ( + Object.values(downloadStates) + .filter((x) => x.modelId === model.id) + .map((item) => ( + + )) + )}
    • ) From 846efb31264059852b28930fdbaa0dcc6a125aa0 Mon Sep 17 00:00:00 2001 From: Louis Date: Fri, 6 Sep 2024 11:14:28 +0700 Subject: [PATCH 12/31] test: add core modules test cases (#3498) * chore: add core module test cases * chore: fix tests * chore: add code coverage report * chore: split coverage step * chore: split coverage step * Update jan-electron-linter-and-test.yml * Update jan-electron-linter-and-test.yml * Update jan-electron-linter-and-test.yml * chore: update tests * chore: add web utils test cases * chore: add restful and helper tests * chore: add tests --- .../jan-electron-linter-and-test.yml | 74 +++++++++++ .gitignore | 2 + Makefile | 2 +- core/jest.config.js | 1 + core/package.json | 4 +- core/src/browser/core.test.ts | 98 ++++++++++++++ core/src/browser/events.test.ts | 37 ++++++ core/src/browser/extension.test.ts | 46 +++++++ .../extensions/engines/helpers/sse.test.ts | 60 +++++++++ core/src/browser/index.test.ts | 32 +++++ core/src/node/api/common/adapter.test.ts | 10 ++ core/src/node/api/common/handler.test.ts | 25 ++++ core/src/node/api/processors/app.test.ts | 40 ++++++ core/src/node/api/processors/download.test.ts | 59 +++++++++ .../src/node/api/processors/extension.test.ts | 9 ++ core/src/node/api/processors/fs.test.ts | 18 +++ core/src/node/api/processors/fsExt.test.ts | 34 +++++ .../src/node/api/processors/processor.test.ts | 0 .../src/node/api/restful/app/download.test.ts | 62 +++++++++ .../src/node/api/restful/app/handlers.test.ts | 16 +++ core/src/node/api/restful/common.test.ts | 21 +++ .../api/restful/helper/configuration.test.ts | 24 ++++ core/src/node/api/restful/v1.test.ts | 16 +++ core/src/node/extension/extension.test.ts | 122 ++++++++++++++++++ core/src/node/extension/manager.test.ts | 28 ++++ core/src/node/extension/store.test.ts | 43 ++++++ core/src/node/helper/config.test.ts | 14 ++ core/src/node/helper/download.test.ts | 11 ++ core/src/node/helper/logger.test.ts | 47 +++++++ core/src/node/helper/module.test.ts | 23 ++++ core/src/node/helper/path.test.ts | 29 +++++ core/src/node/helper/resource.test.ts | 15 +++ .../types/assistant/assistantEvent.test.ts | 7 + core/src/types/file/index.ts | 4 +- core/testRunner.js | 10 ++ core/tests/node/path.test.ts | 12 -- core/tsconfig.json | 1 + jest.config.js | 2 +- web/utils/Stack.test.ts | 35 +++++ web/utils/componentSettings.test.ts | 22 ++++ web/utils/datetime.test.ts | 27 ++++ web/utils/jsonToCssVariables.test.ts | 17 +++ web/utils/memory.test.ts | 2 - web/utils/predefinedComponent.test.ts | 18 +++ web/utils/thread.test.ts | 9 ++ web/utils/titleUtils.test.ts | 25 ++++ 46 files changed, 1194 insertions(+), 19 deletions(-) create mode 100644 core/src/browser/core.test.ts create mode 100644 core/src/browser/events.test.ts create mode 100644 core/src/browser/extension.test.ts create mode 100644 core/src/browser/extensions/engines/helpers/sse.test.ts create mode 100644 core/src/browser/index.test.ts create mode 100644 core/src/node/api/common/adapter.test.ts create mode 100644 core/src/node/api/common/handler.test.ts create mode 100644 core/src/node/api/processors/app.test.ts create mode 100644 core/src/node/api/processors/download.test.ts create mode 100644 core/src/node/api/processors/extension.test.ts create mode 100644 core/src/node/api/processors/fs.test.ts create mode 100644 core/src/node/api/processors/fsExt.test.ts create mode 100644 core/src/node/api/processors/processor.test.ts create mode 100644 core/src/node/api/restful/app/download.test.ts create mode 100644 core/src/node/api/restful/app/handlers.test.ts create mode 100644 core/src/node/api/restful/common.test.ts create mode 100644 core/src/node/api/restful/helper/configuration.test.ts create mode 100644 core/src/node/api/restful/v1.test.ts create mode 100644 core/src/node/extension/extension.test.ts create mode 100644 core/src/node/extension/manager.test.ts create mode 100644 core/src/node/extension/store.test.ts create mode 100644 core/src/node/helper/config.test.ts create mode 100644 core/src/node/helper/download.test.ts create mode 100644 core/src/node/helper/logger.test.ts create mode 100644 core/src/node/helper/module.test.ts create mode 100644 core/src/node/helper/path.test.ts create mode 100644 core/src/node/helper/resource.test.ts create mode 100644 core/src/types/assistant/assistantEvent.test.ts create mode 100644 core/testRunner.js delete mode 100644 core/tests/node/path.test.ts create mode 100644 web/utils/Stack.test.ts create mode 100644 web/utils/componentSettings.test.ts create mode 100644 web/utils/datetime.test.ts create mode 100644 web/utils/jsonToCssVariables.test.ts create mode 100644 web/utils/predefinedComponent.test.ts create mode 100644 web/utils/thread.test.ts create mode 100644 web/utils/titleUtils.test.ts diff --git a/.github/workflows/jan-electron-linter-and-test.yml b/.github/workflows/jan-electron-linter-and-test.yml index 3a95e804e..529739e24 100644 --- a/.github/workflows/jan-electron-linter-and-test.yml +++ b/.github/workflows/jan-electron-linter-and-test.yml @@ -37,6 +37,30 @@ on: - '!README.md' jobs: + + base_branch_cov: + runs-on: ubuntu-latest + 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 + + - name: Run test coverage + run: yarn test:coverage + + - name: Upload code coverage for ref branch + uses: actions/upload-artifact@v3 + with: + name: ref-lcov.info + path: ./coverage/lcov.info + test-on-macos: if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || github.event_name == 'push' || github.event_name == 'workflow_dispatch' runs-on: [self-hosted, macOS, macos-desktop] @@ -292,6 +316,56 @@ jobs: TURBO_TEAM: 'linux' TURBO_TOKEN: '${{ secrets.TURBO_TOKEN }}' + coverage-check: + runs-on: [self-hosted, Linux, ubuntu-desktop] + needs: base_branch_cov + if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || github.event_name == 'push' || github.event_name == 'workflow_dispatch' + steps: + - name: Getting the repo + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Installing node + uses: actions/setup-node@v3 + with: + node-version: 20 + + - name: 'Cleanup cache' + continue-on-error: true + run: | + rm -rf ~/jan + make clean + + - name: Download code coverage report from base branch + uses: actions/download-artifact@v3 + with: + name: ref-lcov.info + + - name: Linter and test coverage + run: | + export DISPLAY=$(w -h | awk 'NR==1 {print $2}') + echo -e "Display ID: $DISPLAY" + npm config set registry ${{ secrets.NPM_PROXY }} --global + yarn config set registry ${{ secrets.NPM_PROXY }} --global + make lint + yarn build:test + yarn test:coverage + env: + TURBO_API: '${{ secrets.TURBO_API }}' + TURBO_TEAM: 'linux' + TURBO_TOKEN: '${{ secrets.TURBO_TOKEN }}' + + - name: Generate Code Coverage report + id: code-coverage + uses: barecheck/code-coverage-action@v1 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + lcov-file: "./coverage/lcov.info" + base-lcov-file: "./lcov.info" + send-summary-comment: true + show-annotations: "warning" + test-on-ubuntu-pr-target: runs-on: [self-hosted, Linux, ubuntu-desktop] if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository diff --git a/.gitignore b/.gitignore index 4c0884dec..f9b1dab66 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,5 @@ extensions/*-extension/bin/vulkaninfo .turbo electron/test-data electron/test-results +core/test_results.html +coverage 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/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/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/jest.config.js b/jest.config.js index eb1aab657..a911a7f0a 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,3 +1,3 @@ module.exports = { - projects: ['/core', '/web'], + projects: ['/core', '/web', '/joi'], } diff --git a/web/utils/Stack.test.ts b/web/utils/Stack.test.ts new file mode 100644 index 000000000..e10753c68 --- /dev/null +++ b/web/utils/Stack.test.ts @@ -0,0 +1,35 @@ + +import { Stack } from './Stack'; + +it('should return elements in reverse order', () => { + const stack = new Stack(); + stack.push(1); + stack.push(2); + stack.push(3); + const reversedOutput = stack.reverseOutput(); + expect(reversedOutput).toEqual([1, 2, 3]); +}); + + +it('should pop an element from the stack', () => { + const stack = new Stack(); + stack.push(1); + const poppedElement = stack.pop(); + expect(poppedElement).toBe(1); + expect(stack.isEmpty()).toBe(true); +}); + + +it('should push an element to the stack', () => { + const stack = new Stack(); + stack.push(1); + expect(stack.isEmpty()).toBe(false); + expect(stack.size()).toBe(1); + expect(stack.peek()).toBe(1); +}); + + +it('should initialize as empty', () => { + const stack = new Stack(); + expect(stack.isEmpty()).toBe(true); +}); diff --git a/web/utils/componentSettings.test.ts b/web/utils/componentSettings.test.ts new file mode 100644 index 000000000..51d2a90fd --- /dev/null +++ b/web/utils/componentSettings.test.ts @@ -0,0 +1,22 @@ + +import { getConfigurationsData } from './componentSettings'; + +it('should process checkbox setting', () => { + const settings = { embedding: true }; + const result = getConfigurationsData(settings); + expect(result[0].controllerProps.value).toBe(true); +}); + + +it('should process input setting and handle array input', () => { + const settings = { prompt_template: ['Hello', 'World', ''] }; + const result = getConfigurationsData(settings); + expect(result[0].controllerProps.value).toBe('Hello World '); +}); + + +it('should return an empty array when settings object is empty', () => { + const settings = {}; + const result = getConfigurationsData(settings); + expect(result).toEqual([]); +}); diff --git a/web/utils/datetime.test.ts b/web/utils/datetime.test.ts new file mode 100644 index 000000000..75bdb1f8f --- /dev/null +++ b/web/utils/datetime.test.ts @@ -0,0 +1,27 @@ + +import { displayDate } from './datetime'; +import { isToday } from './datetime'; + +test('should return only time for today\'s timestamp', () => { + const today = new Date(); + const timestamp = today.getTime(); + const expectedTime = today.toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: true, + }); + expect(displayDate(timestamp)).toBe(expectedTime); +}); + + +test('should return N/A for undefined timestamp', () => { + expect(displayDate()).toBe('N/A'); +}); + + +test('should return true for today\'s timestamp', () => { + const today = new Date(); + const timestamp = today.setHours(0, 0, 0, 0); + expect(isToday(timestamp)).toBe(true); +}); diff --git a/web/utils/jsonToCssVariables.test.ts b/web/utils/jsonToCssVariables.test.ts new file mode 100644 index 000000000..0ff19e9a2 --- /dev/null +++ b/web/utils/jsonToCssVariables.test.ts @@ -0,0 +1,17 @@ + +import cssVars from './jsonToCssVariables'; + +test('should convert nested JSON object to CSS variables', () => { + const input = { theme: { color: 'blue', font: { size: '14px', weight: 'bold' } } }; + const expectedOutput = '--theme-color: blue;--theme-font-size: 14px;--theme-font-weight: bold;'; + const result = cssVars(input); + expect(result).toBe(expectedOutput); +}); + + +test('should convert simple JSON object to CSS variables', () => { + const input = { color: 'red', fontSize: '16px' }; + const expectedOutput = '--color: red;--fontSize: 16px;'; + const result = cssVars(input); + expect(result).toBe(expectedOutput); +}); diff --git a/web/utils/memory.test.ts b/web/utils/memory.test.ts index e7420957d..5150df72b 100644 --- a/web/utils/memory.test.ts +++ b/web/utils/memory.test.ts @@ -1,5 +1,3 @@ -// @auto-generated - import { utilizedMemory } from './memory' test('test_utilizedMemory_arbitraryValues', () => { diff --git a/web/utils/predefinedComponent.test.ts b/web/utils/predefinedComponent.test.ts new file mode 100644 index 000000000..42b391d73 --- /dev/null +++ b/web/utils/predefinedComponent.test.ts @@ -0,0 +1,18 @@ + +import { presetConfiguration } from './predefinedComponent'; + +it('should have correct configuration for prompt_template', () => { + const config = presetConfiguration['prompt_template']; + expect(config).toEqual({ + key: 'prompt_template', + title: 'Prompt template', + description: `A predefined text or framework that guides the AI model's response generation. It includes placeholders or instructions for the model to fill in or expand upon.`, + controllerType: 'input', + controllerProps: { + placeholder: 'Prompt template', + value: '', + }, + requireModelReload: true, + configType: 'setting', + }); +}); diff --git a/web/utils/thread.test.ts b/web/utils/thread.test.ts new file mode 100644 index 000000000..672e3adde --- /dev/null +++ b/web/utils/thread.test.ts @@ -0,0 +1,9 @@ + +import { generateThreadId } from './thread'; + +test('shouldGenerateThreadIdWithCorrectFormat', () => { + const assistantId = 'assistant123'; + const threadId = generateThreadId(assistantId); + const regex = /^assistant123_\d{10}$/; + expect(threadId).toMatch(regex); +}); diff --git a/web/utils/titleUtils.test.ts b/web/utils/titleUtils.test.ts new file mode 100644 index 000000000..0cf2e1db7 --- /dev/null +++ b/web/utils/titleUtils.test.ts @@ -0,0 +1,25 @@ + +import { openFileTitle } from './titleUtils'; + + test('should return "Open Containing Folder" when neither isMac nor isWindows is true', () => { + (global as any).isMac = false; + (global as any).isWindows = false; + const result = openFileTitle(); + expect(result).toBe('Open Containing Folder'); + }); + + + test('should return "Show in File Explorer" when isWindows is true', () => { + (global as any).isMac = false; + (global as any).isWindows = true; + const result = openFileTitle(); + expect(result).toBe('Show in File Explorer'); + }); + + + test('should return "Show in Finder" when isMac is true', () => { + (global as any).isMac = true; + (global as any).isWindows = false; + const result = openFileTitle(); + expect(result).toBe('Show in Finder'); + }); From 3ddb32e2c7e1a06656c4433606ba9628dca7bcdf Mon Sep 17 00:00:00 2001 From: Louis Date: Fri, 6 Sep 2024 15:36:50 +0700 Subject: [PATCH 13/31] test: fix import from components (#3581) --- .github/workflows/jan-electron-linter-and-test.yml | 5 ++++- web/helpers/atoms/HuggingFace.atom.ts | 2 +- .../Thread/ThreadCenterPanel/ChatBody/EmptyModel/index.tsx | 2 +- web/utils/predefinedComponent.ts | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/jan-electron-linter-and-test.yml b/.github/workflows/jan-electron-linter-and-test.yml index 529739e24..a151d6497 100644 --- a/.github/workflows/jan-electron-linter-and-test.yml +++ b/.github/workflows/jan-electron-linter-and-test.yml @@ -40,6 +40,7 @@ jobs: base_branch_cov: runs-on: ubuntu-latest + continue-on-error: true steps: - uses: actions/checkout@v3 with: @@ -50,7 +51,9 @@ jobs: node-version: v20.9.0 - name: Install dependencies - run: yarn + run: | + yarn + yarn build:core - name: Run test coverage run: yarn test:coverage diff --git a/web/helpers/atoms/HuggingFace.atom.ts b/web/helpers/atoms/HuggingFace.atom.ts index 514efb186..09f7870a3 100644 --- a/web/helpers/atoms/HuggingFace.atom.ts +++ b/web/helpers/atoms/HuggingFace.atom.ts @@ -1,4 +1,4 @@ -import { HuggingFaceRepoData } from '@janhq/core/.' +import { HuggingFaceRepoData } from '@janhq/core' import { atom } from 'jotai' // modals diff --git a/web/screens/Thread/ThreadCenterPanel/ChatBody/EmptyModel/index.tsx b/web/screens/Thread/ThreadCenterPanel/ChatBody/EmptyModel/index.tsx index 2b179e744..12739b5de 100644 --- a/web/screens/Thread/ThreadCenterPanel/ChatBody/EmptyModel/index.tsx +++ b/web/screens/Thread/ThreadCenterPanel/ChatBody/EmptyModel/index.tsx @@ -2,7 +2,7 @@ import React, { Fragment, useState } from 'react' import Image from 'next/image' -import { InferenceEngine } from '@janhq/core/.' +import { InferenceEngine } from '@janhq/core' import { Button, Input, Progress, ScrollArea } from '@janhq/joi' import { useAtomValue, useSetAtom } from 'jotai' diff --git a/web/utils/predefinedComponent.ts b/web/utils/predefinedComponent.ts index 2d0238bec..82087f43b 100644 --- a/web/utils/predefinedComponent.ts +++ b/web/utils/predefinedComponent.ts @@ -1,4 +1,4 @@ -import { SettingComponentProps } from '@janhq/core/.' +import { SettingComponentProps } from '@janhq/core' export const presetConfiguration: Record = { prompt_template: { From ecf83d03c5b4520aa3bafcac5686951e8472b42d Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Mon, 9 Sep 2024 09:39:46 +0700 Subject: [PATCH 14/31] chore: update copy when retrieval turn off (#3585) --- .../Thread/ThreadCenterPanel/ChatInput/index.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/web/screens/Thread/ThreadCenterPanel/ChatInput/index.tsx b/web/screens/Thread/ThreadCenterPanel/ChatInput/index.tsx index 141c13fbe..bbecef10e 100644 --- a/web/screens/Thread/ThreadCenterPanel/ChatInput/index.tsx +++ b/web/screens/Thread/ThreadCenterPanel/ChatInput/index.tsx @@ -226,7 +226,16 @@ const ChatInput = () => { )} {activeThread?.assistants[0].tools && activeThread?.assistants[0].tools[0]?.enabled === - false && Not supported for this model} + false && + isModelSupportRagAndTools && ( + + Turn on Retrieval in Tools settings to use this + feature + + )} + {!isModelSupportRagAndTools && ( + Not supported for this model + )} ))} From 48b2773c8f0207fcb96af175efc083d4504ffec7 Mon Sep 17 00:00:00 2001 From: 0xSage Date: Mon, 9 Sep 2024 13:06:20 +0800 Subject: [PATCH 15/31] chore: fix bug template --- .github/ISSUE_TEMPLATE/bug_report.md | 37 ----------- .github/ISSUE_TEMPLATE/bug_report.yml | 91 ++++++++------------------- 2 files changed, 26 insertions(+), 102 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.md 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 From 1c9342fc08f8e29b4dc4cdf8288bbdce62c3939d Mon Sep 17 00:00:00 2001 From: 0xSage Date: Mon, 9 Sep 2024 13:30:44 +0800 Subject: [PATCH 16/31] add model request form --- .github/ISSUE_TEMPLATE/feature_request.yml | 32 +++------------------- .github/ISSUE_TEMPLATE/model_request.yml | 21 ++++++++++++++ 2 files changed, 25 insertions(+), 28 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/model_request.yml diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 0f7f59f6c..8d961147d 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -3,24 +3,12 @@ description: "Suggest an idea for this project \U0001F63B!" title: 'feat: [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 Description" + 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..6b01d01a6 --- /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 and added to the Hub" +title: 'feat: [DESCRIPTION]' +labels: 'type: model request' +body: + - type: markdown + attributes: + value: "**Tip:** you can download any GGUF model from HuggingFace by pasting its ID in the Hub's search bar." + - 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) From e31921ba4dece266c91be1d04ea68d3147f9834a Mon Sep 17 00:00:00 2001 From: 0xSage Date: Mon, 9 Sep 2024 13:34:38 +0800 Subject: [PATCH 17/31] nit --- .github/ISSUE_TEMPLATE/model_request.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/model_request.yml b/.github/ISSUE_TEMPLATE/model_request.yml index 6b01d01a6..b308fe386 100644 --- a/.github/ISSUE_TEMPLATE/model_request.yml +++ b/.github/ISSUE_TEMPLATE/model_request.yml @@ -1,11 +1,11 @@ name: "\U0001F929 Model Request" -description: "Request a new model to be compiled and added to the Hub" +description: "Request a new model to be compiled" title: 'feat: [DESCRIPTION]' labels: 'type: model request' body: - type: markdown attributes: - value: "**Tip:** you can download any GGUF model from HuggingFace by pasting its ID in the Hub's search bar." + value: "**Tip:** Download any model by HuggingFace URL in the app hub's search bar. Use this form for unsupported models only." - type: textarea validations: required: true From 163952b914a4d0991d2a0c45cac80e84d7385b06 Mon Sep 17 00:00:00 2001 From: 0xSage Date: Mon, 9 Sep 2024 13:40:33 +0800 Subject: [PATCH 18/31] discussions --- .github/ISSUE_TEMPLATE/config.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From 85d90865ccaeed1e9a3fb5e8348b42b523d4907f Mon Sep 17 00:00:00 2001 From: 0xSage Date: Mon, 9 Sep 2024 14:59:24 +0800 Subject: [PATCH 19/31] add url --- .github/ISSUE_TEMPLATE/model_request.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/model_request.yml b/.github/ISSUE_TEMPLATE/model_request.yml index b308fe386..7f7c4f63c 100644 --- a/.github/ISSUE_TEMPLATE/model_request.yml +++ b/.github/ISSUE_TEMPLATE/model_request.yml @@ -5,7 +5,7 @@ labels: 'type: model request' body: - type: markdown attributes: - value: "**Tip:** Download any model by HuggingFace URL in the app hub's search bar. Use this form for unsupported models only." + 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 From 71c5c52c5421cbe321619a4355cc67cbeffe5d1f Mon Sep 17 00:00:00 2001 From: 0xSage Date: Mon, 9 Sep 2024 15:32:21 +0800 Subject: [PATCH 20/31] nit --- .github/ISSUE_TEMPLATE/feature_request.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 8d961147d..1738b2c4c 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -16,5 +16,5 @@ body: validations: required: true attributes: - label: "Feature Description" + label: "Feature Idea" description: "Describe what you want instead. Examples are welcome!" \ No newline at end of file From 9eee7dca52cac1713502d530e750bff28a249c05 Mon Sep 17 00:00:00 2001 From: 0xSage Date: Mon, 9 Sep 2024 15:33:10 +0800 Subject: [PATCH 21/31] idea --- .github/ISSUE_TEMPLATE/feature_request.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 1738b2c4c..b1a10e856 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,6 +1,6 @@ name: "\U0001F680 Feature Request" description: "Suggest an idea for this project \U0001F63B!" -title: 'feat: [DESCRIPTION]' +title: 'idea: [DESCRIPTION]' labels: 'type: feature request' body: - type: textarea From fe9936cf5b79580cb1cacb6e566a1594cf51e456 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Mon, 9 Sep 2024 16:46:36 +0700 Subject: [PATCH 22/31] fix: missing type number for slider input right panel (#3609) --- web/containers/SliderRightPanel/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/containers/SliderRightPanel/index.tsx b/web/containers/SliderRightPanel/index.tsx index 2a42ade61..af7569860 100644 --- a/web/containers/SliderRightPanel/index.tsx +++ b/web/containers/SliderRightPanel/index.tsx @@ -51,7 +51,7 @@ const SliderRightPanel = ({ { - onValueChanged?.(e[0]) + onValueChanged?.(Number(e[0])) setVal(e[0].toString()) }} min={min} @@ -87,7 +87,7 @@ const SliderRightPanel = ({ } }} onChange={(e) => { - onValueChanged?.(e.target.value) + onValueChanged?.(Number(e.target.value)) if (/^\d*\.?\d*$/.test(e.target.value)) { setVal(e.target.value) } From ac672476365a69c5a9d78b8a6fdd0e53591fe489 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Mon, 9 Sep 2024 16:47:47 +0700 Subject: [PATCH 23/31] test: finished all ui components test (#3588) * test: finished all ui components test * chore: update naming of mock handle change modal test --- joi/package.json | 1 + joi/src/core/Button/Button.test.tsx | 36 ++++-- joi/src/core/Checkbox/Checkbox.test.tsx | 50 ++++++++ joi/src/core/Input/Input.test.tsx | 53 +++++++++ joi/src/core/Input/index.tsx | 2 +- joi/src/core/Modal/Modal.test.tsx | 78 +++++++++++++ joi/src/core/Progress/Progress.test.tsx | 55 +++++++++ joi/src/core/Progress/index.tsx | 9 +- joi/src/core/ScrollArea/ScrollArea.test.tsx | 47 ++++++++ joi/src/core/Select/Select.test.tsx | 107 +++++++++++++++++ joi/src/core/Slider/Slider.test.tsx | 65 +++++++++++ joi/src/core/Switch/Switch.test.tsx | 52 +++++++++ joi/src/core/Tabs/Tabs.test.tsx | 99 ++++++++++++++++ joi/src/core/TextArea/TextArea.test.tsx | 34 ++++++ joi/src/core/Tooltip/Tooltip.test.tsx | 121 ++++++++++++++++++++ 15 files changed, 800 insertions(+), 9 deletions(-) create mode 100644 joi/src/core/Checkbox/Checkbox.test.tsx create mode 100644 joi/src/core/Input/Input.test.tsx create mode 100644 joi/src/core/Modal/Modal.test.tsx create mode 100644 joi/src/core/Progress/Progress.test.tsx create mode 100644 joi/src/core/ScrollArea/ScrollArea.test.tsx create mode 100644 joi/src/core/Select/Select.test.tsx create mode 100644 joi/src/core/Slider/Slider.test.tsx create mode 100644 joi/src/core/Switch/Switch.test.tsx create mode 100644 joi/src/core/Tabs/Tabs.test.tsx create mode 100644 joi/src/core/TextArea/TextArea.test.tsx create mode 100644 joi/src/core/Tooltip/Tooltip.test.tsx diff --git a/joi/package.json b/joi/package.json index c336cce12..576c33d72 100644 --- a/joi/package.json +++ b/joi/package.json @@ -52,6 +52,7 @@ "@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", diff --git a/joi/src/core/Button/Button.test.tsx b/joi/src/core/Button/Button.test.tsx index 3ff76143c..a4c679773 100644 --- a/joi/src/core/Button/Button.test.tsx +++ b/joi/src/core/Button/Button.test.tsx @@ -6,7 +6,7 @@ import { Button, buttonConfig } from './index' // Mock the styles jest.mock('./styles.scss', () => ({})) -describe('Button', () => { +describe('@joi/core/Button', () => { it('renders with default props', () => { render() const button = screen.getByRole('button', { name: /click me/i }) @@ -14,6 +14,12 @@ describe('Button', () => { 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 button = screen.getByRole('button', { name: /custom class button/i }) - expect(button).toHaveClass( - 'btn btn--primary btn--medium btn--solid custom-class' - ) + 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/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/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(