-
Notifications
You must be signed in to change notification settings - Fork 648
CodeRabbit Generated Unit Tests: Add renderer unit tests for components and composables #1027
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| import { mount } from '@vue/test-utils' | ||
| import { describe, it, expect } from 'vitest' | ||
| import HTMLArtifact from '@/components/artifacts/HTMLArtifact.vue' | ||
|
|
||
| describe('HTMLArtifact', () => { | ||
| it('applies correct classes and styles for mobile viewport', () => { | ||
| const wrapper = mount(HTMLArtifact, { | ||
| props: { | ||
| block: { content: '<html><body>Hello</body></html>', artifact: { type: 'text/html', title: 'doc' } }, | ||
| isPreview: true, | ||
| viewportSize: 'mobile' | ||
| }, | ||
| attachTo: document.body | ||
| }) | ||
|
|
||
| const iframe = wrapper.find('iframe') | ||
| expect(iframe.exists()).toBe(true) | ||
| const cls = iframe.attributes('class') || '' | ||
| expect(cls).toContain('html-iframe-wrapper') | ||
| expect(cls).toContain('border') | ||
|
|
||
| const style = iframe.attributes('style') || '' | ||
| expect(style).toContain('width: 375px') | ||
| expect(style).toContain('height: 667px') | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import { mount } from '@vue/test-utils' | ||
| import { describe, it, expect } from 'vitest' | ||
| import MessageActionButtons from '@/components/message/MessageActionButtons.vue' | ||
|
|
||
| describe('MessageActionButtons', () => { | ||
| it('emits events on clicks', async () => { | ||
| const wrapper = mount(MessageActionButtons, { | ||
| props: { showCleanButton: true, showScrollButton: true } | ||
| }) | ||
| await wrapper.find('[key="new-chat"]').trigger('click') | ||
| await wrapper.find('[key="scroll-bottom"]').trigger('click') | ||
|
|
||
| expect(wrapper.emitted().clean).toBeTruthy() | ||
| expect(wrapper.emitted()['scroll-to-bottom']).toBeTruthy() | ||
| }) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| import { ref } from 'vue' | ||
| import { describe, it, expect } from 'vitest' | ||
| import { useArtifactContext } from '@/composables/useArtifactContext' | ||
|
|
||
| describe('useArtifactContext', () => { | ||
| it('builds a stable context key from thread/message/artifact', () => { | ||
| const art = ref<any>({ id: 'art-1' }) | ||
| const threadId = ref<string|null>('t-1') | ||
| const messageId = ref<string|null>('m-1') | ||
| const { componentKey, activeArtifactContext } = useArtifactContext(art, threadId, messageId) | ||
|
|
||
| expect(activeArtifactContext.value).toBe('t-1:m-1:art-1') | ||
| const prevKey = componentKey.value | ||
|
|
||
| // same artifact id but different message produces new key | ||
| messageId.value = 'm-2' | ||
| expect(activeArtifactContext.value).toBe('t-1:m-2:art-1') | ||
| expect(componentKey.value).toBeGreaterThan(prevKey) | ||
|
|
||
| // null artifact resets key | ||
| art.value = null | ||
| expect(activeArtifactContext.value).toBeNull() | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| import { describe, it, expect, vi, beforeEach } from 'vitest' | ||
| import { useArtifactExport } from '@/composables/useArtifactExport' | ||
|
|
||
| vi.mock('mermaid', () => ({ | ||
| default: {}, | ||
| render: vi.fn().mockResolvedValue({ svg: '<svg></svg>' }) | ||
| })) | ||
|
|
||
| // jsdom helpers for blob download | ||
| beforeEach(() => { | ||
| // @ts-ignore | ||
| global.URL.createObjectURL = vi.fn(() => 'blob://x') | ||
| // @ts-ignore | ||
| global.URL.revokeObjectURL = vi.fn() | ||
| vi.spyOn(document.body, 'appendChild') | ||
| vi.spyOn(document.body, 'removeChild') | ||
| }) | ||
|
|
||
| const mkArtifact = (type: string, content: string, title='artifact') => ({ type, content, title } as any) | ||
|
|
||
| describe('useArtifactExport', () => { | ||
| it('exports mermaid as SVG via download', async () => { | ||
| const capture = vi.fn() | ||
| const api = useArtifactExport(capture) | ||
| await api.exportSVG(mkArtifact('application/vnd.ant.mermaid','graph TD; A-->B;','m1')) | ||
| expect(document.body.appendChild).toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it('throws for invalid svg content', async () => { | ||
| const api = useArtifactExport(vi.fn()) | ||
| await expect(api.exportSVG(mkArtifact('image/svg+xml','NOT_SVG','bad'))) | ||
| .rejects.toBeTruthy() | ||
| }) | ||
|
|
||
| it('exports code and copies content', async () => { | ||
| const api = useArtifactExport(vi.fn()) | ||
| // export code should trigger download | ||
| await api.exportCode(mkArtifact('text/markdown','# hello','readme')) | ||
| expect(document.body.appendChild).toHaveBeenCalled() | ||
|
|
||
| // copy | ||
| Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(void 0) } }) | ||
| await api.copyContent(mkArtifact('application/vnd.ant.code','hello world')) | ||
| expect(navigator.clipboard.writeText).toHaveBeenCalledWith('hello world') | ||
| }) | ||
|
|
||
| it('copyAsImage delegates to captureAndCopy with proper config', async () => { | ||
| const capture = vi.fn().mockResolvedValue(true) | ||
| const api = useArtifactExport(capture) | ||
| const ok = await api.copyAsImage(mkArtifact('text/plain','content'), { | ||
| isDark: false, | ||
| version: '1.0.0', | ||
| texts: { brand: 'DeepChat', tip: 'tip' } | ||
| }) | ||
| expect(ok).toBe(true) | ||
| expect(capture).toHaveBeenCalled() | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| import { ref } from 'vue' | ||
| import { describe, it, expect } from 'vitest' | ||
| import { useArtifactViewMode } from '@/composables/useArtifactViewMode' | ||
|
|
||
| const mkArtifact = (id: string, type: string, status: 'loaded'|'loading'|'error'='loaded') => ({ | ||
| id, type, status | ||
| }) as any | ||
|
|
||
| describe('useArtifactViewMode', () => { | ||
| it('auto-previews for certain types and reacts to changes', () => { | ||
| const artifact = ref<any>(mkArtifact('a1','application/vnd.ant.mermaid')) | ||
| const { isPreview, setPreview } = useArtifactViewMode(artifact) | ||
| expect(isPreview.value).toBe(true) | ||
|
|
||
| // user override sticks | ||
| setPreview(false) | ||
| expect(isPreview.value).toBe(false) | ||
|
|
||
| // new artifact resets preference and recomputes | ||
| artifact.value = mkArtifact('a2','image/svg+xml') | ||
| expect(isPreview.value).toBe(true) | ||
|
|
||
| // non-preview types default to code view | ||
| artifact.value = mkArtifact('a3','text/markdown') | ||
| expect(isPreview.value).toBe(false) | ||
| }) | ||
|
|
||
| it('depends on status: not preview until loaded', () => { | ||
| const artifact = ref<any>(mkArtifact('b1','image/svg+xml','loading')) | ||
| const vm = useArtifactViewMode(artifact) | ||
| expect(vm.isPreview.value).toBe(false) | ||
| artifact.value = mkArtifact('b1','image/svg+xml','loaded') | ||
| expect(vm.isPreview.value).toBe(true) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import { describe, it, expect } from 'vitest' | ||
| import { useDragAndDrop } from '@/components/prompt-input/composables/useDragAndDrop' | ||
|
|
||
| describe('useDragAndDrop', () => { | ||
| it('tracks drag state with proper counters and timers', () => { | ||
| const api = useDragAndDrop() | ||
| const evt = { dataTransfer: { types: ['Files'] } } as any as DragEvent | ||
| api.handleDragEnter(evt) | ||
| expect(api.isDragging.value).toBe(true) | ||
| api.handleDragOver() | ||
| api.handleDragLeave() | ||
| // we cannot await timer here; call reset directly | ||
| api.resetDragState() | ||
| expect(api.isDragging.value).toBe(false) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| import { describe, it, expect, vi } from 'vitest' | ||
| import { useInputHistory } from '@/components/prompt-input/composables/useInputHistory' | ||
|
|
||
| // mock search history module with a simple ring buffer | ||
| let entries = ['alpha','beta','gamma'] | ||
| let idx = entries.length | ||
| vi.mock('@/lib/searchHistory', () => ({ | ||
| searchHistory: { | ||
| addSearch: (v: string) => { entries.push(v); idx = entries.length }, | ||
| resetIndex: () => { idx = entries.length }, | ||
| getPrevious: () => { if (idx>0) { idx--; return entries[idx] } return null }, | ||
| getNext: () => { if (idx<entries.length-1) { idx++; return entries[idx] } return null } | ||
| } | ||
| })) | ||
|
|
||
| const fakeEditor = () => ({ | ||
| commands: { | ||
| setContent: vi.fn() | ||
| }, | ||
| view: { | ||
| updateState: vi.fn() | ||
| } | ||
| }) as any | ||
|
|
||
| describe('useInputHistory', () => { | ||
| it('manages placeholder and confirms fill', () => { | ||
| const t = (k: string) => k | ||
| const ed = fakeEditor() | ||
| const api = useInputHistory(ed, t) | ||
|
|
||
| expect(api.dynamicPlaceholder.value).toBe('chat.input.placeholder') | ||
| api.setHistoryPlaceholder('recent text') | ||
| expect(api.dynamicPlaceholder.value.includes('recent text')).toBe(true) | ||
|
|
||
| const ok = api.confirmHistoryPlaceholder() | ||
| expect(ok).toBe(true) | ||
| expect(ed.commands.setContent).toHaveBeenCalledWith('recent text') | ||
| }) | ||
|
|
||
| it('navigates entries with arrows only when empty', () => { | ||
| const t = (k: string) => k | ||
| const ed = fakeEditor() | ||
| const api = useInputHistory(ed, t) | ||
|
|
||
| let handled = api.handleArrowKey('up','') | ||
| expect(handled).toBe(true) | ||
| handled = api.handleArrowKey('down','') | ||
| expect(handled).toBe(true) | ||
|
|
||
| // content not empty -> do nothing | ||
| expect(api.handleArrowKey('up','has text')).toBe(false) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| import { describe, it, expect, vi, beforeEach } from 'vitest' | ||
| import { useMessageCapture } from '@/composables/message/useMessageCapture' | ||
|
|
||
| vi.mock('@/composables/usePageCapture', () => ({ | ||
| usePageCapture: () => ({ | ||
| isCapturing: { value: false }, | ||
| captureAndCopy: vi.fn().mockResolvedValue(true) | ||
| }) | ||
| })) | ||
| vi.mock('@/composables/usePresenter', () => ({ | ||
| usePresenter: (name: string) => { | ||
| if (name === 'devicePresenter') { | ||
| return { getAppVersion: vi.fn().mockResolvedValue('1.0.0') } | ||
| } | ||
| return {} | ||
| } | ||
| })) | ||
| vi.mock('vue-i18n', () => ({ | ||
| useI18n: () => ({ t: (k: string) => k }) | ||
| })) | ||
|
|
||
| describe('useMessageCapture', () => { | ||
| beforeEach(() => { | ||
| // container | ||
| const container = document.createElement('div') | ||
| container.className = 'message-list-container' | ||
| document.body.appendChild(container) | ||
| }) | ||
|
Comment on lines
+23
to
+28
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add cleanup in afterEach to prevent DOM pollution across tests. The Add an +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
-import { describe, it, expect, vi, beforeEach } from 'vitest' describe('useMessageCapture', () => {
+ let container: HTMLElement
+
beforeEach(() => {
- const container = document.createElement('div')
+ container = document.createElement('div')
container.className = 'message-list-container'
document.body.appendChild(container)
})
+
+ afterEach(() => {
+ if (container && document.body.contains(container)) {
+ document.body.removeChild(container)
+ }
+ })🤖 Prompt for AI Agents |
||
|
|
||
| it('captures assistant and user block area', async () => { | ||
| const api = useMessageCapture() | ||
|
|
||
| // prepare elements | ||
| const user = document.createElement('div') | ||
| user.setAttribute('data-message-id','u1') | ||
| const asst = document.createElement('div') | ||
| asst.setAttribute('data-message-id','a1') | ||
| document.body.appendChild(user) | ||
| document.body.appendChild(asst) | ||
|
|
||
| const ok = await api.captureMessage({ | ||
| messageId: 'a1', | ||
| parentId: 'u1', | ||
| modelInfo: { model_name: 'm', model_provider: 'p' } | ||
| }) | ||
| expect(ok).toBe(true) | ||
|
|
||
| document.body.removeChild(user) | ||
| document.body.removeChild(asst) | ||
| }) | ||
|
|
||
| it('captures from top to current', async () => { | ||
| const api = useMessageCapture() | ||
| const first = document.createElement('div') | ||
| first.setAttribute('data-message-id','x-first') | ||
| const current = document.createElement('div') | ||
| current.setAttribute('data-message-id','x-current') | ||
| document.body.appendChild(first) | ||
| document.body.appendChild(current) | ||
|
|
||
| const ok = await api.captureMessage({ messageId: 'x-current', fromTop: true }) | ||
| expect(ok).toBe(true) | ||
|
|
||
| document.body.removeChild(first) | ||
| document.body.removeChild(current) | ||
| }) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| import { describe, it, expect, beforeEach } from 'vitest' | ||
| import { useMessageScroll } from '@/composables/message/useMessageScroll' | ||
|
|
||
| class IO { | ||
| cb: any | ||
| constructor(cb: any) { this.cb = cb } | ||
| observe() { this.cb([{ isIntersecting: false }]) } | ||
| unobserve() {} | ||
| disconnect() {} | ||
| } | ||
| beforeEach(() => { | ||
| // @ts-ignore | ||
| global.IntersectionObserver = IO as any | ||
| }) | ||
|
|
||
| describe('useMessageScroll', () => { | ||
| it('updates scroll info and threshold, supports scrolling to bottom/message', async () => { | ||
| const api = useMessageScroll() | ||
| const container = document.createElement('div') | ||
| const anchor = document.createElement('div') | ||
| Object.defineProperty(container, 'clientHeight', { value: 500 }) | ||
| Object.defineProperty(container, 'scrollHeight', { value: 2000, configurable: true }) | ||
| Object.defineProperty(container, 'scrollTop', { | ||
| get() { return this._st || 0 }, | ||
| set(v) { this._st = v } | ||
| }) | ||
|
|
||
| api.messagesContainer.value = container as any | ||
| api.scrollAnchor.value = anchor as any | ||
|
|
||
| api.setupScrollObserver() | ||
| expect(api.aboveThreshold.value).toBe(true) | ||
|
|
||
| api.scrollToBottom(false) | ||
| // allow nextTick chain to run | ||
| await Promise.resolve() | ||
| expect((container as any)._st).toBe(1500) | ||
|
|
||
| // add a target to scroll to | ||
| const msg = document.createElement('div') | ||
| msg.setAttribute('data-message-id','m-1') | ||
| document.body.appendChild(msg) | ||
| api.scrollToMessage('m-1') | ||
| await Promise.resolve() | ||
| document.body.removeChild(msg) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| import { ref } from 'vue' | ||
| import { describe, it, expect } from 'vitest' | ||
| import { useModelCapabilities } from '@/composables/useModelCapabilities' | ||
|
|
||
| describe('useModelCapabilities', () => { | ||
| it('fetches capabilities and resets when ids missing', async () => { | ||
| const providerId = ref<string|undefined>('openai') | ||
| const modelId = ref<string|undefined>('gpt-4') | ||
| const mockPresenter: any = { | ||
| supportsReasoningCapability: vi.fn().mockResolvedValue(true), | ||
| getThinkingBudgetRange: vi.fn().mockResolvedValue({ min: 100, max: 200 }), | ||
| supportsSearchCapability: vi.fn().mockResolvedValue(true), | ||
| getSearchDefaults: vi.fn().mockResolvedValue({ default: true, forced: false, strategy: 'turbo' }) | ||
| } | ||
|
|
||
| const api = useModelCapabilities({ providerId, modelId, configPresenter: mockPresenter }) | ||
| // initial immediate fetch occurs | ||
| await Promise.resolve() | ||
| expect(api.supportsReasoning.value).toBe(true) | ||
| expect(api.budgetRange.value?.max).toBe(200) | ||
| expect(api.supportsSearch.value).toBe(true) | ||
| expect(api.searchDefaults.value?.strategy).toBe('turbo') | ||
|
|
||
| // reset path | ||
| providerId.value = undefined | ||
| await Promise.resolve() | ||
| expect(api.supportsReasoning.value).toBeNull() | ||
| expect(api.budgetRange.value).toBeNull() | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
The
keyattribute selector will likely fail.Vue's
keyattribute is used for internal list reconciliation and is not rendered to the DOM. The selectors[key="new-chat"]and[key="scroll-bottom"]won't match any elements unless the component explicitly renders akeyHTML attribute (which would be non-standard).Consider using
data-testidattributes instead for test-friendly element selection.Apply this diff to use proper test selectors:
Ensure the MessageActionButtons component includes corresponding
data-testidattributes on the clickable elements.Run this script to verify how the component renders these elements:
🏁 Script executed:
Length of output: 27
I need to search for the MessageActionButtons component file and examine its actual implementation.
Length of output: 230
🏁 Script executed:
Length of output: 260
🏁 Script executed:
Length of output: 1444
🏁 Script executed:
Length of output: 118
I need to verify how Vue Test Utils handles key attribute selectors, as this is critical to confirming whether the original review comment is correct.
💡 Result:
Short answer: you can't select by Vue "key" because key is a virtual-DOM-only attribute (not exposed on the rendered DOM or component API). Instead use one of these approaches:
const items = wrapper.findAllComponents(MyItem);
const item = items.wrappers.find(w => w.props('id') === '42');
References: Vue Test Utils docs (selectors / find / findAll) and community answer explaining key is not queryable. [1][2]
Sources
[1] Stack Overflow — "Can a component be found using its key?" (explains key not accessible).
[2] Vue Test Utils API / selectors (find, findAll, attributes).
The review comment is correct about the problem, but the solution is incomplete—both the component and test files need updates.
The web search confirms Vue's
keyattribute is virtual-DOM-only and cannot be queried via CSS selectors like[key="..."]. The test selectors will fail.However, the fix requires changes to both files:
[key="..."]→[data-testid="..."](lines 10-11 in test file)data-testidattributes to the Button elements in the component template (lines 18 and 29 insrc/renderer/src/components/message/MessageActionButtons.vue)The component file currently only has
keydirectives without correspondingdata-testidattributes, so applying only the test changes would not resolve the issue.🤖 Prompt for AI Agents