diff --git a/package-lock.json b/package-lock.json index 39fc8c4..e413f7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "witsy", - "version": "1.7.1", + "version": "1.7.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "witsy", - "version": "1.7.1", + "version": "1.7.3", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.20.4", diff --git a/src/screens/Wait.vue b/src/screens/Wait.vue index d263357..9ff8b14 100644 --- a/src/screens/Wait.vue +++ b/src/screens/Wait.vue @@ -1,17 +1,21 @@ @@ -44,15 +48,7 @@ const onCancel = () => { margin: 4px; width: 10px; height: 10px; - display: none; -} - -.wait:hover .cancel { display: inline-block; } -.wait:hover .loader { - display: none; -} - \ No newline at end of file diff --git a/src/types/index.d.ts b/src/types/index.d.ts index d081109..d300eea 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -148,6 +148,7 @@ declare global { commands?: { load?(): Command[] save?(commands: Command[]): void + cancel?(): void closePalette?(): void run?(command: Command): void getPrompt?(id: string): string @@ -155,6 +156,7 @@ declare global { anywhere?: { prompt?(text: string): void cancel?(): void + resize?(width: number, height: number): void } prompts?: { load?(): Prompt[] diff --git a/tests/screens/commands.test.ts b/tests/screens/commands.test.ts new file mode 100644 index 0000000..1fbee2b --- /dev/null +++ b/tests/screens/commands.test.ts @@ -0,0 +1,96 @@ + +import { vi, beforeEach, expect, test } from 'vitest' +import { mount } from '@vue/test-utils' +import Commands from '../../src/screens/Commands.vue' +import defaults from '../../defaults/settings.json' + +window.api = { + config: { + load: vi.fn(() => defaults), + }, + commands: { + load: vi.fn(() => [ + { id: 1, icon: '1', label: 'Command 1', shortcut: '1', action: 'chat_window', state: 'enabled' }, + { id: 2, icon: '2', label: 'Command 2', shortcut: '2', action: 'paste_below', state: 'enabled' }, + { id: 3, icon: '3', label: 'Command 3', shortcut: '3', action: 'paste_in_place', state: 'enabled' }, + { id: 4, icon: '4', label: 'Command 4', shortcut: '4', action: 'clipboard_copy', state: 'enabled' }, + { id: 5, icon: '5', label: 'Command 5', shortcut: '5', action: 'chat_window', state: 'disabled' }, + ]), + run: vi.fn(), + cancel: vi.fn(), + closePalette: vi.fn(), + } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +test('Renders correctly', () => { + const wrapper = mount(Commands) + expect(wrapper.exists()).toBe(true) + expect(wrapper.find('.commands').exists()).toBe(true) + expect(wrapper.findAll('.command')).toHaveLength(4) + + for (let i=0; i<4; i++) { + const command = wrapper.findAll('.command').at(i) + expect(command.find('.icon').text()).toBe(`${i+1}`) + expect(command.find('.label').text()).toBe(`Command ${i+1}`) + } +}) + +// test('Closes on Escape', async () => { +// const wrapper = mount(Commands) +// await wrapper.trigger('keyup', { key: 'Escape' }) +// expect(window.api.commands.closePalette).toHaveBeenCalled() +// }) + +test('Runs command on click', async () => { + const wrapper = mount(Commands, { props: { extra: { textId: 6 }}}) + const command = wrapper.findAll('.command').at(0) + await command.trigger('click') + expect(window.api.commands.run).toHaveBeenCalledWith({ + command: { + action: 'chat_window', + icon: '1', + id: 1, + shortcut: '1', + label: 'Command 1', + state: 'enabled', + }, + textId: 6, + }) +}) + +// test('Runs command on shortcut', async () => { +// const wrapper = mount(Commands, { props: { extra: { textId: 6 }}}) +// await wrapper.trigger('keyup', { key: '2' }) +// expect(window.api.commands.run).toHaveBeenCalledWith({ +// command: { +// action: 'paste_below', +// icon: '2', +// id: 2, +// shortcut: '2', +// label: 'Command 2', +// state: 'enabled', +// }, +// textId: 6, +// }) +// }) + +// test('Uses chat on shift', async () => { +// const wrapper = mount(Commands, { props: { extra: { textId: 6 }}}) +// await wrapper.trigger('keyup', { key: '3', shiftKey: true}) +// expect(window.api.commands.run).toHaveBeenCalledWith({ +// command: { +// action: 'chat_window', +// icon: '3', +// id: 3, +// shortcut: '3', +// label: 'Command 3', +// state: 'enabled', +// }, +// textId: 6, +// }) + +// }) diff --git a/tests/screens/main.test.ts b/tests/screens/main.test.ts new file mode 100644 index 0000000..46d4033 --- /dev/null +++ b/tests/screens/main.test.ts @@ -0,0 +1,90 @@ + +import { vi, beforeEach, expect, test } from 'vitest' +import { mount } from '@vue/test-utils' +import { store } from '../../src/services/store' +import Main from '../../src/screens/Main.vue' +import Sidebar from '../../src/components/Sidebar.vue' +import ChatArea from '../../src/components/ChatArea.vue' +import Settings from '../../src/screens/Settings.vue' +import defaults from '../../defaults/settings.json' +import * as _Assistant from '../../src/services/assistant' + +import useEventBus from '../../src/composables/useEventBus' +const { emitEvent } = useEventBus() + +window.api = { + config: { + load: vi.fn(() => defaults), + }, + commands: { + load: vi.fn(() => []), + }, + prompts: { + load: vi.fn(() => []), + }, + history: { + load: vi.fn(() => []), + }, + on: vi.fn(), +} + +vi.mock('../../src/services/assistant', async () => { + const Assistant = vi.fn() + Assistant.prototype.setChat = vi.fn() + Assistant.prototype.initLlm = vi.fn() + Assistant.prototype.hasLlm = vi.fn(() => true) + Assistant.prototype.prompt = vi.fn() + Assistant.prototype.stop = vi.fn() + return { default: Assistant } +}) + +beforeEach(() => { + vi.clearAllMocks() +}) + +test('Renders correctly', () => { + const wrapper = mount(Main) + expect(wrapper.exists()).toBe(true) + expect(wrapper.find('.main').exists()).toBe(true) + expect(wrapper.findComponent(Sidebar).exists()).toBe(true) + expect(wrapper.findComponent(ChatArea).exists()).toBe(true) + expect(wrapper.findComponent(Settings).exists()).toBe(true) +}) + +test('Resets assistant', async () => { + mount(Main) + emitEvent('newChat') + expect(_Assistant.default.prototype.setChat).toHaveBeenCalledWith(null) +}) + +test('Attach/Detach file', async () => { + mount(Main) + expect(store.pendingAttachment).toBeNull() + emitEvent('attachFile', 'file') + expect(store.pendingAttachment).toBe('file') + emitEvent('detachFile') + expect(store.pendingAttachment).toBeNull() +}) + +test('Sends prompt', async () => { + mount(Main) + emitEvent('sendPrompt', 'prompt') + expect(_Assistant.default.prototype.initLlm).toHaveBeenCalled() + expect(_Assistant.default.prototype.prompt).toHaveBeenCalledWith('prompt', { attachment: null }, expect.any(Function)) +}) + +test('Sends prompt with attachment', async () => { + mount(Main) + emitEvent('attachFile', 'file') + expect(store.pendingAttachment).toBe('file') + emitEvent('sendPrompt', 'prompt') + expect(_Assistant.default.prototype.initLlm).toHaveBeenCalled() + expect(_Assistant.default.prototype.prompt).toHaveBeenCalledWith('prompt', { attachment: 'file' }, expect.any(Function)) +}) + +test('Stop assistant', async () => { + mount(Main) + emitEvent('stopAssistant') + expect(_Assistant.default.prototype.stop).toHaveBeenCalled() +}) + diff --git a/tests/screens/prompt.test.ts b/tests/screens/prompt.test.ts new file mode 100644 index 0000000..26b97a8 --- /dev/null +++ b/tests/screens/prompt.test.ts @@ -0,0 +1,43 @@ + +import { vi, beforeEach, expect, test } from 'vitest' +import { mount } from '@vue/test-utils' +import Prompt from '../../src/components/Prompt.vue' +import PromptAnywhere from '../../src/screens/PromptAnywhere.vue' +import defaults from '../../defaults/settings.json' + +import useEventBus from '../../src/composables/useEventBus' +const { emitEvent } = useEventBus() + +window.api = { + config: { + load: vi.fn(() => defaults), + }, + anywhere: { + prompt: vi.fn(), + cancel: vi.fn(), + resize: vi.fn(), + }, +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +test('Renders correctly', () => { + const wrapper = mount(PromptAnywhere) + expect(wrapper.exists()).toBe(true) + expect(wrapper.find('.anywhere').exists()).toBe(true) + expect(wrapper.findComponent(Prompt).exists()).toBe(true) +}) + +// test('Closes on Escape', async () => { +// const wrapper = mount(PromptAnywhere) +// await wrapper.trigger('keyup', { key: 'Escape' }) +// expect(window.api.anywhere.cancel).toHaveBeenCalled() +// }) + +test('Prompts on Enter', async () => { + const wrapper = mount(PromptAnywhere) + emitEvent('sendPrompt', 'prompt') + expect(window.api.anywhere.prompt).toHaveBeenCalled() +}) diff --git a/tests/components/settings.test.ts b/tests/screens/settings.test.ts similarity index 96% rename from tests/components/settings.test.ts rename to tests/screens/settings.test.ts index 462e6d4..e1b1895 100644 --- a/tests/components/settings.test.ts +++ b/tests/screens/settings.test.ts @@ -55,22 +55,21 @@ const checkVisibility = (visible: number) => { } } +// window let runAtLogin = false +window.api = { + platform: 'darwin', + on: vi.fn(), + runAtLogin: { + get: () => runAtLogin, + set: vi.fn((state) => { + runAtLogin = state + }) + }, +} beforeAll(() => { - // window - window.api = { - platform: 'darwin', - on: vi.fn(), - runAtLogin: { - get: () => runAtLogin, - set: vi.fn((state) => { - runAtLogin = state - }) - }, - } - // init store store.config = defaults diff --git a/tests/screens/wait.test.ts b/tests/screens/wait.test.ts new file mode 100644 index 0000000..bfdb16b --- /dev/null +++ b/tests/screens/wait.test.ts @@ -0,0 +1,40 @@ + +import { vi, expect, test } from 'vitest' +import { mount } from '@vue/test-utils' +import Wait from '../../src/screens/Wait.vue' + +window.api = { + commands: { + cancel: vi.fn() + }, + anywhere: { + cancel: vi.fn() + } +} + +test('Renders correctly', () => { + const wrapper = mount(Wait) + expect(wrapper.exists()).toBe(true) + expect(wrapper.find('.wait').exists()).toBe(true) + expect(wrapper.find('.loader').exists()).toBe(true) + expect(wrapper.find('.cancel').exists()).toBe(true) + expect(wrapper.find('.loader').isVisible()).toBe(true) + expect(wrapper.find('.cancel').isVisible()).toBe(false) +}) + +test('Toggles cancel Button', async () => { + const wrapper = mount(Wait) + await wrapper.find('.wait').trigger('mouseenter') + expect(wrapper.find('.loader').isVisible()).toBe(false) + expect(wrapper.find('.cancel').isVisible()).toBe(true) + await wrapper.find('.wait').trigger('mouseleave') + expect(wrapper.find('.loader').isVisible()).toBe(true) + expect(wrapper.find('.cancel').isVisible()).toBe(false) +}) + +test('Cancels command', () => { + const wrapper = mount(Wait) + wrapper.find('.cancel').trigger('click') + expect(window.api.commands.cancel).toHaveBeenCalled() + expect(window.api.anywhere.cancel).toHaveBeenCalled() +}) diff --git a/tests/unit/anywhere.test.ts b/tests/unit/anywhere.test.ts new file mode 100644 index 0000000..191e20a --- /dev/null +++ b/tests/unit/anywhere.test.ts @@ -0,0 +1,93 @@ + +import { vi, beforeAll, beforeEach, expect, test } from 'vitest' +import { Command } from '../../src/types/index.d' +import { store } from '../../src/services/store' +import defaults from '../../defaults/settings.json' +import * as window from '../../src/main/window' +import PromptAnywhere from '../../src/automations/anywhere' +import Automator from '../../src/automations/automator' +import LlmMock from '../mocks/llm' + +// mock config +vi.mock('../../src/main/config.ts', async () => { + return { + loadSettings: () => defaults, + } +}) + +// mock windows +vi.mock('../../src/main/window.ts', async () => { + return { + openPromptAnywhere: vi.fn(), + openWaitingPanel: vi.fn(), + closeWaitingPanel: vi.fn(), + hideWindows: vi.fn(), + restoreWindows: vi.fn(), + releaseFocus: vi.fn() + } +}) + +// mock automator +vi.mock('../../src/automations/automator.ts', async () => { + const Automator = vi.fn() + Automator.prototype.moveCaretBelow = vi.fn() + Automator.prototype.getSelectedText = vi.fn(() => 'Grabbed text') + Automator.prototype.pasteText = vi.fn() + Automator.prototype.copyToClipboard = vi.fn() + return { default: Automator } +}) + +beforeAll(() => { + + // init store + store.config = defaults + store.config.llm.engine = 'mock' + store.config.instructions = { + default: 'You are a chat assistant', + routing: 'You are a routing assistant', + titling: 'You are a titling assistant' + } + store.config.getActiveModel = () => 'chat' + +}) + +beforeEach(() => { + vi.clearAllMocks() +}) + +test('Prepare prompt', async () => { + + await PromptAnywhere.initPrompt() + + expect(window.hideWindows).toHaveBeenCalledOnce() + expect(window.openPromptAnywhere).toHaveBeenCalledOnce() + +}) + +test('Execute Prompt', async () => { + + const anywhere = new PromptAnywhere(new LlmMock(store.config)) + await anywhere.execPrompt(null, 'Explain this') + + expect(window.openWaitingPanel).toHaveBeenCalledOnce() + expect(window.closeWaitingPanel).toHaveBeenCalledOnce() + expect(window.restoreWindows).toHaveBeenCalledOnce() + + expect(Automator.prototype.pasteText).toHaveBeenCalledWith('[{"role":"user","content":"Explain this"},{"role":"assistant","content":"Be kind. Don\'t mock me"}]') + +}) + +test('Cancel Prompt', async () => { + + const anywhere = new PromptAnywhere(new LlmMock(store.config)) + await anywhere.cancel() + + expect(window.closeWaitingPanel).toHaveBeenCalledOnce() + expect(window.restoreWindows).toHaveBeenCalledOnce() + expect(window.releaseFocus).toHaveBeenCalledOnce() + + await anywhere.execPrompt(null, 'Explain this') + + expect(Automator.prototype.pasteText).not.toHaveBeenCalled() + +}) diff --git a/tests/unit/engine_ollama.test.ts b/tests/unit/engine_ollama.test.ts index 2c652cc..dcbc698 100644 --- a/tests/unit/engine_ollama.test.ts +++ b/tests/unit/engine_ollama.test.ts @@ -41,7 +41,6 @@ beforeEach(() => { test('Ollama Load Models', async () => { expect(await loadOllamaModels()).toBe(true) const models = store.config.engines.ollama.models.chat - console.log(models) expect(models.map((m: Model) => { return { id: m.id, name: m.name }})).toStrictEqual([ { id: 'model1', name: 'model1' }, { id: 'model2', name: 'model2' }, diff --git a/tests/unit/engine_openai.test.ts b/tests/unit/engine_openai.test.ts index 8ef38b9..1b546dc 100644 --- a/tests/unit/engine_openai.test.ts +++ b/tests/unit/engine_openai.test.ts @@ -10,7 +10,7 @@ import { ChatCompletionChunk } from 'openai/resources' import { loadOpenAIModels } from '../../src/services/llm' import { Model } from '../../src/types/config.d' -vi.mock('openai', async() => { +vi.mock('openai', async () => { const OpenAI = vi.fn() OpenAI.prototype.apiKey = '123' OpenAI.prototype.models = {