diff --git a/extensions/conversational-extension/jest.config.js b/extensions/conversational-extension/jest.config.js new file mode 100644 index 0000000000..8bb37208d7 --- /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 d062ce9c33..036fcfab25 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 0000000000..3d1d6fc607 --- /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 1bca75347d..b34f09181d 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 0000000000..7f83cadce5 --- /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 2477d58ce5..8427123e73 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/jest.config.js b/jest.config.js index a911a7f0a8..a9f0f59385 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', + ], }