* fix: #3693 broken thread.json should not break the entire threads * test: add tests
This commit is contained in:
parent
c5e0c93ab4
commit
aee8624338
5
extensions/conversational-extension/jest.config.js
Normal file
5
extensions/conversational-extension/jest.config.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||||
|
module.exports = {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@
|
|||||||
"author": "Jan <service@jan.ai>",
|
"author": "Jan <service@jan.ai>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"test": "jest",
|
||||||
"build": "tsc -b . && webpack --config webpack.config.js",
|
"build": "tsc -b . && webpack --config webpack.config.js",
|
||||||
"build:publish": "rimraf *.tgz --glob && yarn build && npm pack && cpx *.tgz ../../pre-install"
|
"build:publish": "rimraf *.tgz --glob && yarn build && npm pack && cpx *.tgz ../../pre-install"
|
||||||
},
|
},
|
||||||
|
|||||||
408
extensions/conversational-extension/src/Conversational.test.ts
Normal file
408
extensions/conversational-extension/src/Conversational.test.ts
Normal 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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -5,6 +5,7 @@ import {
|
|||||||
Thread,
|
Thread,
|
||||||
ThreadMessage,
|
ThreadMessage,
|
||||||
} from '@janhq/core'
|
} from '@janhq/core'
|
||||||
|
import { safelyParseJSON } from './jsonUtil'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JSONConversationalExtension is a ConversationalExtension implementation that provides
|
* JSONConversationalExtension is a ConversationalExtension implementation that provides
|
||||||
@ -45,10 +46,11 @@ export default class JSONConversationalExtension extends ConversationalExtension
|
|||||||
if (result.status === 'fulfilled') {
|
if (result.status === 'fulfilled') {
|
||||||
return typeof result.value === 'object'
|
return typeof result.value === 'object'
|
||||||
? result.value
|
? result.value
|
||||||
: JSON.parse(result.value)
|
: safelyParseJSON(result.value)
|
||||||
}
|
}
|
||||||
|
return undefined
|
||||||
})
|
})
|
||||||
.filter((convo) => convo != null)
|
.filter((convo) => !!convo)
|
||||||
convos.sort(
|
convos.sort(
|
||||||
(a, b) => new Date(b.updated).getTime() - new Date(a.updated).getTime()
|
(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.
|
* @param threadDirName the thread dir we are reading from.
|
||||||
* @returns data of the thread
|
* @returns data of the thread
|
||||||
*/
|
*/
|
||||||
private async readThread(threadDirName: string): Promise<any> {
|
async readThread(threadDirName: string): Promise<any> {
|
||||||
return fs.readFileSync(
|
return fs.readFileSync(
|
||||||
await joinPath([
|
await joinPath([
|
||||||
JSONConversationalExtension._threadFolder,
|
JSONConversationalExtension._threadFolder,
|
||||||
@ -210,7 +212,7 @@ export default class JSONConversationalExtension extends ConversationalExtension
|
|||||||
* Returns a Promise that resolves to an array of thread directories.
|
* Returns a Promise that resolves to an array of thread directories.
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
private async getValidThreadDirs(): Promise<string[]> {
|
async getValidThreadDirs(): Promise<string[]> {
|
||||||
const fileInsideThread: string[] = await fs.readdirSync(
|
const fileInsideThread: string[] = await fs.readdirSync(
|
||||||
JSONConversationalExtension._threadFolder
|
JSONConversationalExtension._threadFolder
|
||||||
)
|
)
|
||||||
@ -266,7 +268,8 @@ export default class JSONConversationalExtension extends ConversationalExtension
|
|||||||
|
|
||||||
const messages: ThreadMessage[] = []
|
const messages: ThreadMessage[] = []
|
||||||
result.forEach((line: string) => {
|
result.forEach((line: string) => {
|
||||||
messages.push(JSON.parse(line))
|
const message = safelyParseJSON(line)
|
||||||
|
if (message) messages.push(safelyParseJSON(line))
|
||||||
})
|
})
|
||||||
return messages
|
return messages
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
14
extensions/conversational-extension/src/jsonUtil.ts
Normal file
14
extensions/conversational-extension/src/jsonUtil.ts
Normal 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!
|
||||||
|
}
|
||||||
@ -10,5 +10,6 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"rootDir": "./src"
|
"rootDir": "./src"
|
||||||
},
|
},
|
||||||
"include": ["./src"]
|
"include": ["./src"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
87
extensions/model-extension/src/helpers/path.test.ts
Normal file
87
extensions/model-extension/src/helpers/path.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -3,6 +3,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export function extractFileName(url: string, fileExtension: string): string {
|
export function extractFileName(url: string, fileExtension: string): string {
|
||||||
|
if(!url) return fileExtension
|
||||||
|
|
||||||
const extractedFileName = url.split('/').pop()
|
const extractedFileName = url.split('/').pop()
|
||||||
const fileName = extractedFileName.toLowerCase().endsWith(fileExtension)
|
const fileName = extractedFileName.toLowerCase().endsWith(fileExtension)
|
||||||
? extractedFileName
|
? extractedFileName
|
||||||
|
|||||||
@ -1,3 +1,10 @@
|
|||||||
module.exports = {
|
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',
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
107
web/containers/ErrorMessage/index.test.tsx
Normal file
107
web/containers/ErrorMessage/index.test.tsx
Normal 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, 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(<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);
|
||||||
|
});
|
||||||
|
});
|
||||||
69
web/containers/ListContainer/index.test.tsx
Normal file
69
web/containers/ListContainer/index.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -29,7 +29,7 @@ const ListContainer = ({ children }: Props) => {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isUserManuallyScrollingUp.current === true) return
|
if (isUserManuallyScrollingUp.current === true || !listRef.current) return
|
||||||
const scrollHeight = listRef.current?.scrollHeight ?? 0
|
const scrollHeight = listRef.current?.scrollHeight ?? 0
|
||||||
listRef.current?.scrollTo({
|
listRef.current?.scrollTo({
|
||||||
top: scrollHeight,
|
top: scrollHeight,
|
||||||
|
|||||||
75
web/containers/Loader/GenerateResponse.test.tsx
Normal file
75
web/containers/Loader/GenerateResponse.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
124
web/containers/Loader/ModelReload.test.tsx
Normal file
124
web/containers/Loader/ModelReload.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
22
web/containers/Loader/ProgressCircle.test.tsx
Normal file
22
web/containers/Loader/ProgressCircle.test.tsx
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -22,6 +22,7 @@ const ProgressCircle: React.FC<ProgressCircleProps> = ({
|
|||||||
width={size}
|
width={size}
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox={`0 0 ${size} ${size}`}
|
viewBox={`0 0 ${size} ${size}`}
|
||||||
|
role="img"
|
||||||
>
|
>
|
||||||
<circle
|
<circle
|
||||||
className="opacity-25"
|
className="opacity-25"
|
||||||
|
|||||||
105
web/containers/ModalTroubleShoot/AppLogs.test.tsx
Normal file
105
web/containers/ModalTroubleShoot/AppLogs.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user