// @vitest-environment jsdom import { beforeEach, describe, expect, it, vi } from 'vitest' import { mount } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key, }), })) vi.mock('naive-ui', () => ({ useMessage: () => ({ error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), }), })) import MessageItem from '@/components/hermes/chat/MessageItem.vue' import type { Message } from '@/stores/hermes/chat' describe('MessageItem tool details', () => { beforeEach(() => { setActivePinia(createPinia()) Object.defineProperty(window, 'isSecureContext', { configurable: true, value: true, }) Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: vi.fn().mockResolvedValue(undefined), }, }) Object.defineProperty(window, 'speechSynthesis', { configurable: true, value: { addEventListener: vi.fn(), removeEventListener: vi.fn(), getVoices: vi.fn(() => []), speak: vi.fn(), cancel: vi.fn(), pause: vi.fn(), resume: vi.fn(), }, }) }) it('renders highlighted code blocks for tool arguments and tool results', async () => { const wrapper = mount(MessageItem, { props: { message: { id: 'tool-1', role: 'tool', content: '', timestamp: Date.now(), toolName: 'web_search', toolArgs: '{"query":"syntax highlighting"}', toolResult: '{"results":[{"title":"Done"}]}', toolStatus: 'done', } satisfies Message, }, }) await wrapper.find('.tool-line').trigger('click') const blocks = wrapper.findAll('.tool-details .hljs-code-block') expect(blocks).toHaveLength(2) expect(blocks[0].find('.code-lang').text()).toBe('json') expect(blocks[1].find('.code-lang').text()).toBe('json') }) it('copies tool detail code through the delegated click handler', async () => { const writeText = vi.mocked(navigator.clipboard.writeText) const wrapper = mount(MessageItem, { props: { message: { id: 'tool-copy', role: 'tool', content: '', timestamp: Date.now(), toolName: 'web_search', toolArgs: '{"query":"syntax highlighting"}', toolStatus: 'done', } satisfies Message, }, }) await wrapper.find('.tool-line').trigger('click') const expected = wrapper.find('.tool-details code.hljs').text() await wrapper.find('.tool-details [data-copy-code="true"]').trigger('click') expect(writeText).toHaveBeenCalledWith(expected) }) it('truncates large tool arguments for display but copies the full formatted payload', async () => { const writeText = vi.mocked(navigator.clipboard.writeText) const message = { content: 'x'.repeat(4000), ok: true, } const wrapper = mount(MessageItem, { props: { message: { id: 'tool-args-large', role: 'tool', content: '', timestamp: Date.now(), toolName: 'write_file', toolArgs: JSON.stringify(message), toolStatus: 'done', } satisfies Message, }, }) await wrapper.find('.tool-line').trigger('click') const expected = JSON.stringify(message, null, 2) const code = wrapper.find('.tool-details code.hljs') const displayed = JSON.parse(code.text()) expect(wrapper.find('.tool-details .code-lang').text()).toBe('json') expect(wrapper.html()).toContain('chat.truncated') expect(displayed.content).toContain('chat.truncated') expect(code.findAll('span').length).toBeGreaterThan(0) await wrapper.find('.tool-details [data-copy-code="true"]').trigger('click') expect(writeText).toHaveBeenCalledWith(expected) }) it('copies the full large JSON tool result even when the display is truncated', async () => { const writeText = vi.mocked(navigator.clipboard.writeText) const fullResult = { content: 'x'.repeat(4000), ok: true, } const wrapper = mount(MessageItem, { props: { message: { id: 'tool-2', role: 'tool', content: '', timestamp: Date.now(), toolName: 'read_file', toolResult: JSON.stringify(fullResult), toolStatus: 'done', } satisfies Message, }, }) await wrapper.find('.tool-line').trigger('click') const code = wrapper.find('.tool-details code.hljs') const displayed = JSON.parse(code.text()) expect(wrapper.find('.tool-details .code-lang').text()).toBe('json') expect(wrapper.html()).toContain('chat.truncated') expect(displayed.content).toContain('chat.truncated') expect(code.findAll('span').length).toBeGreaterThan(0) await wrapper.find('.tool-details [data-copy-code="true"]').trigger('click') expect(writeText).toHaveBeenCalledWith(JSON.stringify(fullResult, null, 2)) }) it('truncates large JSON arrays at item boundaries so display remains parseable JSON', async () => { const fullResult = Array.from({ length: 100 }, (_, index) => ({ index, value: `item-${index}-${'x'.repeat(80)}`, })) const wrapper = mount(MessageItem, { props: { message: { id: 'tool-array', role: 'tool', content: '', timestamp: Date.now(), toolName: 'browser_snapshot', toolResult: JSON.stringify(fullResult), toolStatus: 'done', } satisfies Message, }, }) await wrapper.find('.tool-line').trigger('click') const code = wrapper.find('.tool-details code.hljs') const displayed = JSON.parse(code.text()) expect(Array.isArray(displayed)).toBe(true) expect(displayed.at(-1)).toContain('chat.truncated') expect(code.text().length).toBeLessThanOrEqual(1000) }) it('copies the full large raw tool result even when the display is truncated', async () => { const writeText = vi.mocked(navigator.clipboard.writeText) const fullResult = 'line\n'.repeat(1200) const wrapper = mount(MessageItem, { props: { message: { id: 'tool-raw', role: 'tool', content: '', timestamp: Date.now(), toolName: 'read_file', toolResult: fullResult, toolStatus: 'done', } satisfies Message, }, }) await wrapper.find('.tool-line').trigger('click') const displayedResult = fullResult.slice(0, 1000) + '\nchat.truncated' const code = wrapper.find('.tool-details code.hljs') expect(wrapper.find('.tool-details .code-lang').text()).toBe('text') expect(code.text()).toBe(displayedResult) expect(code.findAll('span')).toHaveLength(0) await wrapper.find('.tool-details [data-copy-code="true"]').trigger('click') expect(writeText).toHaveBeenCalledWith(fullResult) }) })