diff --git a/extensions/conversational-extension/jest.config.js b/extensions/conversational-extension/jest.config.js new file mode 100644 index 000000000..8bb37208d --- /dev/null +++ b/extensions/conversational-extension/jest.config.js @@ -0,0 +1,5 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', +} diff --git a/extensions/conversational-extension/package.json b/extensions/conversational-extension/package.json index d062ce9c3..036fcfab2 100644 --- a/extensions/conversational-extension/package.json +++ b/extensions/conversational-extension/package.json @@ -7,6 +7,7 @@ "author": "Jan ", "license": "MIT", "scripts": { + "test": "jest", "build": "tsc -b . && webpack --config webpack.config.js", "build:publish": "rimraf *.tgz --glob && yarn build && npm pack && cpx *.tgz ../../pre-install" }, diff --git a/extensions/conversational-extension/src/Conversational.test.ts b/extensions/conversational-extension/src/Conversational.test.ts new file mode 100644 index 000000000..3d1d6fc60 --- /dev/null +++ b/extensions/conversational-extension/src/Conversational.test.ts @@ -0,0 +1,408 @@ +/** + * @jest-environment jsdom + */ +jest.mock('@janhq/core', () => ({ + ...jest.requireActual('@janhq/core/node'), + fs: { + existsSync: jest.fn(), + mkdir: jest.fn(), + writeFileSync: jest.fn(), + readdirSync: jest.fn(), + readFileSync: jest.fn(), + appendFileSync: jest.fn(), + rm: jest.fn(), + writeBlob: jest.fn(), + joinPath: jest.fn(), + fileStat: jest.fn(), + }, + joinPath: jest.fn(), + ConversationalExtension: jest.fn(), +})) + +import { fs } from '@janhq/core' + +import JSONConversationalExtension from '.' + +describe('JSONConversationalExtension Tests', () => { + let extension: JSONConversationalExtension + + beforeEach(() => { + // @ts-ignore + extension = new JSONConversationalExtension() + }) + + it('should create thread folder on load if it does not exist', async () => { + // @ts-ignore + jest.spyOn(fs, 'existsSync').mockResolvedValue(false) + const mkdirSpy = jest.spyOn(fs, 'mkdir').mockResolvedValue({}) + + await extension.onLoad() + + expect(mkdirSpy).toHaveBeenCalledWith('file://threads') + }) + + it('should log message on unload', () => { + const consoleSpy = jest.spyOn(console, 'debug').mockImplementation() + + extension.onUnload() + + expect(consoleSpy).toHaveBeenCalledWith( + 'JSONConversationalExtension unloaded' + ) + }) + + it('should return sorted threads', async () => { + jest + .spyOn(extension, 'getValidThreadDirs') + .mockResolvedValue(['dir1', 'dir2']) + jest + .spyOn(extension, 'readThread') + .mockResolvedValueOnce({ updated: '2023-01-01' }) + .mockResolvedValueOnce({ updated: '2023-01-02' }) + + const threads = await extension.getThreads() + + expect(threads).toEqual([ + { updated: '2023-01-02' }, + { updated: '2023-01-01' }, + ]) + }) + + it('should ignore broken threads', async () => { + jest + .spyOn(extension, 'getValidThreadDirs') + .mockResolvedValue(['dir1', 'dir2']) + jest + .spyOn(extension, 'readThread') + .mockResolvedValueOnce(JSON.stringify({ updated: '2023-01-01' })) + .mockResolvedValueOnce('this_is_an_invalid_json_content') + + const threads = await extension.getThreads() + + expect(threads).toEqual([{ updated: '2023-01-01' }]) + }) + + it('should save thread', async () => { + // @ts-ignore + jest.spyOn(fs, 'existsSync').mockResolvedValue(false) + const mkdirSpy = jest.spyOn(fs, 'mkdir').mockResolvedValue({}) + const writeFileSyncSpy = jest + .spyOn(fs, 'writeFileSync') + .mockResolvedValue({}) + + const thread = { id: '1', updated: '2023-01-01' } as any + await extension.saveThread(thread) + + expect(mkdirSpy).toHaveBeenCalled() + expect(writeFileSyncSpy).toHaveBeenCalled() + }) + + it('should delete thread', async () => { + const rmSpy = jest.spyOn(fs, 'rm').mockResolvedValue({}) + + await extension.deleteThread('1') + + expect(rmSpy).toHaveBeenCalled() + }) + + it('should add new message', async () => { + // @ts-ignore + jest.spyOn(fs, 'existsSync').mockResolvedValue(false) + const mkdirSpy = jest.spyOn(fs, 'mkdir').mockResolvedValue({}) + const appendFileSyncSpy = jest + .spyOn(fs, 'appendFileSync') + .mockResolvedValue({}) + + const message = { + thread_id: '1', + content: [{ type: 'text', text: { annotations: [] } }], + } as any + await extension.addNewMessage(message) + + expect(mkdirSpy).toHaveBeenCalled() + expect(appendFileSyncSpy).toHaveBeenCalled() + }) + + it('should store image', async () => { + const writeBlobSpy = jest.spyOn(fs, 'writeBlob').mockResolvedValue({}) + + await extension.storeImage( + 'data:image/png;base64,abcd', + 'path/to/image.png' + ) + + expect(writeBlobSpy).toHaveBeenCalled() + }) + + it('should store file', async () => { + const writeBlobSpy = jest.spyOn(fs, 'writeBlob').mockResolvedValue({}) + + await extension.storeFile( + 'data:application/pdf;base64,abcd', + 'path/to/file.pdf' + ) + + expect(writeBlobSpy).toHaveBeenCalled() + }) + + it('should write messages', async () => { + // @ts-ignore + jest.spyOn(fs, 'existsSync').mockResolvedValue(false) + const mkdirSpy = jest.spyOn(fs, 'mkdir').mockResolvedValue({}) + const writeFileSyncSpy = jest + .spyOn(fs, 'writeFileSync') + .mockResolvedValue({}) + + const messages = [{ id: '1', thread_id: '1', content: [] }] as any + await extension.writeMessages('1', messages) + + expect(mkdirSpy).toHaveBeenCalled() + expect(writeFileSyncSpy).toHaveBeenCalled() + }) + + it('should get all messages on string response', async () => { + jest.spyOn(fs, 'readdirSync').mockResolvedValue(['messages.jsonl']) + jest.spyOn(fs, 'readFileSync').mockResolvedValue('{"id":"1"}\n{"id":"2"}\n') + + const messages = await extension.getAllMessages('1') + + expect(messages).toEqual([{ id: '1' }, { id: '2' }]) + }) + + it('should get all messages on object response', async () => { + jest.spyOn(fs, 'readdirSync').mockResolvedValue(['messages.jsonl']) + jest.spyOn(fs, 'readFileSync').mockResolvedValue({ id: 1 }) + + const messages = await extension.getAllMessages('1') + + expect(messages).toEqual([{ id: 1 }]) + }) + + it('get all messages return empty on error', async () => { + jest.spyOn(fs, 'readdirSync').mockRejectedValue(['messages.jsonl']) + + const messages = await extension.getAllMessages('1') + + expect(messages).toEqual([]) + }) + + it('return empty messages on no messages file', async () => { + jest.spyOn(fs, 'readdirSync').mockResolvedValue([]) + + const messages = await extension.getAllMessages('1') + + expect(messages).toEqual([]) + }) + + it('should ignore error message', async () => { + jest.spyOn(fs, 'readdirSync').mockResolvedValue(['messages.jsonl']) + jest + .spyOn(fs, 'readFileSync') + .mockResolvedValue('{"id":"1"}\nyolo\n{"id":"2"}\n') + + const messages = await extension.getAllMessages('1') + + expect(messages).toEqual([{ id: '1' }, { id: '2' }]) + }) + + it('should create thread folder on load if it does not exist', async () => { + // @ts-ignore + jest.spyOn(fs, 'existsSync').mockResolvedValue(false) + const mkdirSpy = jest.spyOn(fs, 'mkdir').mockResolvedValue({}) + + await extension.onLoad() + + expect(mkdirSpy).toHaveBeenCalledWith('file://threads') + }) + + it('should log message on unload', () => { + const consoleSpy = jest.spyOn(console, 'debug').mockImplementation() + + extension.onUnload() + + expect(consoleSpy).toHaveBeenCalledWith( + 'JSONConversationalExtension unloaded' + ) + }) + + it('should return sorted threads', async () => { + jest + .spyOn(extension, 'getValidThreadDirs') + .mockResolvedValue(['dir1', 'dir2']) + jest + .spyOn(extension, 'readThread') + .mockResolvedValueOnce({ updated: '2023-01-01' }) + .mockResolvedValueOnce({ updated: '2023-01-02' }) + + const threads = await extension.getThreads() + + expect(threads).toEqual([ + { updated: '2023-01-02' }, + { updated: '2023-01-01' }, + ]) + }) + + it('should ignore broken threads', async () => { + jest + .spyOn(extension, 'getValidThreadDirs') + .mockResolvedValue(['dir1', 'dir2']) + jest + .spyOn(extension, 'readThread') + .mockResolvedValueOnce(JSON.stringify({ updated: '2023-01-01' })) + .mockResolvedValueOnce('this_is_an_invalid_json_content') + + const threads = await extension.getThreads() + + expect(threads).toEqual([{ updated: '2023-01-01' }]) + }) + + it('should save thread', async () => { + // @ts-ignore + jest.spyOn(fs, 'existsSync').mockResolvedValue(false) + const mkdirSpy = jest.spyOn(fs, 'mkdir').mockResolvedValue({}) + const writeFileSyncSpy = jest + .spyOn(fs, 'writeFileSync') + .mockResolvedValue({}) + + const thread = { id: '1', updated: '2023-01-01' } as any + await extension.saveThread(thread) + + expect(mkdirSpy).toHaveBeenCalled() + expect(writeFileSyncSpy).toHaveBeenCalled() + }) + + it('should delete thread', async () => { + const rmSpy = jest.spyOn(fs, 'rm').mockResolvedValue({}) + + await extension.deleteThread('1') + + expect(rmSpy).toHaveBeenCalled() + }) + + it('should add new message', async () => { + // @ts-ignore + jest.spyOn(fs, 'existsSync').mockResolvedValue(false) + const mkdirSpy = jest.spyOn(fs, 'mkdir').mockResolvedValue({}) + const appendFileSyncSpy = jest + .spyOn(fs, 'appendFileSync') + .mockResolvedValue({}) + + const message = { + thread_id: '1', + content: [{ type: 'text', text: { annotations: [] } }], + } as any + await extension.addNewMessage(message) + + expect(mkdirSpy).toHaveBeenCalled() + expect(appendFileSyncSpy).toHaveBeenCalled() + }) + + it('should add new image message', async () => { + jest + .spyOn(fs, 'existsSync') + // @ts-ignore + .mockResolvedValueOnce(false) + // @ts-ignore + .mockResolvedValueOnce(false) + // @ts-ignore + .mockResolvedValueOnce(true) + const mkdirSpy = jest.spyOn(fs, 'mkdir').mockResolvedValue({}) + const appendFileSyncSpy = jest + .spyOn(fs, 'appendFileSync') + .mockResolvedValue({}) + jest.spyOn(fs, 'writeBlob').mockResolvedValue({}) + + const message = { + thread_id: '1', + content: [ + { type: 'image', text: { annotations: ['data:image;base64,hehe'] } }, + ], + } as any + await extension.addNewMessage(message) + + expect(mkdirSpy).toHaveBeenCalled() + expect(appendFileSyncSpy).toHaveBeenCalled() + }) + + it('should add new pdf message', async () => { + jest + .spyOn(fs, 'existsSync') + // @ts-ignore + .mockResolvedValueOnce(false) + // @ts-ignore + .mockResolvedValueOnce(false) + // @ts-ignore + .mockResolvedValueOnce(true) + const mkdirSpy = jest.spyOn(fs, 'mkdir').mockResolvedValue({}) + const appendFileSyncSpy = jest + .spyOn(fs, 'appendFileSync') + .mockResolvedValue({}) + jest.spyOn(fs, 'writeBlob').mockResolvedValue({}) + + const message = { + thread_id: '1', + content: [ + { type: 'pdf', text: { annotations: ['data:pdf;base64,hehe'] } }, + ], + } as any + await extension.addNewMessage(message) + + expect(mkdirSpy).toHaveBeenCalled() + expect(appendFileSyncSpy).toHaveBeenCalled() + }) + + it('should store image', async () => { + const writeBlobSpy = jest.spyOn(fs, 'writeBlob').mockResolvedValue({}) + + await extension.storeImage( + 'data:image/png;base64,abcd', + 'path/to/image.png' + ) + + expect(writeBlobSpy).toHaveBeenCalled() + }) + + it('should store file', async () => { + const writeBlobSpy = jest.spyOn(fs, 'writeBlob').mockResolvedValue({}) + + await extension.storeFile( + 'data:application/pdf;base64,abcd', + 'path/to/file.pdf' + ) + + expect(writeBlobSpy).toHaveBeenCalled() + }) +}) + +describe('test readThread', () => { + let extension: JSONConversationalExtension + + beforeEach(() => { + // @ts-ignore + extension = new JSONConversationalExtension() + }) + + it('should read thread', async () => { + jest + .spyOn(fs, 'readFileSync') + .mockResolvedValue(JSON.stringify({ id: '1' })) + const thread = await extension.readThread('1') + expect(thread).toEqual(`{"id":"1"}`) + }) + + it('getValidThreadDirs should return valid thread directories', async () => { + jest + .spyOn(fs, 'readdirSync') + .mockResolvedValueOnce(['1', '2', '3']) + .mockResolvedValueOnce(['thread.json']) + .mockResolvedValueOnce(['thread.json']) + .mockResolvedValueOnce([]) + // @ts-ignore + jest.spyOn(fs, 'existsSync').mockResolvedValue(true) + jest.spyOn(fs, 'fileStat').mockResolvedValue({ + isDirectory: true, + } as any) + const validThreadDirs = await extension.getValidThreadDirs() + expect(validThreadDirs).toEqual(['1', '2']) + }) +}) diff --git a/extensions/conversational-extension/src/index.ts b/extensions/conversational-extension/src/index.ts index 1bca75347..b34f09181 100644 --- a/extensions/conversational-extension/src/index.ts +++ b/extensions/conversational-extension/src/index.ts @@ -5,6 +5,7 @@ import { Thread, ThreadMessage, } from '@janhq/core' +import { safelyParseJSON } from './jsonUtil' /** * JSONConversationalExtension is a ConversationalExtension implementation that provides @@ -45,10 +46,11 @@ export default class JSONConversationalExtension extends ConversationalExtension if (result.status === 'fulfilled') { return typeof result.value === 'object' ? result.value - : JSON.parse(result.value) + : safelyParseJSON(result.value) } + return undefined }) - .filter((convo) => convo != null) + .filter((convo) => !!convo) convos.sort( (a, b) => new Date(b.updated).getTime() - new Date(a.updated).getTime() ) @@ -195,7 +197,7 @@ export default class JSONConversationalExtension extends ConversationalExtension * @param threadDirName the thread dir we are reading from. * @returns data of the thread */ - private async readThread(threadDirName: string): Promise { + async readThread(threadDirName: string): Promise { return fs.readFileSync( await joinPath([ JSONConversationalExtension._threadFolder, @@ -210,7 +212,7 @@ export default class JSONConversationalExtension extends ConversationalExtension * Returns a Promise that resolves to an array of thread directories. * @private */ - private async getValidThreadDirs(): Promise { + async getValidThreadDirs(): Promise { const fileInsideThread: string[] = await fs.readdirSync( JSONConversationalExtension._threadFolder ) @@ -266,7 +268,8 @@ export default class JSONConversationalExtension extends ConversationalExtension const messages: ThreadMessage[] = [] result.forEach((line: string) => { - messages.push(JSON.parse(line)) + const message = safelyParseJSON(line) + if (message) messages.push(safelyParseJSON(line)) }) return messages } catch (err) { diff --git a/extensions/conversational-extension/src/jsonUtil.ts b/extensions/conversational-extension/src/jsonUtil.ts new file mode 100644 index 000000000..7f83cadce --- /dev/null +++ b/extensions/conversational-extension/src/jsonUtil.ts @@ -0,0 +1,14 @@ +// Note about performance +// The v8 JavaScript engine used by Node.js cannot optimise functions which contain a try/catch block. +// v8 4.5 and above can optimise try/catch +export function safelyParseJSON(json) { + // This function cannot be optimised, it's best to + // keep it small! + var parsed + try { + parsed = JSON.parse(json) + } catch (e) { + return undefined + } + return parsed // Could be undefined! +} diff --git a/extensions/conversational-extension/tsconfig.json b/extensions/conversational-extension/tsconfig.json index 2477d58ce..8427123e7 100644 --- a/extensions/conversational-extension/tsconfig.json +++ b/extensions/conversational-extension/tsconfig.json @@ -10,5 +10,6 @@ "skipLibCheck": true, "rootDir": "./src" }, - "include": ["./src"] + "include": ["./src"], + "exclude": ["src/**/*.test.ts"] } diff --git a/extensions/model-extension/src/helpers/path.test.ts b/extensions/model-extension/src/helpers/path.test.ts new file mode 100644 index 000000000..64ca65d8a --- /dev/null +++ b/extensions/model-extension/src/helpers/path.test.ts @@ -0,0 +1,87 @@ +import { extractFileName } from './path'; + +describe('extractFileName Function', () => { + it('should correctly extract the file name with the provided file extension', () => { + const url = 'http://example.com/some/path/to/file.ext'; + const fileExtension = '.ext'; + const fileName = extractFileName(url, fileExtension); + expect(fileName).toBe('file.ext'); + }); + + it('should correctly append the file extension if it does not already exist in the file name', () => { + const url = 'http://example.com/some/path/to/file'; + const fileExtension = '.txt'; + const fileName = extractFileName(url, fileExtension); + expect(fileName).toBe('file.txt'); + }); + + it('should handle cases where the URL does not have a file extension correctly', () => { + const url = 'http://example.com/some/path/to/file'; + const fileExtension = '.jpg'; + const fileName = extractFileName(url, fileExtension); + expect(fileName).toBe('file.jpg'); + }); + + it('should correctly handle URLs without a trailing slash', () => { + const url = 'http://example.com/some/path/tofile'; + const fileExtension = '.txt'; + const fileName = extractFileName(url, fileExtension); + expect(fileName).toBe('tofile.txt'); + }); + + it('should correctly handle URLs with multiple file extensions', () => { + const url = 'http://example.com/some/path/tofile.tar.gz'; + const fileExtension = '.gz'; + const fileName = extractFileName(url, fileExtension); + expect(fileName).toBe('tofile.tar.gz'); + }); + + it('should correctly handle URLs with special characters', () => { + const url = 'http://example.com/some/path/tófílë.extë'; + const fileExtension = '.extë'; + const fileName = extractFileName(url, fileExtension); + expect(fileName).toBe('tófílë.extë'); + }); + + it('should correctly handle URLs that are just a file with no path', () => { + const url = 'http://example.com/file.txt'; + const fileExtension = '.txt'; + const fileName = extractFileName(url, fileExtension); + expect(fileName).toBe('file.txt'); + }); + + it('should correctly handle URLs that have special query parameters', () => { + const url = 'http://example.com/some/path/tofile.ext?query=1'; + const fileExtension = '.ext'; + const fileName = extractFileName(url.split('?')[0], fileExtension); + expect(fileName).toBe('tofile.ext'); + }); + + it('should correctly handle URLs that have uppercase characters', () => { + const url = 'http://EXAMPLE.COM/PATH/TO/FILE.EXT'; + const fileExtension = '.ext'; + const fileName = extractFileName(url, fileExtension); + expect(fileName).toBe('FILE.EXT'); + }); + + it('should correctly handle invalid URLs', () => { + const url = 'invalid-url'; + const fileExtension = '.txt'; + const fileName = extractFileName(url, fileExtension); + expect(fileName).toBe('invalid-url.txt'); + }); + + it('should correctly handle empty URLs', () => { + const url = ''; + const fileExtension = '.txt'; + const fileName = extractFileName(url, fileExtension); + expect(fileName).toBe('.txt'); + }); + + it('should correctly handle undefined URLs', () => { + const url = undefined; + const fileExtension = '.txt'; + const fileName = extractFileName(url as any, fileExtension); + expect(fileName).toBe('.txt'); + }); +}); diff --git a/extensions/model-extension/src/helpers/path.ts b/extensions/model-extension/src/helpers/path.ts index cbb151aa6..6091005b8 100644 --- a/extensions/model-extension/src/helpers/path.ts +++ b/extensions/model-extension/src/helpers/path.ts @@ -3,6 +3,8 @@ */ export function extractFileName(url: string, fileExtension: string): string { + if(!url) return fileExtension + const extractedFileName = url.split('/').pop() const fileName = extractedFileName.toLowerCase().endsWith(fileExtension) ? extractedFileName diff --git a/jest.config.js b/jest.config.js index a911a7f0a..a9f0f5938 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,3 +1,10 @@ module.exports = { - projects: ['/core', '/web', '/joi'], + projects: [ + '/core', + '/web', + '/joi', + '/extensions/inference-nitro-extension', + '/extensions/conversational-extension', + '/extensions/model-extension', + ], } diff --git a/web/containers/ErrorMessage/index.test.tsx b/web/containers/ErrorMessage/index.test.tsx new file mode 100644 index 000000000..99dad5415 --- /dev/null +++ b/web/containers/ErrorMessage/index.test.tsx @@ -0,0 +1,107 @@ +// ErrorMessage.test.tsx +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import ErrorMessage from './index'; +import { ThreadMessage, MessageStatus, ErrorCode } from '@janhq/core'; +import { useAtomValue, useSetAtom } from 'jotai'; +import useSendChatMessage from '@/hooks/useSendChatMessage'; + +// Mock the dependencies +jest.mock('jotai', () => { + const originalModule = jest.requireActual('jotai') + return { + ...originalModule, + useAtomValue: jest.fn(), + useSetAtom: jest.fn(), + } + }) + +jest.mock('@/hooks/useSendChatMessage', () => ({ + __esModule: true, + default: jest.fn(), +})); + +describe('ErrorMessage Component', () => { + const mockSetMainState = jest.fn(); + const mockSetSelectedSettingScreen = jest.fn(); + const mockSetModalTroubleShooting = jest.fn(); + const mockResendChatMessage = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (useAtomValue as jest.Mock).mockReturnValue([]); + (useSetAtom as jest.Mock).mockReturnValue(mockSetMainState); + (useSetAtom as jest.Mock).mockReturnValue(mockSetSelectedSettingScreen); + (useSetAtom as jest.Mock).mockReturnValue(mockSetModalTroubleShooting); + (useSendChatMessage as jest.Mock).mockReturnValue({ resendChatMessage: mockResendChatMessage }); + }); + + it('renders stopped message correctly', () => { + const message: ThreadMessage = { + id: '1', + status: MessageStatus.Stopped, + content: [{ text: { value: 'Test message' } }], + } as ThreadMessage; + + render(); + + expect(screen.getByText("Oops! The generation was interrupted. Let's give it another go!")).toBeInTheDocument(); + expect(screen.getByText('Regenerate')).toBeInTheDocument(); + }); + + it('renders error message with InvalidApiKey correctly', () => { + const message: ThreadMessage = { + id: '1', + status: MessageStatus.Error, + error_code: ErrorCode.InvalidApiKey, + content: [{ text: { value: 'Invalid API Key' } }], + } as ThreadMessage; + + render(); + + expect(screen.getByTestId('invalid-API-key-error')).toBeInTheDocument(); + expect(screen.getByText('Settings')).toBeInTheDocument(); + }); + + it('renders general error message correctly', () => { + const message: ThreadMessage = { + id: '1', + status: MessageStatus.Error, + error_code: ErrorCode.Unknown, + content: [{ text: { value: 'Unknown error occurred' } }], + } as ThreadMessage; + + render(); + + expect(screen.getByText("Apologies, something’s amiss!")).toBeInTheDocument(); + expect(screen.getByText('troubleshooting assistance')).toBeInTheDocument(); + }); + + it('calls regenerateMessage when Regenerate button is clicked', () => { + const message: ThreadMessage = { + id: '1', + status: MessageStatus.Stopped, + content: [{ text: { value: 'Test message' } }], + } as ThreadMessage; + + render(); + + fireEvent.click(screen.getByText('Regenerate')); + expect(mockResendChatMessage).toHaveBeenCalled(); + }); + + it('opens troubleshooting modal when link is clicked', () => { + const message: ThreadMessage = { + id: '1', + status: MessageStatus.Error, + error_code: ErrorCode.Unknown, + content: [{ text: { value: 'Unknown error occurred' } }], + } as ThreadMessage; + + render(); + + fireEvent.click(screen.getByText('troubleshooting assistance')); + expect(mockSetModalTroubleShooting).toHaveBeenCalledWith(true); + }); +}); diff --git a/web/containers/ListContainer/index.test.tsx b/web/containers/ListContainer/index.test.tsx new file mode 100644 index 000000000..866d8ff4e --- /dev/null +++ b/web/containers/ListContainer/index.test.tsx @@ -0,0 +1,69 @@ +// ListContainer.test.tsx +import React from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import '@testing-library/jest-dom' +import ListContainer from './index' + +class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} +} + +global.ResizeObserver = ResizeObserverMock + +describe('ListContainer', () => { + const scrollToMock = jest.fn() + Element.prototype.scrollTo = scrollToMock + + it('renders children correctly', () => { + render( + +
Test Child
+
+ ) + expect(screen.getByTestId('child')).toBeInTheDocument() + }) + + it('scrolls to bottom on initial render', () => { + + render( + +
Long content
+
+ ) + + expect(scrollToMock).toHaveBeenCalledWith({ + top: expect.any(Number), + behavior: 'instant', + }) + }) + + it('sets isUserManuallyScrollingUp when scrolling up', () => { + const { container } = render( + +
Long content
+
+ ) + + const scrollArea = container.firstChild as HTMLElement + + // Simulate scrolling down + fireEvent.scroll(scrollArea, { target: { scrollTop: 500 } }) + + // Simulate scrolling up + fireEvent.scroll(scrollArea, { target: { scrollTop: 300 } }) + + // We can't directly test the internal state, but we can check that + // subsequent scroll to bottom doesn't happen (as it would if isUserManuallyScrollingUp was false) + + // Trigger a re-render + render( + +
Long content
+
+ ) + + expect(scrollToMock).toHaveBeenCalled() + }) +}) diff --git a/web/containers/ListContainer/index.tsx b/web/containers/ListContainer/index.tsx index a48db5313..bd650e315 100644 --- a/web/containers/ListContainer/index.tsx +++ b/web/containers/ListContainer/index.tsx @@ -29,7 +29,7 @@ const ListContainer = ({ children }: Props) => { }, []) useEffect(() => { - if (isUserManuallyScrollingUp.current === true) return + if (isUserManuallyScrollingUp.current === true || !listRef.current) return const scrollHeight = listRef.current?.scrollHeight ?? 0 listRef.current?.scrollTo({ top: scrollHeight, diff --git a/web/containers/Loader/GenerateResponse.test.tsx b/web/containers/Loader/GenerateResponse.test.tsx new file mode 100644 index 000000000..7e3e5c3a4 --- /dev/null +++ b/web/containers/Loader/GenerateResponse.test.tsx @@ -0,0 +1,75 @@ +// GenerateResponse.test.tsx +import React from 'react'; +import { render, screen, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import GenerateResponse from './GenerateResponse'; + +jest.useFakeTimers(); + +describe('GenerateResponse Component', () => { + it('renders initially with 1% loader width', () => { + render(); + const loader = screen.getByTestId('response-loader'); + expect(loader).toHaveStyle('width: 24%'); + }); + + it('updates loader width over time', () => { + render(); + const loader = screen.getByTestId('response-loader'); + + // Advance timers to simulate time passing + act(() => { + jest.advanceTimersByTime(1000); + }); + + expect(loader).not.toHaveStyle('width: 1%'); + expect(parseFloat(loader.style.width)).toBeGreaterThan(1); + }); + + it('pauses at specific percentages', () => { + render(); + const loader = screen.getByTestId('response-loader'); + + // Advance to 24% + act(() => { + for (let i = 0; i < 24; i++) { + jest.advanceTimersByTime(50); + } + }); + + expect(loader).toHaveStyle('width: 50%'); + + // Advance past the pause + act(() => { + jest.advanceTimersByTime(300); + }); + + expect(loader).toHaveStyle('width: 78%'); + }); + + it('stops at 85%', () => { + render(); + const loader = screen.getByTestId('response-loader'); + + // Advance to 50% + act(() => { + for (let i = 0; i < 85; i++) { + jest.advanceTimersByTime(50); + } + }); + + expect(loader).toHaveStyle('width: 50%'); + + // Check if it stays at 78% + act(() => { + jest.advanceTimersByTime(1000); + }); + + expect(loader).toHaveStyle('width: 78%'); + }); + + it('displays the correct text', () => { + render(); + expect(screen.getByText('Generating response...')).toBeInTheDocument(); + }); +}); diff --git a/web/containers/Loader/ModelReload.test.tsx b/web/containers/Loader/ModelReload.test.tsx new file mode 100644 index 000000000..2de2db4fd --- /dev/null +++ b/web/containers/Loader/ModelReload.test.tsx @@ -0,0 +1,124 @@ +// ModelReload.test.tsx +import React from 'react' +import '@testing-library/jest-dom' +import { render, screen, act } from '@testing-library/react' +import ModelReload from './ModelReload' +import { useActiveModel } from '@/hooks/useActiveModel' + +jest.mock('@/hooks/useActiveModel') + +describe('ModelReload Component', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('renders nothing when not loading', () => { + ;(useActiveModel as jest.Mock).mockReturnValue({ + stateModel: { loading: false }, + }) + + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + it('renders loading message when loading', () => { + ;(useActiveModel as jest.Mock).mockReturnValue({ + stateModel: { loading: true, model: { id: 'test-model' } }, + }) + + render() + expect(screen.getByText(/Reloading model test-model/)).toBeInTheDocument() + }) + + it('updates loader percentage over time', () => { + ;(useActiveModel as jest.Mock).mockReturnValue({ + stateModel: { loading: true, model: { id: 'test-model' } }, + }) + + render() + + // Initial render + expect(screen.getByText(/Reloading model test-model/)).toBeInTheDocument() + const loaderElement = screen.getByText( + /Reloading model test-model/ + ).parentElement + + // Check initial width + expect(loaderElement?.firstChild).toHaveStyle('width: 50%') + + // Advance timers and check updated width + act(() => { + jest.advanceTimersByTime(250) + }) + expect(loaderElement?.firstChild).toHaveStyle('width: 78%') + + // Advance to 99% + for (let i = 0; i < 27; i++) { + act(() => { + jest.advanceTimersByTime(250) + }) + } + expect(loaderElement?.firstChild).toHaveStyle('width: 99%') + + // Advance one more time to hit the 250ms delay + act(() => { + jest.advanceTimersByTime(250) + }) + expect(loaderElement?.firstChild).toHaveStyle('width: 99%') + }) + + it('stops at 99%', () => { + ;(useActiveModel as jest.Mock).mockReturnValue({ + stateModel: { loading: true, model: { id: 'test-model' } }, + }) + + render() + + const loaderElement = screen.getByText( + /Reloading model test-model/ + ).parentElement + + // Advance to 99% + for (let i = 0; i < 50; i++) { + act(() => { + jest.advanceTimersByTime(250) + }) + } + expect(loaderElement?.firstChild).toHaveStyle('width: 99%') + + // Advance more and check it stays at 99% + act(() => { + jest.advanceTimersByTime(1000) + }) + expect(loaderElement?.firstChild).toHaveStyle('width: 99%') + }) + + it('resets to 0% when loading completes', () => { + const { rerender } = render() + ;(useActiveModel as jest.Mock).mockReturnValue({ + stateModel: { loading: true, model: { id: 'test-model' } }, + }) + + rerender() + + const loaderElement = screen.getByText( + /Reloading model test-model/ + ).parentElement + + expect(loaderElement?.firstChild).toHaveStyle('width: 50%') + // Set loading to false + ;(useActiveModel as jest.Mock).mockReturnValue({ + stateModel: { loading: false }, + }) + + rerender() + + expect( + screen.queryByText(/Reloading model test-model/) + ).not.toBeInTheDocument() + }) +}) diff --git a/web/containers/Loader/ProgressCircle.test.tsx b/web/containers/Loader/ProgressCircle.test.tsx new file mode 100644 index 000000000..651f9a4f2 --- /dev/null +++ b/web/containers/Loader/ProgressCircle.test.tsx @@ -0,0 +1,22 @@ +// ProgressCircle.test.tsx +import React from 'react' +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import ProgressCircle from './ProgressCircle' + +describe('ProgressCircle Component', () => { + test('renders ProgressCircle with default props', () => { + render() + const svg = screen.getByRole('img', { hidden: true }) + expect(svg).toBeInTheDocument() + expect(svg).toHaveAttribute('width', '100') + expect(svg).toHaveAttribute('height', '100') + }) + + test('renders ProgressCircle with custom size', () => { + render() + const svg = screen.getByRole('img', { hidden: true }) + expect(svg).toHaveAttribute('width', '200') + expect(svg).toHaveAttribute('height', '200') + }) +}) diff --git a/web/containers/Loader/ProgressCircle.tsx b/web/containers/Loader/ProgressCircle.tsx index e10434113..aec7c81cc 100644 --- a/web/containers/Loader/ProgressCircle.tsx +++ b/web/containers/Loader/ProgressCircle.tsx @@ -22,6 +22,7 @@ const ProgressCircle: React.FC = ({ width={size} xmlns="http://www.w3.org/2000/svg" viewBox={`0 0 ${size} ${size}`} + role="img" > { + const mockLogs = ['Log 1', 'Log 2', 'Log 3'] + + beforeEach(() => { + // Reset all mocks + jest.resetAllMocks() + + // Setup default mock implementations + ;(useLogs as jest.Mock).mockReturnValue({ + getLogs: jest.fn().mockResolvedValue(mockLogs.join('\n')), + }) + ;(usePath as jest.Mock).mockReturnValue({ + onRevealInFinder: jest.fn(), + }) + ;(useClipboard as jest.Mock).mockReturnValue({ + copy: jest.fn(), + copied: false, + }) + }) + + test('renders AppLogs component with logs', async () => { + render() + + await waitFor(() => { + mockLogs.forEach((log) => { + expect(screen.getByText(log)).toBeInTheDocument() + }) + }) + + expect(screen.getByText('Open')).toBeInTheDocument() + expect(screen.getByText('Copy All')).toBeInTheDocument() + }) + + test('renders empty state when no logs', async () => { + ;(useLogs as jest.Mock).mockReturnValue({ + getLogs: jest.fn().mockResolvedValue(''), + }) + + render() + + await waitFor(() => { + expect(screen.getByText('Empty logs')).toBeInTheDocument() + }) + }) + + test('calls onRevealInFinder when Open button is clicked', async () => { + const mockOnRevealInFinder = jest.fn() + ;(usePath as jest.Mock).mockReturnValue({ + onRevealInFinder: mockOnRevealInFinder, + }) + + render() + + await waitFor(() => { + const openButton = screen.getByText('Open') + userEvent.click(openButton) + + expect(mockOnRevealInFinder).toHaveBeenCalledWith('Logs') + }) + }) + + test('calls copy function when Copy All button is clicked', async () => { + const mockCopy = jest.fn() + ;(useClipboard as jest.Mock).mockReturnValue({ + copy: mockCopy, + copied: false, + }) + + render() + + await waitFor(() => { + const copyButton = screen.getByText('Copy All') + userEvent.click(copyButton) + expect(mockCopy).toHaveBeenCalled() + }) + }) + + test('shows Copying... text when copied is true', async () => { + ;(useClipboard as jest.Mock).mockReturnValue({ + copy: jest.fn(), + copied: true, + }) + + render() + + await waitFor(() => { + expect(screen.getByText('Copying...')).toBeInTheDocument() + }) + }) +}) diff --git a/web/screens/Thread/ThreadCenterPanel/AssistantSetting/index.test.tsx b/web/screens/Thread/ThreadCenterPanel/AssistantSetting/index.test.tsx new file mode 100644 index 000000000..96ff6f559 --- /dev/null +++ b/web/screens/Thread/ThreadCenterPanel/AssistantSetting/index.test.tsx @@ -0,0 +1,137 @@ +// ./AssistantSetting.test.tsx +import '@testing-library/jest-dom' +import React from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useAtomValue, useSetAtom } from 'jotai' +import { useActiveModel } from '@/hooks/useActiveModel' +import { useCreateNewThread } from '@/hooks/useCreateNewThread' +import AssistantSetting from './index' + +jest.mock('jotai', () => { + const originalModule = jest.requireActual('jotai') + return { + ...originalModule, + useAtomValue: jest.fn(), + useSetAtom: jest.fn(), + } +}) +jest.mock('@/hooks/useActiveModel') +jest.mock('@/hooks/useCreateNewThread') +jest.mock('./../../../../containers/ModelSetting/SettingComponent', () => { + return jest.fn().mockImplementation(({ onValueUpdated }) => { + return ( + onValueUpdated('chunk_size', e.target.value)} + /> + ) + }) +}) + +describe('AssistantSetting Component', () => { + const mockActiveThread = { + id: '123', + assistants: [ + { + id: '456', + tools: [ + { + type: 'retrieval', + enabled: true, + settings: { + chunk_size: 100, + chunk_overlap: 50, + }, + }, + ], + }, + ], + } + const ComponentPropsMock: any[] = [ + { + key: 'chunk_size', + type: 'number', + title: 'Chunk Size', + value: 100, + controllerType: 'input', + }, + { + key: 'chunk_overlap', + type: 'number', + title: 'Chunk Overlap', + value: 50, + controllerType: 'input', + }, + ] + + beforeEach(() => { + jest.clearAllMocks() + }) + + test('renders AssistantSetting component with proper data', async () => { + const setEngineParamsUpdate = jest.fn() + ;(useSetAtom as jest.Mock).mockImplementationOnce( + () => setEngineParamsUpdate + ) + ;(useAtomValue as jest.Mock).mockImplementationOnce(() => mockActiveThread) + const updateThreadMetadata = jest.fn() + ;(useActiveModel as jest.Mock).mockReturnValueOnce({ stopModel: jest.fn() }) + ;(useCreateNewThread as jest.Mock).mockReturnValueOnce({ + updateThreadMetadata, + }) + + render() + + await waitFor(() => { + const firstInput = screen.getByTestId('input') + expect(firstInput).toBeInTheDocument() + + userEvent.type(firstInput, '200') + expect(updateThreadMetadata).toHaveBeenCalled() + expect(setEngineParamsUpdate).toHaveBeenCalledTimes(0) + }) + }) + + test('triggers model reload with onValueChanged', async () => { + const setEngineParamsUpdate = jest.fn() + const updateThreadMetadata = jest.fn() + const stopModel = jest.fn() + ;(useAtomValue as jest.Mock).mockImplementationOnce(() => mockActiveThread) + ;(useSetAtom as jest.Mock).mockImplementation(() => setEngineParamsUpdate) + ;(useActiveModel as jest.Mock).mockReturnValueOnce({ stopModel }) + ;(useCreateNewThread as jest.Mock).mockReturnValueOnce({ + updateThreadMetadata, + }) + ;(useCreateNewThread as jest.Mock).mockReturnValueOnce({ + updateThreadMetadata, + }) + + render( + + ) + + await waitFor(() => { + const firstInput = screen.getByTestId('input') + expect(firstInput).toBeInTheDocument() + + userEvent.type(firstInput, '200') + expect(setEngineParamsUpdate).toHaveBeenCalled() + expect(stopModel).toHaveBeenCalled() + }) + }) +})