fix: #3693 broken thread.json should not break the entire threads (#3709)

* fix: #3693 broken thread.json should not break the entire threads

* test: add tests
This commit is contained in:
Louis 2024-09-23 14:20:01 +07:00 committed by GitHub
parent c5e0c93ab4
commit aee8624338
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1176 additions and 8 deletions

View File

@ -0,0 +1,5 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
}

View File

@ -7,6 +7,7 @@
"author": "Jan <service@jan.ai>",
"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"
},

View File

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

View File

@ -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<any> {
async readThread(threadDirName: string): Promise<any> {
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<string[]> {
async getValidThreadDirs(): Promise<string[]> {
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) {

View File

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

View File

@ -10,5 +10,6 @@
"skipLibCheck": true,
"rootDir": "./src"
},
"include": ["./src"]
"include": ["./src"],
"exclude": ["src/**/*.test.ts"]
}

View File

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

View File

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

View File

@ -1,3 +1,10 @@
module.exports = {
projects: ['<rootDir>/core', '<rootDir>/web', '<rootDir>/joi'],
projects: [
'<rootDir>/core',
'<rootDir>/web',
'<rootDir>/joi',
'<rootDir>/extensions/inference-nitro-extension',
'<rootDir>/extensions/conversational-extension',
'<rootDir>/extensions/model-extension',
],
}

View File

@ -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(<ErrorMessage message={message} />);
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(<ErrorMessage message={message} />);
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(<ErrorMessage message={message} />);
expect(screen.getByText("Apologies, somethings 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(<ErrorMessage message={message} />);
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(<ErrorMessage message={message} />);
fireEvent.click(screen.getByText('troubleshooting assistance'));
expect(mockSetModalTroubleShooting).toHaveBeenCalledWith(true);
});
});

View File

@ -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(
<ListContainer>
<div data-testid="child">Test Child</div>
</ListContainer>
)
expect(screen.getByTestId('child')).toBeInTheDocument()
})
it('scrolls to bottom on initial render', () => {
render(
<ListContainer>
<div style={{ height: '1000px' }}>Long content</div>
</ListContainer>
)
expect(scrollToMock).toHaveBeenCalledWith({
top: expect.any(Number),
behavior: 'instant',
})
})
it('sets isUserManuallyScrollingUp when scrolling up', () => {
const { container } = render(
<ListContainer>
<div style={{ height: '1000px' }}>Long content</div>
</ListContainer>
)
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(
<ListContainer>
<div style={{ height: '1000px' }}>Long content</div>
</ListContainer>
)
expect(scrollToMock).toHaveBeenCalled()
})
})

View File

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

View File

@ -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(<GenerateResponse />);
const loader = screen.getByTestId('response-loader');
expect(loader).toHaveStyle('width: 24%');
});
it('updates loader width over time', () => {
render(<GenerateResponse />);
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(<GenerateResponse />);
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(<GenerateResponse />);
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(<GenerateResponse />);
expect(screen.getByText('Generating response...')).toBeInTheDocument();
});
});

View File

@ -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(<ModelReload />)
expect(container).toBeEmptyDOMElement()
})
it('renders loading message when loading', () => {
;(useActiveModel as jest.Mock).mockReturnValue({
stateModel: { loading: true, model: { id: 'test-model' } },
})
render(<ModelReload />)
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(<ModelReload />)
// 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(<ModelReload />)
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(<ModelReload />)
;(useActiveModel as jest.Mock).mockReturnValue({
stateModel: { loading: true, model: { id: 'test-model' } },
})
rerender(<ModelReload />)
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(<ModelReload />)
expect(
screen.queryByText(/Reloading model test-model/)
).not.toBeInTheDocument()
})
})

View File

@ -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(<ProgressCircle percentage={50} />)
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(<ProgressCircle percentage={75} size={200} />)
const svg = screen.getByRole('img', { hidden: true })
expect(svg).toHaveAttribute('width', '200')
expect(svg).toHaveAttribute('height', '200')
})
})

View File

@ -22,6 +22,7 @@ const ProgressCircle: React.FC<ProgressCircleProps> = ({
width={size}
xmlns="http://www.w3.org/2000/svg"
viewBox={`0 0 ${size} ${size}`}
role="img"
>
<circle
className="opacity-25"

View File

@ -0,0 +1,105 @@
// AppLogs.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 AppLogs from './AppLogs'
import { useLogs } from '@/hooks/useLogs'
import { usePath } from '@/hooks/usePath'
import { useClipboard } from '@/hooks/useClipboard'
// Mock the hooks
jest.mock('@/hooks/useLogs')
jest.mock('@/hooks/usePath')
jest.mock('@/hooks/useClipboard')
describe('AppLogs Component', () => {
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(<AppLogs />)
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(<AppLogs />)
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(<AppLogs />)
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(<AppLogs />)
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(<AppLogs />)
await waitFor(() => {
expect(screen.getByText('Copying...')).toBeInTheDocument()
})
})
})

View File

@ -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 (
<input
type="number"
data-testid="input"
onChange={(e) => 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(<AssistantSetting componentData={ComponentPropsMock} />)
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(
<AssistantSetting
componentData={
[
{
key: 'chunk_size',
type: 'number',
title: 'Chunk Size',
value: 100,
controllerType: 'input',
requireModelReload: true,
},
] as any
}
/>
)
await waitFor(() => {
const firstInput = screen.getByTestId('input')
expect(firstInput).toBeInTheDocument()
userEvent.type(firstInput, '200')
expect(setEngineParamsUpdate).toHaveBeenCalled()
expect(stopModel).toHaveBeenCalled()
})
})
})