chore: improve models and threads caching (#3744)

* chore: managing and maintaining models and threads in the cache

* test: add tests for hooks
This commit is contained in:
Louis 2024-10-01 10:15:30 +07:00 committed by GitHub
parent 87a8bc7359
commit 87e1754e3a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 2084 additions and 84 deletions

View File

@ -16,14 +16,12 @@ import { mainViewStateAtom, showLeftPanelAtom } from '@/helpers/atoms/App.atom'
import { editMessageAtom } from '@/helpers/atoms/ChatMessage.atom'
import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom'
import { isDownloadALocalModelAtom } from '@/helpers/atoms/Model.atom'
import {
reduceTransparentAtom,
selectedSettingAtom,
} from '@/helpers/atoms/Setting.atom'
import {
isDownloadALocalModelAtom,
threadsAtom,
} from '@/helpers/atoms/Thread.atom'
import { threadsAtom } from '@/helpers/atoms/Thread.atom'
export default function RibbonPanel() {
const [mainViewState, setMainViewState] = useAtom(mainViewStateAtom)

View File

@ -513,7 +513,7 @@ const ModelDropdown = ({
const isDownloading = downloadingModels.some(
(md) => md.id === model.id
)
const isdDownloaded = downloadedModels.some(
const isDownloaded = downloadedModels.some(
(c) => c.id === model.id
)
return (
@ -528,7 +528,7 @@ const ModelDropdown = ({
onClick={() => {
if (!apiKey && !isLocalEngine(model.engine))
return null
if (isdDownloaded) {
if (isDownloaded) {
onClickModelItem(model.id)
}
}}
@ -537,7 +537,7 @@ const ModelDropdown = ({
<p
className={twMerge(
'line-clamp-1',
!isdDownloaded &&
!isDownloaded &&
'text-[hsla(var(--text-secondary))]'
)}
title={model.name}
@ -547,12 +547,12 @@ const ModelDropdown = ({
<ModelLabel metadata={model.metadata} compact />
</div>
<div className="flex items-center gap-2 text-[hsla(var(--text-tertiary))]">
{!isdDownloaded && (
{!isDownloaded && (
<span className="font-medium">
{toGibibytes(model.metadata.size)}
</span>
)}
{!isDownloading && !isdDownloaded ? (
{!isDownloading && !isDownloaded ? (
<DownloadCloudIcon
size={18}
className="cursor-pointer text-[hsla(var(--app-link))]"

View File

@ -0,0 +1,78 @@
// Extension.atom.test.ts
import { act, renderHook } from '@testing-library/react'
import * as ExtensionAtoms from './Extension.atom'
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
describe('Extension.atom.ts', () => {
afterEach(() => {
jest.clearAllMocks()
})
describe('installingExtensionAtom', () => {
it('should initialize as an empty array', () => {
const { result } = renderHook(() => useAtomValue(ExtensionAtoms.installingExtensionAtom))
expect(result.current).toEqual([])
})
})
describe('setInstallingExtensionAtom', () => {
it('should add a new installing extension', () => {
const { result: setAtom } = renderHook(() => useSetAtom(ExtensionAtoms.setInstallingExtensionAtom))
const { result: getAtom } = renderHook(() => useAtomValue(ExtensionAtoms.installingExtensionAtom))
act(() => {
setAtom.current('ext1', { extensionId: 'ext1', percentage: 0 })
})
expect(getAtom.current).toEqual([{ extensionId: 'ext1', percentage: 0 }])
})
it('should update an existing installing extension', () => {
const { result: setAtom } = renderHook(() => useSetAtom(ExtensionAtoms.setInstallingExtensionAtom))
const { result: getAtom } = renderHook(() => useAtomValue(ExtensionAtoms.installingExtensionAtom))
act(() => {
setAtom.current('ext1', { extensionId: 'ext1', percentage: 0 })
setAtom.current('ext1', { extensionId: 'ext1', percentage: 50 })
})
expect(getAtom.current).toEqual([{ extensionId: 'ext1', percentage: 50 }])
})
})
describe('removeInstallingExtensionAtom', () => {
it('should remove an installing extension', () => {
const { result: setAtom } = renderHook(() => useSetAtom(ExtensionAtoms.setInstallingExtensionAtom))
const { result: removeAtom } = renderHook(() => useSetAtom(ExtensionAtoms.removeInstallingExtensionAtom))
const { result: getAtom } = renderHook(() => useAtomValue(ExtensionAtoms.installingExtensionAtom))
act(() => {
setAtom.current('ext1', { extensionId: 'ext1', percentage: 0 })
setAtom.current('ext2', { extensionId: 'ext2', percentage: 50 })
removeAtom.current('ext1')
})
expect(getAtom.current).toEqual([{ extensionId: 'ext2', percentage: 50 }])
})
})
describe('inActiveEngineProviderAtom', () => {
it('should initialize as an empty array', () => {
const { result } = renderHook(() => useAtomValue(ExtensionAtoms.inActiveEngineProviderAtom))
expect(result.current).toEqual([])
})
it('should persist value in storage', () => {
const { result } = renderHook(() => useAtom(ExtensionAtoms.inActiveEngineProviderAtom))
act(() => {
result.current[1](['provider1', 'provider2'])
})
// Simulate a re-render to check if the value persists
const { result: newResult } = renderHook(() => useAtomValue(ExtensionAtoms.inActiveEngineProviderAtom))
expect(newResult.current).toEqual(['provider1', 'provider2'])
})
})
})

View File

@ -1,4 +1,4 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { act, renderHook } from '@testing-library/react'
import * as ModelAtoms from './Model.atom'
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
@ -24,11 +24,6 @@ describe('Model.atom.ts', () => {
})
})
})
describe('activeAssistantModelAtom', () => {
it('should initialize as undefined', () => {
expect(ModelAtoms.activeAssistantModelAtom.init).toBeUndefined()
})
})
describe('selectedModelAtom', () => {
it('should initialize as undefined', () => {

View File

@ -1,8 +1,59 @@
import { ImportingModel, InferenceEngine, Model, ModelFile } from '@janhq/core'
import { atom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
/**
* Enum for the keys used to store models in the local storage.
*/
enum ModelStorageAtomKeys {
DownloadedModels = 'downloadedModels',
AvailableModels = 'availableModels',
}
//// Models Atom
/**
* Downloaded Models Atom
* This atom stores the list of models that have been downloaded.
*/
export const downloadedModelsAtom = atomWithStorage<ModelFile[]>(
ModelStorageAtomKeys.DownloadedModels,
[]
)
/**
* Configured Models Atom
* This atom stores the list of models that have been configured and available to download
*/
export const configuredModelsAtom = atomWithStorage<ModelFile[]>(
ModelStorageAtomKeys.AvailableModels,
[]
)
export const removeDownloadedModelAtom = atom(
null,
(get, set, modelId: string) => {
const downloadedModels = get(downloadedModelsAtom)
set(
downloadedModelsAtom,
downloadedModels.filter((e) => e.id !== modelId)
)
}
)
/**
* Atom to store the selected model (from ModelDropdown)
*/
export const selectedModelAtom = atom<ModelFile | undefined>(undefined)
/**
* Atom to store the expanded engine sections (from ModelDropdown)
*/
export const showEngineListModelAtom = atom<string[]>([InferenceEngine.nitro])
/// End Models Atom
/// Model Download Atom
export const stateModel = atom({ state: 'start', loading: false, model: '' })
export const activeAssistantModelAtom = atom<Model | undefined>(undefined)
/**
* Stores the list of models which are being downloaded.
@ -30,28 +81,20 @@ export const removeDownloadingModelAtom = atom(
}
)
export const downloadedModelsAtom = atom<ModelFile[]>([])
export const removeDownloadedModelAtom = atom(
null,
(get, set, modelId: string) => {
const downloadedModels = get(downloadedModelsAtom)
set(
downloadedModelsAtom,
downloadedModels.filter((e) => e.id !== modelId)
)
}
)
export const configuredModelsAtom = atom<ModelFile[]>([])
export const defaultModelAtom = atom<Model | undefined>(undefined)
/// End Model Download Atom
/// Model Import Atom
/// TODO: move this part to another atom
// store the paths of the models that are being imported
export const importingModelsAtom = atom<ImportingModel[]>([])
// DEPRECATED: Remove when moving to cortex.cpp
// Default model template when importing
export const defaultModelAtom = atom<Model | undefined>(undefined)
/**
* Importing progress Atom
*/
export const updateImportingModelProgressAtom = atom(
null,
(get, set, importId: string, percentage: number) => {
@ -69,6 +112,9 @@ export const updateImportingModelProgressAtom = atom(
}
)
/**
* Importing error Atom
*/
export const setImportingModelErrorAtom = atom(
null,
(get, set, importId: string, error: string) => {
@ -87,6 +133,9 @@ export const setImportingModelErrorAtom = atom(
}
)
/**
* Importing success Atom
*/
export const setImportingModelSuccessAtom = atom(
null,
(get, set, importId: string, modelId: string) => {
@ -105,6 +154,9 @@ export const setImportingModelSuccessAtom = atom(
}
)
/**
* Update importing model metadata Atom
*/
export const updateImportingModelAtom = atom(
null,
(
@ -131,6 +183,9 @@ export const updateImportingModelAtom = atom(
}
)
export const selectedModelAtom = atom<ModelFile | undefined>(undefined)
/// End Model Import Atom
export const showEngineListModelAtom = atom<string[]>([InferenceEngine.nitro])
/// ModelDropdown States Atom
export const isDownloadALocalModelAtom = atom<boolean>(false)
export const isAnyRemoteModelConfiguredAtom = atom<boolean>(false)
/// End ModelDropdown States Atom

View File

@ -0,0 +1,146 @@
import { renderHook, act } from '@testing-library/react'
import { useAtom } from 'jotai'
import * as SystemBarAtoms from './SystemBar.atom'
describe('SystemBar.atom.ts', () => {
afterEach(() => {
jest.clearAllMocks()
})
describe('totalRamAtom', () => {
it('should initialize as 0', () => {
const { result } = renderHook(() => useAtom(SystemBarAtoms.totalRamAtom))
expect(result.current[0]).toBe(0)
})
it('should update correctly', () => {
const { result } = renderHook(() => useAtom(SystemBarAtoms.totalRamAtom))
act(() => {
result.current[1](16384)
})
expect(result.current[0]).toBe(16384)
})
})
describe('usedRamAtom', () => {
it('should initialize as 0', () => {
const { result } = renderHook(() => useAtom(SystemBarAtoms.usedRamAtom))
expect(result.current[0]).toBe(0)
})
it('should update correctly', () => {
const { result } = renderHook(() => useAtom(SystemBarAtoms.usedRamAtom))
act(() => {
result.current[1](8192)
})
expect(result.current[0]).toBe(8192)
})
})
describe('cpuUsageAtom', () => {
it('should initialize as 0', () => {
const { result } = renderHook(() => useAtom(SystemBarAtoms.cpuUsageAtom))
expect(result.current[0]).toBe(0)
})
it('should update correctly', () => {
const { result } = renderHook(() => useAtom(SystemBarAtoms.cpuUsageAtom))
act(() => {
result.current[1](50)
})
expect(result.current[0]).toBe(50)
})
})
describe('ramUtilitizedAtom', () => {
it('should initialize as 0', () => {
const { result } = renderHook(() =>
useAtom(SystemBarAtoms.ramUtilitizedAtom)
)
expect(result.current[0]).toBe(0)
})
it('should update correctly', () => {
const { result } = renderHook(() =>
useAtom(SystemBarAtoms.ramUtilitizedAtom)
)
act(() => {
result.current[1](75)
})
expect(result.current[0]).toBe(75)
})
})
describe('gpusAtom', () => {
it('should initialize as an empty array', () => {
const { result } = renderHook(() => useAtom(SystemBarAtoms.gpusAtom))
expect(result.current[0]).toEqual([])
})
it('should update correctly', () => {
const { result } = renderHook(() => useAtom(SystemBarAtoms.gpusAtom))
const gpus = [{ id: 'gpu1' }, { id: 'gpu2' }]
act(() => {
result.current[1](gpus as any)
})
expect(result.current[0]).toEqual(gpus)
})
})
describe('nvidiaTotalVramAtom', () => {
it('should initialize as 0', () => {
const { result } = renderHook(() =>
useAtom(SystemBarAtoms.nvidiaTotalVramAtom)
)
expect(result.current[0]).toBe(0)
})
it('should update correctly', () => {
const { result } = renderHook(() =>
useAtom(SystemBarAtoms.nvidiaTotalVramAtom)
)
act(() => {
result.current[1](8192)
})
expect(result.current[0]).toBe(8192)
})
})
describe('availableVramAtom', () => {
it('should initialize as 0', () => {
const { result } = renderHook(() =>
useAtom(SystemBarAtoms.availableVramAtom)
)
expect(result.current[0]).toBe(0)
})
it('should update correctly', () => {
const { result } = renderHook(() =>
useAtom(SystemBarAtoms.availableVramAtom)
)
act(() => {
result.current[1](4096)
})
expect(result.current[0]).toBe(4096)
})
})
describe('systemMonitorCollapseAtom', () => {
it('should initialize as false', () => {
const { result } = renderHook(() =>
useAtom(SystemBarAtoms.systemMonitorCollapseAtom)
)
expect(result.current[0]).toBe(false)
})
it('should update correctly', () => {
const { result } = renderHook(() =>
useAtom(SystemBarAtoms.systemMonitorCollapseAtom)
)
act(() => {
result.current[1](true)
})
expect(result.current[0]).toBe(true)
})
})
})

View File

@ -0,0 +1,187 @@
// Thread.atom.test.ts
import { act, renderHook } from '@testing-library/react'
import * as ThreadAtoms from './Thread.atom'
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
describe('Thread.atom.ts', () => {
afterEach(() => {
jest.clearAllMocks()
})
describe('threadStatesAtom', () => {
it('should initialize as an empty object', () => {
const { result: threadStatesAtom } = renderHook(() =>
useAtom(ThreadAtoms.threadsAtom)
)
expect(threadStatesAtom.current[0]).toEqual([])
})
})
describe('threadsAtom', () => {
it('should initialize as an empty array', () => {
const { result: threadsAtom } = renderHook(() =>
useAtom(ThreadAtoms.threadsAtom)
)
expect(threadsAtom.current[0]).toEqual([])
})
})
describe('threadDataReadyAtom', () => {
it('should initialize as false', () => {
const { result: threadDataReadyAtom } = renderHook(() =>
useAtom(ThreadAtoms.threadsAtom)
)
expect(threadDataReadyAtom.current[0]).toEqual([])
})
})
describe('activeThreadIdAtom', () => {
it('should set and get active thread id', () => {
const { result: getAtom } = renderHook(() =>
useAtomValue(ThreadAtoms.getActiveThreadIdAtom)
)
const { result: setAtom } = renderHook(() =>
useSetAtom(ThreadAtoms.setActiveThreadIdAtom)
)
expect(getAtom.current).toBeUndefined()
act(() => {
setAtom.current('thread-1')
})
expect(getAtom.current).toBe('thread-1')
})
})
describe('activeThreadAtom', () => {
it('should return the active thread', () => {
const { result: threadsAtom } = renderHook(() =>
useAtom(ThreadAtoms.threadsAtom)
)
const { result: setActiveThreadId } = renderHook(() =>
useSetAtom(ThreadAtoms.setActiveThreadIdAtom)
)
const { result: activeThread } = renderHook(() =>
useAtomValue(ThreadAtoms.activeThreadAtom)
)
act(() => {
threadsAtom.current[1]([
{ id: 'thread-1', title: 'Test Thread' },
] as any)
setActiveThreadId.current('thread-1')
})
expect(activeThread.current).toEqual({
id: 'thread-1',
title: 'Test Thread',
})
})
})
describe('updateThreadAtom', () => {
it('should update an existing thread', () => {
const { result: threadsAtom } = renderHook(() =>
useAtom(ThreadAtoms.threadsAtom)
)
const { result: updateThread } = renderHook(() =>
useSetAtom(ThreadAtoms.updateThreadAtom)
)
act(() => {
threadsAtom.current[1]([
{
id: 'thread-1',
title: 'Old Title',
updated: new Date('2023-01-01').toISOString(),
},
{
id: 'thread-2',
title: 'Thread 2',
updated: new Date('2023-01-02').toISOString(),
},
] as any)
})
act(() => {
updateThread.current({
id: 'thread-1',
title: 'New Title',
updated: new Date('2023-01-03').toISOString(),
} as any)
})
expect(threadsAtom.current[0]).toEqual([
{
id: 'thread-1',
title: 'New Title',
updated: new Date('2023-01-03').toISOString(),
},
{
id: 'thread-2',
title: 'Thread 2',
updated: new Date('2023-01-02').toISOString(),
},
])
})
})
describe('setThreadModelParamsAtom', () => {
it('should set thread model params', () => {
const { result: paramsAtom } = renderHook(() =>
useAtom(ThreadAtoms.threadModelParamsAtom)
)
const { result: setParams } = renderHook(() =>
useSetAtom(ThreadAtoms.setThreadModelParamsAtom)
)
act(() => {
setParams.current('thread-1', { modelName: 'gpt-3' } as any)
})
expect(paramsAtom.current[0]).toEqual({
'thread-1': { modelName: 'gpt-3' },
})
})
})
describe('deleteThreadStateAtom', () => {
it('should delete a thread state', () => {
const { result: statesAtom } = renderHook(() =>
useAtom(ThreadAtoms.threadStatesAtom)
)
const { result: deleteState } = renderHook(() =>
useSetAtom(ThreadAtoms.deleteThreadStateAtom)
)
act(() => {
statesAtom.current[1]({
'thread-1': { lastMessage: 'Hello' },
'thread-2': { lastMessage: 'Hi' },
} as any)
})
act(() => {
deleteState.current('thread-1')
})
expect(statesAtom.current[0]).toEqual({
'thread-2': { lastMessage: 'Hi' },
})
})
})
describe('modalActionThreadAtom', () => {
it('should initialize with undefined values', () => {
const { result } = renderHook(() =>
useAtomValue(ThreadAtoms.modalActionThreadAtom)
)
expect(result.current).toEqual({
showModal: undefined,
thread: undefined,
})
})
})
})

View File

@ -1,45 +1,91 @@
import {
ModelRuntimeParams,
ModelSettingParams,
Thread,
ThreadContent,
ThreadState,
} from '@janhq/core'
import { Thread, ThreadContent, ThreadState } from '@janhq/core'
import { atom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
import { ModelParams } from '@/types/model'
/**
* Thread Modal Action Enum
*/
export enum ThreadModalAction {
Clean = 'clean',
Delete = 'delete',
EditTitle = 'edit-title',
}
export const engineParamsUpdateAtom = atom<boolean>(false)
const ACTIVE_SETTING_INPUT_BOX = 'activeSettingInputBox'
/**
* Enum for the keys used to store models in the local storage.
*/
enum ThreadStorageAtomKeys {
ThreadStates = 'threadStates',
ThreadList = 'threadList',
ThreadListReady = 'threadListReady',
}
//// Threads Atom
/**
* Stores all thread states for the current user
*/
export const threadStatesAtom = atomWithStorage<Record<string, ThreadState>>(
ThreadStorageAtomKeys.ThreadStates,
{}
)
/**
* Stores all threads for the current user
*/
export const threadsAtom = atomWithStorage<Thread[]>(
ThreadStorageAtomKeys.ThreadList,
[]
)
/**
* Whether thread data is ready or not
* */
export const threadDataReadyAtom = atomWithStorage<boolean>(
ThreadStorageAtomKeys.ThreadListReady,
false
)
/**
* Store model params at thread level settings
*/
export const threadModelParamsAtom = atom<Record<string, ModelParams>>({})
//// End Thread Atom
/// Active Thread Atom
/**
* Stores the current active thread id.
*/
const activeThreadIdAtom = atom<string | undefined>(undefined)
/**
* Get the active thread id
*/
export const getActiveThreadIdAtom = atom((get) => get(activeThreadIdAtom))
/**
* Set the active thread id
*/
export const setActiveThreadIdAtom = atom(
null,
(_get, set, threadId: string | undefined) => set(activeThreadIdAtom, threadId)
)
export const waitingToSendMessage = atom<boolean | undefined>(undefined)
export const isGeneratingResponseAtom = atom<boolean | undefined>(undefined)
/**
* Stores all thread states for the current user
* Get the current active thread metadata
*/
export const threadStatesAtom = atom<Record<string, ThreadState>>({})
// Whether thread data is ready or not
export const threadDataReadyAtom = atom<boolean>(false)
export const activeThreadAtom = atom<Thread | undefined>((get) =>
get(threadsAtom).find((c) => c.id === get(getActiveThreadIdAtom))
)
/**
* Get the active thread state
*/
export const activeThreadStateAtom = atom<ThreadState | undefined>((get) => {
const threadId = get(activeThreadIdAtom)
if (!threadId) {
@ -50,6 +96,38 @@ export const activeThreadStateAtom = atom<ThreadState | undefined>((get) => {
return get(threadStatesAtom)[threadId]
})
/**
* Get the active thread model params
*/
export const getActiveThreadModelParamsAtom = atom<ModelParams | undefined>(
(get) => {
const threadId = get(activeThreadIdAtom)
if (!threadId) {
console.debug('Active thread id is undefined')
return undefined
}
return get(threadModelParamsAtom)[threadId]
}
)
/// End Active Thread Atom
/// Threads State Atom
export const engineParamsUpdateAtom = atom<boolean>(false)
/**
* Whether the thread is waiting to send a message
*/
export const waitingToSendMessage = atom<boolean | undefined>(undefined)
/**
* Whether the thread is generating a response
*/
export const isGeneratingResponseAtom = atom<boolean | undefined>(undefined)
/**
* Remove a thread state from the atom
*/
export const deleteThreadStateAtom = atom(
null,
(get, set, threadId: string) => {
@ -59,6 +137,9 @@ export const deleteThreadStateAtom = atom(
}
)
/**
* Update the thread state with the new state
*/
export const updateThreadWaitingForResponseAtom = atom(
null,
(get, set, threadId: string, waitingForResponse: boolean) => {
@ -71,6 +152,10 @@ export const updateThreadWaitingForResponseAtom = atom(
set(threadStatesAtom, currentState)
}
)
/**
* Update the thread last message
*/
export const updateThreadStateLastMessageAtom = atom(
null,
(get, set, threadId: string, lastContent?: ThreadContent[]) => {
@ -84,6 +169,9 @@ export const updateThreadStateLastMessageAtom = atom(
}
)
/**
* Update a thread with the new thread metadata
*/
export const updateThreadAtom = atom(
null,
(get, set, updatedThread: Thread) => {
@ -103,33 +191,8 @@ export const updateThreadAtom = atom(
)
/**
* Stores all threads for the current user
* Update the thread model params
*/
export const threadsAtom = atom<Thread[]>([])
export const activeThreadAtom = atom<Thread | undefined>((get) =>
get(threadsAtom).find((c) => c.id === get(getActiveThreadIdAtom))
)
/**
* Store model params at thread level settings
*/
export const threadModelParamsAtom = atom<Record<string, ModelParams>>({})
export type ModelParams = ModelRuntimeParams | ModelSettingParams
export const getActiveThreadModelParamsAtom = atom<ModelParams | undefined>(
(get) => {
const threadId = get(activeThreadIdAtom)
if (!threadId) {
console.debug('Active thread id is undefined')
return undefined
}
return get(threadModelParamsAtom)[threadId]
}
)
export const setThreadModelParamsAtom = atom(
null,
(get, set, threadId: string, params: ModelParams) => {
@ -139,12 +202,17 @@ export const setThreadModelParamsAtom = atom(
}
)
const ACTIVE_SETTING_INPUT_BOX = 'activeSettingInputBox'
/**
* Settings input box active state
*/
export const activeSettingInputBoxAtom = atomWithStorage<boolean>(
ACTIVE_SETTING_INPUT_BOX,
false
)
/**
* Whether thread thread is presenting a Modal or not
*/
export const modalActionThreadAtom = atom<{
showModal: ThreadModalAction | undefined
thread: Thread | undefined
@ -153,5 +221,4 @@ export const modalActionThreadAtom = atom<{
thread: undefined,
})
export const isDownloadALocalModelAtom = atom<boolean>(false)
export const isAnyRemoteModelConfiguredAtom = atom<boolean>(false)
/// Ebd Threads State Atom

View File

@ -0,0 +1,95 @@
import { renderHook, act } from '@testing-library/react'
import { useSetAtom } from 'jotai'
import { events, AssistantEvent, ExtensionTypeEnum } from '@janhq/core'
// Mock dependencies
jest.mock('jotai', () => ({
useAtomValue: jest.fn(),
useSetAtom: jest.fn(),
useAtom: jest.fn(),
atom: jest.fn(),
}))
jest.mock('@janhq/core')
jest.mock('@/extension')
import useAssistants from './useAssistants'
import { extensionManager } from '@/extension'
// Mock data
const mockAssistants = [
{ id: 'assistant-1', name: 'Assistant 1' },
{ id: 'assistant-2', name: 'Assistant 2' },
]
const mockAssistantExtension = {
getAssistants: jest.fn().mockResolvedValue(mockAssistants),
} as any
describe('useAssistants', () => {
beforeEach(() => {
jest.clearAllMocks()
jest.spyOn(extensionManager, 'get').mockReturnValue(mockAssistantExtension)
})
it('should fetch and set assistants on mount', async () => {
const mockSetAssistants = jest.fn()
;(useSetAtom as jest.Mock).mockReturnValue(mockSetAssistants)
renderHook(() => useAssistants())
// Wait for useEffect to complete
await act(async () => {})
expect(mockAssistantExtension.getAssistants).toHaveBeenCalled()
expect(mockSetAssistants).toHaveBeenCalledWith(mockAssistants)
})
it('should update assistants when AssistantEvent.OnAssistantsUpdate is emitted', async () => {
const mockSetAssistants = jest.fn()
;(useSetAtom as jest.Mock).mockReturnValue(mockSetAssistants)
renderHook(() => useAssistants())
// Wait for initial useEffect to complete
await act(async () => {})
// Clear previous calls
mockSetAssistants.mockClear()
// Simulate AssistantEvent.OnAssistantsUpdate event
await act(async () => {
events.emit(AssistantEvent.OnAssistantsUpdate, '')
})
expect(mockAssistantExtension.getAssistants).toHaveBeenCalledTimes(1)
})
it('should unsubscribe from events on unmount', async () => {
const { unmount } = renderHook(() => useAssistants())
// Wait for useEffect to complete
await act(async () => {})
const offSpy = jest.spyOn(events, 'off')
unmount()
expect(offSpy).toHaveBeenCalledWith(
AssistantEvent.OnAssistantsUpdate,
expect.any(Function)
)
})
it('should handle case when AssistantExtension is not available', async () => {
const mockSetAssistants = jest.fn()
;(useSetAtom as jest.Mock).mockReturnValue(mockSetAssistants)
;(extensionManager.get as jest.Mock).mockReturnValue(undefined)
renderHook(() => useAssistants())
// Wait for useEffect to complete
await act(async () => {})
expect(mockSetAssistants).toHaveBeenCalledWith([])
})
})

View File

@ -0,0 +1,105 @@
import { renderHook, act } from '@testing-library/react'
import { useClipboard } from './useClipboard'
describe('useClipboard', () => {
let originalClipboard: any
beforeAll(() => {
originalClipboard = { ...global.navigator.clipboard }
const mockClipboard = {
writeText: jest.fn(() => Promise.resolve()),
}
// @ts-ignore
global.navigator.clipboard = mockClipboard
})
afterAll(() => {
// @ts-ignore
global.navigator.clipboard = originalClipboard
})
beforeEach(() => {
jest.useFakeTimers()
})
afterEach(() => {
jest.clearAllTimers()
jest.useRealTimers()
})
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 copied to false 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 handle clipboard errors', async () => {
const mockError = new Error('Clipboard error')
// @ts-ignore
navigator.clipboard.writeText.mockRejectedValueOnce(mockError)
const { result } = renderHook(() => useClipboard())
await act(async () => {
result.current.copy('Test text')
})
expect(result.current.error).toEqual(mockError)
expect(result.current.copied).toBe(false)
})
it('should reset state', 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)
})
it('should handle missing clipboard API', () => {
// @ts-ignore
delete global.navigator.clipboard
const { result } = renderHook(() => useClipboard())
act(() => {
result.current.copy('Test text')
})
expect(result.current.error).toEqual(
new Error('useClipboard: navigator.clipboard is not supported')
)
expect(result.current.copied).toBe(false)
})
})

View File

@ -0,0 +1,73 @@
import { renderHook, act } from '@testing-library/react'
import { extensionManager } from '@/extension/ExtensionManager'
import useDeleteModel from './useDeleteModel'
import { toaster } from '@/containers/Toast'
import { useSetAtom } from 'jotai'
// Mock the dependencies
jest.mock('@/extension/ExtensionManager')
jest.mock('@/containers/Toast')
jest.mock('jotai', () => ({
useSetAtom: jest.fn(() => jest.fn()),
atom: jest.fn(),
}))
describe('useDeleteModel', () => {
const mockModel: any = {
id: 'test-model',
name: 'Test Model',
// Add other required properties of ModelFile
}
const mockDeleteModel = jest.fn()
beforeEach(() => {
jest.clearAllMocks()
;(extensionManager.get as jest.Mock).mockReturnValue({
deleteModel: mockDeleteModel,
})
})
it('should delete a model successfully', async () => {
const { result } = renderHook(() => useDeleteModel())
await act(async () => {
await result.current.deleteModel(mockModel)
})
expect(mockDeleteModel).toHaveBeenCalledWith(mockModel)
expect(toaster).toHaveBeenCalledWith({
title: 'Model Deletion Successful',
description: `Model ${mockModel.name} has been successfully deleted.`,
type: 'success',
})
})
it('should call removeDownloadedModel with the model id', async () => {
const { result } = renderHook(() => useDeleteModel())
await act(async () => {
await result.current.deleteModel(mockModel)
})
// Assuming useSetAtom returns a mock function
;(useSetAtom as jest.Mock).mockReturnValue(jest.fn())
expect(useSetAtom).toHaveBeenCalled()
})
it('should handle errors during model deletion', async () => {
const error = new Error('Deletion failed')
mockDeleteModel.mockRejectedValue(error)
const { result } = renderHook(() => useDeleteModel())
await act(async () => {
await expect(result.current.deleteModel(mockModel)).rejects.toThrow(
'Deletion failed'
)
})
expect(mockDeleteModel).toHaveBeenCalledWith(mockModel)
expect(toaster).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,106 @@
import { renderHook, act } from '@testing-library/react'
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import useDeleteThread from './useDeleteThread'
import { extensionManager } from '@/extension/ExtensionManager'
import { toaster } from '@/containers/Toast'
// Mock the necessary dependencies
// Mock dependencies
jest.mock('jotai', () => ({
useAtomValue: jest.fn(),
useSetAtom: jest.fn(),
useAtom: jest.fn(),
atom: jest.fn(),
}))
jest.mock('@/extension/ExtensionManager')
jest.mock('@/containers/Toast')
describe('useDeleteThread', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('should delete a thread successfully', async () => {
const mockThreads = [
{ id: 'thread1', title: 'Thread 1' },
{ id: 'thread2', title: 'Thread 2' },
]
const mockSetThreads = jest.fn()
;(useAtom as jest.Mock).mockReturnValue([mockThreads, mockSetThreads])
const mockDeleteThread = jest.fn()
extensionManager.get = jest.fn().mockReturnValue({
deleteThread: mockDeleteThread,
})
const { result } = renderHook(() => useDeleteThread())
await act(async () => {
await result.current.deleteThread('thread1')
})
expect(mockDeleteThread).toHaveBeenCalledWith('thread1')
expect(mockSetThreads).toHaveBeenCalledWith([mockThreads[1]])
})
it('should clean a thread successfully', async () => {
const mockThreads = [{ id: 'thread1', title: 'Thread 1', metadata: {} }]
const mockSetThreads = jest.fn()
;(useAtom as jest.Mock).mockReturnValue([mockThreads, mockSetThreads])
const mockCleanMessages = jest.fn()
;(useSetAtom as jest.Mock).mockReturnValue(() => mockCleanMessages)
;(useAtomValue as jest.Mock).mockReturnValue(['thread 1'])
const mockWriteMessages = jest.fn()
const mockSaveThread = jest.fn()
extensionManager.get = jest.fn().mockReturnValue({
writeMessages: mockWriteMessages,
saveThread: mockSaveThread,
})
const { result } = renderHook(() => useDeleteThread())
await act(async () => {
await result.current.cleanThread('thread1')
})
expect(mockWriteMessages).toHaveBeenCalled()
expect(mockSaveThread).toHaveBeenCalledWith(
expect.objectContaining({
id: 'thread1',
title: 'New Thread',
metadata: expect.objectContaining({ lastMessage: undefined }),
})
)
})
it('should handle errors when deleting a thread', async () => {
const mockThreads = [{ id: 'thread1', title: 'Thread 1' }]
const mockSetThreads = jest.fn()
;(useAtom as jest.Mock).mockReturnValue([mockThreads, mockSetThreads])
const mockDeleteThread = jest
.fn()
.mockRejectedValue(new Error('Delete error'))
extensionManager.get = jest.fn().mockReturnValue({
deleteThread: mockDeleteThread,
})
const consoleErrorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => {})
const { result } = renderHook(() => useDeleteThread())
await act(async () => {
await result.current.deleteThread('thread1')
})
expect(mockDeleteThread).toHaveBeenCalledWith('thread1')
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.any(Error))
expect(mockSetThreads).not.toHaveBeenCalled()
expect(toaster).not.toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
})

View File

@ -0,0 +1,98 @@
import { renderHook, act } from '@testing-library/react'
import { useAtom, useSetAtom } from 'jotai'
import useDownloadModel from './useDownloadModel'
import * as core from '@janhq/core'
import { extensionManager } from '@/extension/ExtensionManager'
// Mock the necessary dependencies
jest.mock('jotai', () => ({
useAtomValue: jest.fn(),
useSetAtom: jest.fn(),
useAtom: jest.fn(),
atom: jest.fn(),
}))
jest.mock('@janhq/core')
jest.mock('@/extension/ExtensionManager')
jest.mock('./useGpuSetting', () => ({
__esModule: true,
default: () => ({
getGpuSettings: jest.fn().mockResolvedValue({ some: 'gpuSettings' }),
}),
}))
describe('useDownloadModel', () => {
beforeEach(() => {
jest.clearAllMocks()
;(useAtom as jest.Mock).mockReturnValue([false, jest.fn()])
})
it('should download a model', async () => {
const mockModel: core.Model = {
id: 'test-model',
sources: [{ filename: 'test.bin' }],
} as core.Model
const mockExtension = {
downloadModel: jest.fn().mockResolvedValue(undefined),
}
;(useSetAtom as jest.Mock).mockReturnValue(() => undefined)
;(extensionManager.get as jest.Mock).mockReturnValue(mockExtension)
const { result } = renderHook(() => useDownloadModel())
await act(async () => {
await result.current.downloadModel(mockModel)
})
expect(mockExtension.downloadModel).toHaveBeenCalledWith(
mockModel,
{ some: 'gpuSettings' },
{ ignoreSSL: undefined, proxy: '' }
)
})
it('should abort model download', async () => {
const mockModel: core.Model = {
id: 'test-model',
sources: [{ filename: 'test.bin' }],
} as core.Model
;(core.joinPath as jest.Mock).mockResolvedValue('/path/to/model/test.bin')
;(core.abortDownload as jest.Mock).mockResolvedValue(undefined)
;(useSetAtom as jest.Mock).mockReturnValue(() => undefined)
const { result } = renderHook(() => useDownloadModel())
await act(async () => {
await result.current.abortModelDownload(mockModel)
})
expect(core.abortDownload).toHaveBeenCalledWith('/path/to/model/test.bin')
})
it('should handle proxy settings', async () => {
const mockModel: core.Model = {
id: 'test-model',
sources: [{ filename: 'test.bin' }],
} as core.Model
const mockExtension = {
downloadModel: jest.fn().mockResolvedValue(undefined),
}
;(useSetAtom as jest.Mock).mockReturnValue(() => undefined)
;(extensionManager.get as jest.Mock).mockReturnValue(mockExtension)
;(useAtom as jest.Mock).mockReturnValueOnce([true, jest.fn()]) // proxyEnabled
;(useAtom as jest.Mock).mockReturnValueOnce(['http://proxy.com', jest.fn()]) // proxy
const { result } = renderHook(() => useDownloadModel())
await act(async () => {
await result.current.downloadModel(mockModel)
})
expect(mockExtension.downloadModel).toHaveBeenCalledWith(
mockModel,
expect.objectContaining({ some: 'gpuSettings' }),
expect.anything()
)
})
})

View File

@ -0,0 +1,129 @@
// useDropModelBinaries.test.ts
import { renderHook, act } from '@testing-library/react'
import { useSetAtom } from 'jotai'
import { v4 as uuidv4 } from 'uuid'
import useDropModelBinaries from './useDropModelBinaries'
import { getFileInfoFromFile } from '@/utils/file'
import { snackbar } from '@/containers/Toast'
// Mock dependencies
// Mock the necessary dependencies
jest.mock('jotai', () => ({
useAtomValue: jest.fn(),
useSetAtom: jest.fn(),
useAtom: jest.fn(),
atom: jest.fn(),
}))
jest.mock('uuid')
jest.mock('@/utils/file')
jest.mock('@/containers/Toast')
describe('useDropModelBinaries', () => {
const mockSetImportingModels = jest.fn()
const mockSetImportModelStage = jest.fn()
beforeEach(() => {
jest.clearAllMocks()
;(useSetAtom as jest.Mock).mockReturnValueOnce(mockSetImportingModels)
;(useSetAtom as jest.Mock).mockReturnValueOnce(mockSetImportModelStage)
;(uuidv4 as jest.Mock).mockReturnValue('mock-uuid')
;(getFileInfoFromFile as jest.Mock).mockResolvedValue([])
})
it('should handle dropping supported files', async () => {
const { result } = renderHook(() => useDropModelBinaries())
const mockFiles = [
{ name: 'model1.gguf', path: '/path/to/model1.gguf', size: 1000 },
{ name: 'model2.gguf', path: '/path/to/model2.gguf', size: 2000 },
]
;(getFileInfoFromFile as jest.Mock).mockResolvedValue(mockFiles)
await act(async () => {
await result.current.onDropModels([])
})
expect(mockSetImportingModels).toHaveBeenCalledWith([
{
importId: 'mock-uuid',
modelId: undefined,
name: 'model1',
description: '',
path: '/path/to/model1.gguf',
tags: [],
size: 1000,
status: 'PREPARING',
format: 'gguf',
},
{
importId: 'mock-uuid',
modelId: undefined,
name: 'model2',
description: '',
path: '/path/to/model2.gguf',
tags: [],
size: 2000,
status: 'PREPARING',
format: 'gguf',
},
])
expect(mockSetImportModelStage).toHaveBeenCalledWith('MODEL_SELECTED')
})
it('should handle dropping unsupported files', async () => {
const { result } = renderHook(() => useDropModelBinaries())
const mockFiles = [
{ name: 'unsupported.txt', path: '/path/to/unsupported.txt', size: 500 },
]
;(getFileInfoFromFile as jest.Mock).mockResolvedValue(mockFiles)
await act(async () => {
await result.current.onDropModels([])
})
expect(snackbar).toHaveBeenCalledWith({
description: 'Only files with .gguf extension can be imported.',
type: 'error',
})
expect(mockSetImportingModels).not.toHaveBeenCalled()
expect(mockSetImportModelStage).not.toHaveBeenCalled()
})
it('should handle dropping both supported and unsupported files', async () => {
const { result } = renderHook(() => useDropModelBinaries())
const mockFiles = [
{ name: 'model.gguf', path: '/path/to/model.gguf', size: 1000 },
{ name: 'unsupported.txt', path: '/path/to/unsupported.txt', size: 500 },
]
;(getFileInfoFromFile as jest.Mock).mockResolvedValue(mockFiles)
await act(async () => {
await result.current.onDropModels([])
})
expect(snackbar).toHaveBeenCalledWith({
description: 'Only files with .gguf extension can be imported.',
type: 'error',
})
expect(mockSetImportingModels).toHaveBeenCalledWith([
{
importId: 'mock-uuid',
modelId: undefined,
name: 'model',
description: '',
path: '/path/to/model.gguf',
tags: [],
size: 1000,
status: 'PREPARING',
format: 'gguf',
},
])
expect(mockSetImportModelStage).toHaveBeenCalledWith('MODEL_SELECTED')
})
})

View File

@ -0,0 +1,89 @@
import { renderHook, act } from '@testing-library/react'
import { useAtomValue, useSetAtom } from 'jotai'
import useFactoryReset, { FactoryResetState } from './useFactoryReset'
import { useActiveModel } from './useActiveModel'
import { fs } from '@janhq/core'
// Mock the dependencies
jest.mock('jotai', () => ({
atom: jest.fn(),
useAtomValue: jest.fn(),
useSetAtom: jest.fn(),
}))
jest.mock('./useActiveModel', () => ({
useActiveModel: jest.fn(),
}))
jest.mock('@janhq/core', () => ({
fs: {
rm: jest.fn(),
},
}))
describe('useFactoryReset', () => {
const mockStopModel = jest.fn()
const mockSetFactoryResetState = jest.fn()
const mockGetAppConfigurations = jest.fn()
const mockUpdateAppConfiguration = jest.fn()
const mockRelaunch = jest.fn()
beforeEach(() => {
jest.clearAllMocks()
;(useAtomValue as jest.Mock).mockReturnValue('/default/jan/data/folder')
;(useSetAtom as jest.Mock).mockReturnValue(mockSetFactoryResetState)
;(useActiveModel as jest.Mock).mockReturnValue({ stopModel: mockStopModel })
global.window ??= Object.create(window)
global.window.core = {
api: {
getAppConfigurations: mockGetAppConfigurations,
updateAppConfiguration: mockUpdateAppConfiguration,
relaunch: mockRelaunch,
},
}
mockGetAppConfigurations.mockResolvedValue({
data_folder: '/current/jan/data/folder',
quick_ask: false,
})
jest.spyOn(global, 'setTimeout')
})
it('should reset all correctly', async () => {
const { result } = renderHook(() => useFactoryReset())
await act(async () => {
await result.current.resetAll()
})
expect(mockSetFactoryResetState).toHaveBeenCalledWith(
FactoryResetState.Starting
)
expect(mockSetFactoryResetState).toHaveBeenCalledWith(
FactoryResetState.StoppingModel
)
expect(mockStopModel).toHaveBeenCalled()
expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 4000)
expect(mockSetFactoryResetState).toHaveBeenCalledWith(
FactoryResetState.DeletingData
)
expect(fs.rm).toHaveBeenCalledWith('/current/jan/data/folder')
expect(mockUpdateAppConfiguration).toHaveBeenCalledWith({
data_folder: '/default/jan/data/folder',
quick_ask: false,
})
expect(mockSetFactoryResetState).toHaveBeenCalledWith(
FactoryResetState.ClearLocalStorage
)
expect(mockRelaunch).toHaveBeenCalled()
})
it('should keep current folder when specified', async () => {
const { result } = renderHook(() => useFactoryReset())
await act(async () => {
await result.current.resetAll(true)
})
expect(mockUpdateAppConfiguration).not.toHaveBeenCalled()
})
// Add more tests as needed for error cases, edge cases, etc.
})

View File

@ -0,0 +1,39 @@
import { renderHook, act } from '@testing-library/react'
import { useGetHFRepoData } from './useGetHFRepoData'
import { extensionManager } from '@/extension'
jest.mock('@/extension', () => ({
extensionManager: {
get: jest.fn(),
},
}))
describe('useGetHFRepoData', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('should fetch HF repo data successfully', async () => {
const mockData = { name: 'Test Repo', stars: 100 }
const mockFetchHuggingFaceRepoData = jest.fn().mockResolvedValue(mockData)
;(extensionManager.get as jest.Mock).mockReturnValue({
fetchHuggingFaceRepoData: mockFetchHuggingFaceRepoData,
})
const { result } = renderHook(() => useGetHFRepoData())
expect(result.current.loading).toBe(false)
expect(result.current.error).toBeUndefined()
let data
act(() => {
data = result.current.getHfRepoData('test-repo')
})
expect(result.current.loading).toBe(true)
expect(result.current.error).toBeUndefined()
expect(await data).toEqual(mockData)
expect(mockFetchHuggingFaceRepoData).toHaveBeenCalledWith('test-repo')
})
})

View File

@ -0,0 +1,103 @@
// useGetSystemResources.test.ts
import { renderHook, act } from '@testing-library/react'
import useGetSystemResources from './useGetSystemResources'
import { extensionManager } from '@/extension/ExtensionManager'
// Mock the extensionManager
jest.mock('@/extension/ExtensionManager', () => ({
extensionManager: {
get: jest.fn(),
},
}))
// Mock the necessary dependencies
jest.mock('jotai', () => ({
useAtomValue: jest.fn(),
useSetAtom: () => jest.fn(),
useAtom: jest.fn(),
atom: jest.fn(),
}))
describe('useGetSystemResources', () => {
const mockMonitoringExtension = {
getResourcesInfo: jest.fn(),
getCurrentLoad: jest.fn(),
}
beforeEach(() => {
jest.useFakeTimers()
;(extensionManager.get as jest.Mock).mockReturnValue(
mockMonitoringExtension
)
})
afterEach(() => {
jest.clearAllMocks()
jest.useRealTimers()
})
it('should fetch system resources on initial render', async () => {
mockMonitoringExtension.getResourcesInfo.mockResolvedValue({
mem: { usedMemory: 4000, totalMemory: 8000 },
})
mockMonitoringExtension.getCurrentLoad.mockResolvedValue({
cpu: { usage: 50 },
gpu: [],
})
const { result } = renderHook(() => useGetSystemResources())
expect(mockMonitoringExtension.getResourcesInfo).toHaveBeenCalledTimes(1)
})
it('should start watching system resources when watch is called', () => {
const { result } = renderHook(() => useGetSystemResources())
act(() => {
result.current.watch()
})
expect(mockMonitoringExtension.getResourcesInfo).toHaveBeenCalled()
// Fast-forward time by 2 seconds
act(() => {
jest.advanceTimersByTime(2000)
})
expect(mockMonitoringExtension.getResourcesInfo).toHaveBeenCalled()
})
it('should stop watching when stopWatching is called', () => {
const { result } = renderHook(() => useGetSystemResources())
act(() => {
result.current.watch()
})
act(() => {
result.current.stopWatching()
})
// Fast-forward time by 2 seconds
act(() => {
jest.advanceTimersByTime(2000)
})
// Expect no additional calls after stopping
expect(mockMonitoringExtension.getResourcesInfo).toHaveBeenCalled()
})
it('should not fetch resources if monitoring extension is not available', async () => {
;(extensionManager.get as jest.Mock).mockReturnValue(null)
const { result } = renderHook(() => useGetSystemResources())
await act(async () => {
result.current.getSystemResources()
})
expect(mockMonitoringExtension.getResourcesInfo).not.toHaveBeenCalled()
expect(mockMonitoringExtension.getCurrentLoad).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,87 @@
// useGpuSetting.test.ts
import { renderHook, act } from '@testing-library/react'
import { ExtensionTypeEnum, MonitoringExtension } from '@janhq/core'
// Mock dependencies
jest.mock('@/extension')
import useGpuSetting from './useGpuSetting'
import { extensionManager } from '@/extension'
describe('useGpuSetting', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('should return GPU settings when available', async () => {
const mockGpuSettings = {
gpuCount: 2,
gpuNames: ['NVIDIA GeForce RTX 3080', 'NVIDIA GeForce RTX 3070'],
totalMemory: 20000,
freeMemory: 15000,
}
const mockMonitoringExtension: Partial<MonitoringExtension> = {
getGpuSetting: jest.fn().mockResolvedValue(mockGpuSettings),
}
jest
.spyOn(extensionManager, 'get')
.mockReturnValue(mockMonitoringExtension as MonitoringExtension)
const { result } = renderHook(() => useGpuSetting())
let gpuSettings
await act(async () => {
gpuSettings = await result.current.getGpuSettings()
})
expect(gpuSettings).toEqual(mockGpuSettings)
expect(extensionManager.get).toHaveBeenCalledWith(
ExtensionTypeEnum.SystemMonitoring
)
expect(mockMonitoringExtension.getGpuSetting).toHaveBeenCalled()
})
it('should return undefined when no GPU settings are found', async () => {
const mockMonitoringExtension: Partial<MonitoringExtension> = {
getGpuSetting: jest.fn().mockResolvedValue(undefined),
}
jest
.spyOn(extensionManager, 'get')
.mockReturnValue(mockMonitoringExtension as MonitoringExtension)
const { result } = renderHook(() => useGpuSetting())
let gpuSettings
await act(async () => {
gpuSettings = await result.current.getGpuSettings()
})
expect(gpuSettings).toBeUndefined()
expect(extensionManager.get).toHaveBeenCalledWith(
ExtensionTypeEnum.SystemMonitoring
)
expect(mockMonitoringExtension.getGpuSetting).toHaveBeenCalled()
})
it('should handle missing MonitoringExtension', async () => {
jest.spyOn(extensionManager, 'get').mockReturnValue(undefined)
jest.spyOn(console, 'debug').mockImplementation(() => {})
const { result } = renderHook(() => useGpuSetting())
let gpuSettings
await act(async () => {
gpuSettings = await result.current.getGpuSettings()
})
expect(gpuSettings).toBeUndefined()
expect(extensionManager.get).toHaveBeenCalledWith(
ExtensionTypeEnum.SystemMonitoring
)
expect(console.debug).toHaveBeenCalledWith('No GPU setting found')
})
})

View File

@ -0,0 +1,70 @@
// useImportModel.test.ts
import { renderHook, act } from '@testing-library/react'
import { extensionManager } from '@/extension'
import useImportModel from './useImportModel'
// Mock dependencies
jest.mock('@janhq/core')
jest.mock('@/extension')
jest.mock('@/containers/Toast')
jest.mock('uuid', () => ({ v4: () => 'mocked-uuid' }))
describe('useImportModel', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('should import models successfully', async () => {
const mockImportModels = jest.fn().mockResolvedValue(undefined)
const mockExtension = {
importModels: mockImportModels,
} as any
jest.spyOn(extensionManager, 'get').mockReturnValue(mockExtension)
const { result } = renderHook(() => useImportModel())
const models = [
{ importId: '1', name: 'Model 1', path: '/path/to/model1' },
{ importId: '2', name: 'Model 2', path: '/path/to/model2' },
] as any
await act(async () => {
await result.current.importModels(models, 'local' as any)
})
expect(mockImportModels).toHaveBeenCalledWith(models, 'local')
})
it('should update model info successfully', async () => {
const mockUpdateModelInfo = jest
.fn()
.mockResolvedValue({ id: 'model-1', name: 'Updated Model' })
const mockExtension = {
updateModelInfo: mockUpdateModelInfo,
} as any
jest.spyOn(extensionManager, 'get').mockReturnValue(mockExtension)
const { result } = renderHook(() => useImportModel())
const modelInfo = { id: 'model-1', name: 'Updated Model' }
await act(async () => {
await result.current.updateModelInfo(modelInfo)
})
expect(mockUpdateModelInfo).toHaveBeenCalledWith(modelInfo)
})
it('should handle empty file paths', async () => {
const { result } = renderHook(() => useImportModel())
await act(async () => {
await result.current.sanitizeFilePaths([])
})
// Expect no state changes or side effects
})
})

View File

@ -0,0 +1,111 @@
import { renderHook, act } from '@testing-library/react'
import { useTheme } from 'next-themes'
import { fs, joinPath } from '@janhq/core'
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import { useLoadTheme } from './useLoadTheme'
// Mock dependencies
jest.mock('next-themes')
jest.mock('@janhq/core')
// Mock dependencies
jest.mock('jotai', () => ({
useAtomValue: jest.fn(),
useSetAtom: jest.fn(),
useAtom: jest.fn(),
atom: jest.fn(),
}))
describe('useLoadTheme', () => {
beforeEach(() => {
jest.clearAllMocks()
})
const mockJanDataFolderPath = '/mock/path'
const mockThemesPath = '/mock/path/themes'
const mockSelectedThemeId = 'joi-light'
const mockThemeData = {
id: 'joi-light',
displayName: 'Joi Light',
nativeTheme: 'light',
variables: {
'--primary-color': '#007bff',
},
}
it('should load theme and set variables', async () => {
// Mock Jotai hooks
;(useAtomValue as jest.Mock).mockReturnValue(mockJanDataFolderPath)
;(useSetAtom as jest.Mock).mockReturnValue(jest.fn())
;(useAtom as jest.Mock).mockReturnValue([mockSelectedThemeId, jest.fn()])
;(useAtom as jest.Mock).mockReturnValue([mockThemeData, jest.fn()])
// Mock fs and joinPath
;(fs.readdirSync as jest.Mock).mockResolvedValue(['joi-light', 'joi-dark'])
;(fs.readFileSync as jest.Mock).mockResolvedValue(
JSON.stringify(mockThemeData)
)
;(joinPath as jest.Mock).mockImplementation((paths) => paths.join('/'))
// Mock setTheme from next-themes
const mockSetTheme = jest.fn()
;(useTheme as jest.Mock).mockReturnValue({ setTheme: mockSetTheme })
// Mock window.electronAPI
Object.defineProperty(window, 'electronAPI', {
value: {
setNativeThemeLight: jest.fn(),
setNativeThemeDark: jest.fn(),
},
writable: true,
})
const { result } = renderHook(() => useLoadTheme())
await act(async () => {
await result.current
})
// Assertions
expect(fs.readdirSync).toHaveBeenCalledWith(mockThemesPath)
expect(fs.readFileSync).toHaveBeenCalledWith(
`${mockThemesPath}/${mockSelectedThemeId}/theme.json`,
'utf-8'
)
expect(mockSetTheme).toHaveBeenCalledWith('light')
expect(window.electronAPI.setNativeThemeLight).toHaveBeenCalled()
})
it('should set default theme if no selected theme', async () => {
// Mock Jotai hooks with empty selected theme
;(useAtomValue as jest.Mock).mockReturnValue(mockJanDataFolderPath)
;(useSetAtom as jest.Mock).mockReturnValue(jest.fn())
;(useAtom as jest.Mock).mockReturnValue(['', jest.fn()])
;(useAtom as jest.Mock).mockReturnValue([{}, jest.fn()])
const mockSetSelectedThemeId = jest.fn()
;(useAtom as jest.Mock).mockReturnValue(['', mockSetSelectedThemeId])
const { result } = renderHook(() => useLoadTheme())
await act(async () => {
await result.current
})
expect(mockSetSelectedThemeId).toHaveBeenCalledWith('joi-light')
})
it('should handle missing janDataFolderPath', async () => {
// Mock Jotai hooks with empty janDataFolderPath
;(useAtomValue as jest.Mock).mockReturnValue('')
const { result } = renderHook(() => useLoadTheme())
await act(async () => {
await result.current
})
expect(fs.readdirSync).not.toHaveBeenCalled()
})
})

103
web/hooks/useLogs.test.ts Normal file
View File

@ -0,0 +1,103 @@
// useLogs.test.ts
import { renderHook, act } from '@testing-library/react'
import { useAtomValue } from 'jotai'
import { fs, joinPath, openFileExplorer } from '@janhq/core'
import { useLogs } from './useLogs'
// Mock dependencies
jest.mock('jotai', () => ({
useAtomValue: jest.fn(),
atom: jest.fn(),
}))
jest.mock('@janhq/core', () => ({
fs: {
existsSync: jest.fn(),
readFileSync: jest.fn(),
writeFileSync: jest.fn(),
},
joinPath: jest.fn(),
openFileExplorer: jest.fn(),
}))
describe('useLogs', () => {
beforeEach(() => {
jest.clearAllMocks()
;(useAtomValue as jest.Mock).mockReturnValue('/mock/jan/data/folder')
})
it('should get logs and sanitize them', async () => {
const mockLogs = '/mock/jan/data/folder/some/log/content'
const expectedSanitizedLogs = 'jan-data-folder/some/log/content'
;(joinPath as jest.Mock).mockResolvedValue('file://logs/test.log')
;(fs.existsSync as jest.Mock).mockResolvedValue(true)
;(fs.readFileSync as jest.Mock).mockResolvedValue(mockLogs)
const { result } = renderHook(() => useLogs())
await act(async () => {
const logs = await result.current.getLogs('test')
expect(logs).toBe(expectedSanitizedLogs)
})
expect(joinPath).toHaveBeenCalledWith(['file://logs', 'test.log'])
expect(fs.existsSync).toHaveBeenCalledWith('file://logs/test.log')
expect(fs.readFileSync).toHaveBeenCalledWith(
'file://logs/test.log',
'utf-8'
)
})
it('should return empty string if log file does not exist', async () => {
;(joinPath as jest.Mock).mockResolvedValue('file://logs/nonexistent.log')
;(fs.existsSync as jest.Mock).mockResolvedValue(false)
const { result } = renderHook(() => useLogs())
await act(async () => {
const logs = await result.current.getLogs('nonexistent')
expect(logs).toBe('')
})
expect(fs.readFileSync).not.toHaveBeenCalled()
})
it('should open server log', async () => {
;(joinPath as jest.Mock).mockResolvedValue(
'/mock/jan/data/folder/logs/app.log'
)
;(openFileExplorer as jest.Mock).mockResolvedValue(undefined)
const { result } = renderHook(() => useLogs())
await act(async () => {
await result.current.openServerLog()
})
expect(joinPath).toHaveBeenCalledWith([
'/mock/jan/data/folder',
'logs',
'app.log',
])
expect(openFileExplorer).toHaveBeenCalledWith(
'/mock/jan/data/folder/logs/app.log'
)
})
it('should clear server log', async () => {
;(joinPath as jest.Mock).mockResolvedValue('file://logs/app.log')
;(fs.writeFileSync as jest.Mock).mockResolvedValue(undefined)
const { result } = renderHook(() => useLogs())
await act(async () => {
await result.current.clearServerLog()
})
expect(joinPath).toHaveBeenCalledWith(['file://logs', 'app.log'])
expect(fs.writeFileSync).toHaveBeenCalledWith('file://logs/app.log', '')
})
})

View File

@ -0,0 +1,61 @@
// useModels.test.ts
import { renderHook, act } from '@testing-library/react'
import { events, ModelEvent } from '@janhq/core'
import { extensionManager } from '@/extension'
// Mock dependencies
jest.mock('@janhq/core')
jest.mock('@/extension')
import useModels from './useModels'
// Mock data
const mockDownloadedModels = [
{ id: 'model-1', name: 'Model 1' },
{ id: 'model-2', name: 'Model 2' },
]
const mockConfiguredModels = [
{ id: 'model-3', name: 'Model 3' },
{ id: 'model-4', name: 'Model 4' },
]
const mockDefaultModel = { id: 'default-model', name: 'Default Model' }
describe('useModels', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('should fetch and set models on mount', async () => {
const mockModelExtension = {
getDownloadedModels: jest.fn().mockResolvedValue(mockDownloadedModels),
getConfiguredModels: jest.fn().mockResolvedValue(mockConfiguredModels),
getDefaultModel: jest.fn().mockResolvedValue(mockDefaultModel),
} as any
jest.spyOn(extensionManager, 'get').mockReturnValue(mockModelExtension)
await act(async () => {
renderHook(() => useModels())
})
expect(mockModelExtension.getDownloadedModels).toHaveBeenCalled()
expect(mockModelExtension.getConfiguredModels).toHaveBeenCalled()
expect(mockModelExtension.getDefaultModel).toHaveBeenCalled()
})
it('should remove event listener on unmount', async () => {
const removeListenerSpy = jest.spyOn(events, 'off')
const { unmount } = renderHook(() => useModels())
unmount()
expect(removeListenerSpy).toHaveBeenCalledWith(
ModelEvent.OnModelsUpdate,
expect.any(Function)
)
})
})

View File

@ -18,6 +18,11 @@ import {
downloadedModelsAtom,
} from '@/helpers/atoms/Model.atom'
/**
* useModels hook - Handles the state of models
* It fetches the downloaded models, configured models and default model from Model Extension
* and updates the atoms accordingly.
*/
const useModels = () => {
const setDownloadedModels = useSetAtom(downloadedModelsAtom)
const setConfiguredModels = useSetAtom(configuredModelsAtom)
@ -39,6 +44,7 @@ const useModels = () => {
setDefaultModel(defaultModel)
}
// Fetch all data
Promise.all([
getDownloadedModels(),
getConfiguredModels(),
@ -59,16 +65,19 @@ const useModels = () => {
}, [getData])
}
// TODO: Deprecated - Remove when moving to cortex.cpp
const getLocalDefaultModel = async (): Promise<Model | undefined> =>
extensionManager
.get<ModelExtension>(ExtensionTypeEnum.Model)
?.getDefaultModel()
// TODO: Deprecated - Remove when moving to cortex.cpp
const getLocalConfiguredModels = async (): Promise<ModelFile[]> =>
extensionManager
.get<ModelExtension>(ExtensionTypeEnum.Model)
?.getConfiguredModels() ?? []
// TODO: Deprecated - Remove when moving to cortex.cpp
const getLocalDownloadedModels = async (): Promise<ModelFile[]> =>
extensionManager
.get<ModelExtension>(ExtensionTypeEnum.Model)

View File

@ -8,10 +8,10 @@ import {
setConvoMessagesAtom,
} from '@/helpers/atoms/ChatMessage.atom'
import {
ModelParams,
setActiveThreadIdAtom,
setThreadModelParamsAtom,
} from '@/helpers/atoms/Thread.atom'
import { ModelParams } from '@/types/model'
export default function useSetActiveThread() {
const setActiveThreadId = useSetAtom(setActiveThreadIdAtom)

192
web/hooks/useThread.test.ts Normal file
View File

@ -0,0 +1,192 @@
// useThreads.test.ts
import { renderHook, act } from '@testing-library/react'
import { useSetAtom } from 'jotai'
import { ExtensionTypeEnum } from '@janhq/core'
import { extensionManager } from '@/extension/ExtensionManager'
import useThreads from './useThreads'
import {
threadDataReadyAtom,
threadModelParamsAtom,
threadsAtom,
threadStatesAtom,
} from '@/helpers/atoms/Thread.atom'
// Mock the necessary dependencies
jest.mock('jotai', () => ({
useAtomValue: jest.fn(),
useSetAtom: jest.fn(),
useAtom: jest.fn(),
atom: jest.fn(),
}))
jest.mock('@/extension/ExtensionManager')
describe('useThreads', () => {
beforeEach(() => {
jest.clearAllMocks()
})
const mockThreads = [
{
id: 'thread1',
metadata: { lastMessage: 'Hello' },
assistants: [
{
model: {
parameters: { param1: 'value1' },
settings: { setting1: 'value1' },
},
},
],
},
{
id: 'thread2',
metadata: { lastMessage: 'Hi there' },
assistants: [
{
model: {
parameters: { param2: 'value2' },
settings: { setting2: 'value2' },
},
},
],
},
]
it('should fetch and set threads data', async () => {
// Mock Jotai hooks
const mockSetThreadStates = jest.fn()
const mockSetThreads = jest.fn()
const mockSetThreadModelRuntimeParams = jest.fn()
const mockSetThreadDataReady = jest.fn()
;(useSetAtom as jest.Mock).mockImplementation((atom) => {
switch (atom) {
case threadStatesAtom:
return mockSetThreadStates
case threadsAtom:
return mockSetThreads
case threadModelParamsAtom:
return mockSetThreadModelRuntimeParams
case threadDataReadyAtom:
return mockSetThreadDataReady
default:
return jest.fn()
}
})
// Mock extensionManager
const mockGetThreads = jest.fn().mockResolvedValue(mockThreads)
;(extensionManager.get as jest.Mock).mockReturnValue({
getThreads: mockGetThreads,
})
const { result } = renderHook(() => useThreads())
await act(async () => {
// Wait for useEffect to complete
})
// Assertions
expect(extensionManager.get).toHaveBeenCalledWith(
ExtensionTypeEnum.Conversational
)
expect(mockGetThreads).toHaveBeenCalled()
expect(mockSetThreadStates).toHaveBeenCalledWith({
thread1: {
hasMore: false,
waitingForResponse: false,
lastMessage: 'Hello',
},
thread2: {
hasMore: false,
waitingForResponse: false,
lastMessage: 'Hi there',
},
})
expect(mockSetThreads).toHaveBeenCalledWith(mockThreads)
expect(mockSetThreadModelRuntimeParams).toHaveBeenCalledWith({
thread1: { param1: 'value1', setting1: 'value1' },
thread2: { param2: 'value2', setting2: 'value2' },
})
expect(mockSetThreadDataReady).toHaveBeenCalledWith(true)
})
it('should handle empty threads', async () => {
// Mock empty threads
;(extensionManager.get as jest.Mock).mockReturnValue({
getThreads: jest.fn().mockResolvedValue([]),
})
const mockSetThreadStates = jest.fn()
const mockSetThreads = jest.fn()
const mockSetThreadModelRuntimeParams = jest.fn()
const mockSetThreadDataReady = jest.fn()
;(useSetAtom as jest.Mock).mockImplementation((atom) => {
switch (atom) {
case threadStatesAtom:
return mockSetThreadStates
case threadsAtom:
return mockSetThreads
case threadModelParamsAtom:
return mockSetThreadModelRuntimeParams
case threadDataReadyAtom:
return mockSetThreadDataReady
default:
return jest.fn()
}
})
const { result } = renderHook(() => useThreads())
await act(async () => {
// Wait for useEffect to complete
})
expect(mockSetThreadStates).toHaveBeenCalledWith({})
expect(mockSetThreads).toHaveBeenCalledWith([])
expect(mockSetThreadModelRuntimeParams).toHaveBeenCalledWith({})
expect(mockSetThreadDataReady).toHaveBeenCalledWith(true)
})
it('should handle missing ConversationalExtension', async () => {
// Mock missing ConversationalExtension
;(extensionManager.get as jest.Mock).mockReturnValue(null)
const mockSetThreadStates = jest.fn()
const mockSetThreads = jest.fn()
const mockSetThreadModelRuntimeParams = jest.fn()
const mockSetThreadDataReady = jest.fn()
;(useSetAtom as jest.Mock).mockImplementation((atom) => {
switch (atom) {
case threadStatesAtom:
return mockSetThreadStates
case threadsAtom:
return mockSetThreads
case threadModelParamsAtom:
return mockSetThreadModelRuntimeParams
case threadDataReadyAtom:
return mockSetThreadDataReady
default:
return jest.fn()
}
})
const { result } = renderHook(() => useThreads())
await act(async () => {
// Wait for useEffect to complete
})
expect(mockSetThreadStates).toHaveBeenCalledWith({})
expect(mockSetThreads).toHaveBeenCalledWith([])
expect(mockSetThreadModelRuntimeParams).toHaveBeenCalledWith({})
expect(mockSetThreadDataReady).toHaveBeenCalledWith(true)
})
})

View File

@ -11,12 +11,12 @@ import { useSetAtom } from 'jotai'
import { extensionManager } from '@/extension/ExtensionManager'
import {
ModelParams,
threadDataReadyAtom,
threadModelParamsAtom,
threadStatesAtom,
threadsAtom,
} from '@/helpers/atoms/Thread.atom'
import { ModelParams } from '@/types/model'
const useThreads = () => {
const setThreadStates = useSetAtom(threadStatesAtom)

View File

@ -18,10 +18,10 @@ import {
import { extensionManager } from '@/extension'
import { selectedModelAtom } from '@/helpers/atoms/Model.atom'
import {
ModelParams,
getActiveThreadModelParamsAtom,
setThreadModelParamsAtom,
} from '@/helpers/atoms/Thread.atom'
import { ModelParams } from '@/types/model'
export type UpdateModelParameter = {
params?: ModelParams

4
web/types/model.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
/**
* ModelParams types
*/
export type ModelParams = ModelRuntimeParams | ModelSettingParams

View File

@ -2,7 +2,7 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { ModelRuntimeParams, ModelSettingParams } from '@janhq/core'
import { ModelParams } from '@/helpers/atoms/Thread.atom'
import { ModelParams } from '@/types/model'
/**
* Validation rules for model parameters