components.test.tsx
Language TypeScript8.0 KB178 lines
import { act, fireEvent, render, screen } from '@testing-library/react'import { beforeEach, describe, expect, it } from 'vitest'import type { IpcClient } from '../ipc/client'import { createMockClient } from '../ipc/mock/mockClient'import { useStore } from '../state/store'import { Markdown } from './Markdown'import { prettyShortcut, SettingsPanel, shortcutFrom } from './SettingsPanel'import { Sidebar } from './Sidebar'const key = (code: string, mods: Partial<Record<'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey', boolean>> = {}) => ({code,metaKey: false,ctrlKey: false,altKey: false,shiftKey: false,...mods,})describe('shortcut recorder', () => {it('builds global-shortcut strings', () => {expect(shortcutFrom(key('Space', { metaKey: true, shiftKey: true }), true)).toBe('CommandOrControl+Shift+Space')expect(shortcutFrom(key('KeyJ', { ctrlKey: true, altKey: true }), false)).toBe('CommandOrControl+Alt+J')expect(shortcutFrom(key('KeyJ'), true)).toBeNull() // a bare key is not a global shortcutexpect(shortcutFrom(key('ArrowUp', { metaKey: true }), true)).toBeNull()expect(prettyShortcut('CommandOrControl+Shift+Space', true)).toBe('⌘⇧Space')expect(prettyShortcut('CommandOrControl+Shift+Space', false)).toBe('Ctrl+Shift+Space')})})describe('markdown', () => {it('renders code and tables, and never raw HTML', () => {const { container } = render(<Markdowntext={'**bold** <script>alert(1)</script><img src=x onerror=alert(1)>\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n```bash\necho hi\n```'}/>,)expect(container.querySelector('strong')?.textContent).toBe('bold')expect(container.querySelector('script')).toBeNull()expect(container.querySelector('img')).toBeNull()expect(container.querySelector('table')).not.toBeNull()expect(container.querySelector('.codeblock-bar')?.textContent).toContain('bash')expect(container.querySelector('.hljs-built_in, .hljs-keyword')).not.toBeNull()})it('links do not navigate the app', () => {const opened: string[] = []const client = { openUrl: async (url: string) => void opened.push(url) } as unknown as IpcClientuseStore.setState({ client })render(<Markdown text="[site](https://example.com), [plain](http://example.com) and [bad](javascript:alert(1))" />)const link = screen.getByText('site') as HTMLAnchorElementconst bad = screen.getByText('bad') as HTMLAnchorElementexpect(link.title).toBe('https://example.com')expect(bad.getAttribute('href') ?? '').not.toMatch(/^javascript:/i)// fireEvent returns false when the click's default (navigation) was prevented.for (const a of [link, screen.getByText('plain'), bad]) expect(fireEvent.click(a)).toBe(false)expect(opened).toEqual(['https://example.com'])useStore.setState({ client: null })})})describe('the store streams a reply', () => {beforeEach(() => {useStore.setState({ client: null, view: null, streaming: null, drafts: [], toasts: [] })})it('creates the chat, streams, then settles with a cost', async () => {const client = createMockClient({ delay: 1, empty: true })await act(() => useStore.getState().init(client))let sent = falseawait act(async () => {sent = await useStore.getState().send('what does a cache layer buy me')})expect(sent).toBe(true)expect(useStore.getState().view?.messages[0].content).toBe('what does a cache layer buy me')await act(() => new Promise((r) => setTimeout(r, 400)))const s = useStore.getState()expect(s.streaming).toBeNull()const reply = s.view!.messages[1]expect(reply.status).toBe('complete')expect(reply.content).toContain('You asked')expect(reply.cost_nanousd).toBeGreaterThan(0)expect(s.conversations[0].title).toBe('what does a cache layer buy me')expect(s.spend!.month_nanousd).toBe(reply.cost_nanousd)})it('stop keeps the partial reply as cancelled', async () => {const client = createMockClient({ delay: 20, empty: true })await act(() => useStore.getState().init(client))await act(async () => {await useStore.getState().send('please write a long answer about caching')})await act(() => new Promise((r) => setTimeout(r, 90)))await act(() => useStore.getState().stop())await act(() => new Promise((r) => setTimeout(r, 60)))const reply = useStore.getState().view!.messages[1]expect(reply.status).toBe('cancelled')expect(reply.usage_source).toBe('estimated')expect(reply.content.length).toBeGreaterThan(0)})it('without a key, sending is refused with a clear message', async () => {const client = createMockClient({ empty: true, hasKey: false })await act(() => useStore.getState().init(client))let sent = trueawait act(async () => {sent = await useStore.getState().send('hi')})expect(sent).toBe(false)expect(useStore.getState().toasts[0].text).toMatch(/Add your DeepInfra API key/)})})describe('titles, labels and the key field', () => {beforeEach(() => {useStore.setState({ client: null, view: null, streaming: null, drafts: [], toasts: [], panel: null })})it('the open chat takes its title from the first message', async () => {const client = createMockClient({ delay: 1, empty: true })await act(() => useStore.getState().init(client))await act(async () => {await useStore.getState().send('Does this set read?')})await act(() => new Promise((r) => setTimeout(r, 200)))expect(useStore.getState().view?.conversation.title).toBe('Does this set read?')})it('a chat whose reply was refused is not labeled free', async () => {const client = createMockClient({ empty: true })await act(() => useStore.getState().init(client))const s = (await client.listConversations(false))[0]expect(s).toBeUndefined()const conv = await client.createConversation('deepinfra', 'google/gemma-3-12b-it', null, 'main')const list = await client.listConversations(false)const summary = list.find((x) => x.id === conv.id)!expect(summary.has_usage).toBe(false)// A refused reply is saved with no usage and no cost; a local model's reply has usage and no cost.const refused = { ...summary, message_count: 2 }const local = { ...summary, id: 'local', title: 'Local reply', message_count: 2, has_usage: true }act(() => useStore.setState({ conversations: [refused, local] }))const { container } = render(<Sidebar />)const costs = [...container.querySelectorAll('.conv-cost')].map((el) => el.textContent)expect(costs).toEqual(['', 'free'])})it('Enter in the key field saves it and confirms at once', async () => {const client = createMockClient({ empty: true, hasKey: false })await act(() => useStore.getState().init(client))render(<SettingsPanel />)const input = screen.getByTestId('api-key')fireEvent.change(input, { target: { value: 'di-test-key' } })await act(async () => {fireEvent.submit(input.closest('form')!)await new Promise((r) => setTimeout(r, 20))})expect(await client.hasApiKey('deepinfra')).toBe(true)expect(useStore.getState().keys.deepinfra).toBe(true)expect(useStore.getState().toasts.map((t) => t.text)).toContain('Key saved. DeepInfra accepted it.')})it('a refused key is not saved, and says so in one message', async () => {const client = createMockClient({ empty: true, hasKey: false })await act(() => useStore.getState().init(client))render(<SettingsPanel />)const input = screen.getByTestId('api-key')fireEvent.change(input, { target: { value: 'bad-key' } })await act(async () => {fireEvent.submit(input.closest('form')!)await new Promise((r) => setTimeout(r, 20))})expect(await client.hasApiKey('deepinfra')).toBe(false)const toasts = useStore.getState().toasts.map((t) => t.text)expect(toasts).toEqual(['DeepInfra rejected this key, so it was not saved. Copy the whole key again and paste it.'])})})