Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions test/renderer/components/HTMLArtifact.test.ts
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')
})
})
16 changes: 16 additions & 0 deletions test/renderer/components/MessageActionButtons.test.ts
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')
Comment on lines +10 to +11
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

The key attribute selector will likely fail.

Vue's key attribute 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 a key HTML attribute (which would be non-standard).

Consider using data-testid attributes instead for test-friendly element selection.

Apply this diff to use proper test selectors:

-    await wrapper.find('[key="new-chat"]').trigger('click')
-    await wrapper.find('[key="scroll-bottom"]').trigger('click')
+    await wrapper.find('[data-testid="new-chat"]').trigger('click')
+    await wrapper.find('[data-testid="scroll-bottom"]').trigger('click')

Ensure the MessageActionButtons component includes corresponding data-testid attributes on the clickable elements.

Run this script to verify how the component renders these elements:


🏁 Script executed:

#!/bin/bash
# Check if MessageActionButtons.vue uses key as a DOM attribute or data-testid
rg -n -C3 'new-chat|scroll-bottom' --type=vue --type=ts

Length of output: 27


I need to search for the MessageActionButtons component file and examine its actual implementation.


---

🏁 Script executed:

```shell
#!/bin/bash
# Find the MessageActionButtons component file
fd -name 'MessageActionButtons*' --type f

Length of output: 230


🏁 Script executed:

#!/bin/bash
# Search for references to new-chat and scroll-bottom in test file
rg -n 'new-chat|scroll-bottom' test/renderer/components/MessageActionButtons.test.ts -A2 -B2

Length of output: 260


🏁 Script executed:

#!/bin/bash
# Search for MessageActionButtons component implementation
rg -n 'new-chat|scroll-bottom' --type=ts --type=js -A2 -B2 | head -100

Length of output: 1444


🏁 Script executed:

#!/bin/bash
# Look for data-testid or key attributes in component files
find . -name 'MessageActionButtons*' -type f | head -20

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.


---

🌐 Web query:

```web
Vue Test Utils find key attribute selector

💡 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:

  • Add a stable DOM attribute (data-test, id, class) and query that: wrapper.find('[data-test="item-42"]').
  • If selecting a child component in a v-for, use findAllComponents() and filter by a prop or other exposed value:
    const items = wrapper.findAllComponents(MyItem);
    const item = items.wrappers.find(w => w.props('id') === '42');
  • Or give the item a ref (or data-test) when you need direct access.

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 key attribute 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:

  1. Update test selectors: [key="..."][data-testid="..."] (lines 10-11 in test file)
  2. Add data-testid attributes to the Button elements in the component template (lines 18 and 29 in src/renderer/src/components/message/MessageActionButtons.vue)

The component file currently only has key directives without corresponding data-testid attributes, so applying only the test changes would not resolve the issue.

🤖 Prompt for AI Agents
In test/renderer/components/MessageActionButtons.test.ts around lines 10-11 and
src/renderer/src/components/message/MessageActionButtons.vue around lines 18 and
29: the test uses CSS selectors querying Vue-only key attributes ([key="..."])
which cannot be selected at runtime; update the test to use data-testid
selectors (e.g., [data-testid="new-chat"] and [data-testid="scroll-bottom"]) and
add matching data-testid attributes to the corresponding Button elements in the
component template at the indicated lines (keep the existing key attributes
intact). Ensure attribute names match exactly between test and component.


expect(wrapper.emitted().clean).toBeTruthy()
expect(wrapper.emitted()['scroll-to-bottom']).toBeTruthy()
})
})
24 changes: 24 additions & 0 deletions test/renderer/composables/useArtifactContext.test.ts
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()
})
})
58 changes: 58 additions & 0 deletions test/renderer/composables/useArtifactExport.test.ts
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()
})
})
35 changes: 35 additions & 0 deletions test/renderer/composables/useArtifactViewMode.test.ts
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)
})
})
16 changes: 16 additions & 0 deletions test/renderer/composables/useDragAndDrop.test.ts
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)
})
})
53 changes: 53 additions & 0 deletions test/renderer/composables/useInputHistory.test.ts
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)
})
})
67 changes: 67 additions & 0 deletions test/renderer/composables/useMessageCapture.test.ts
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
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add cleanup in afterEach to prevent DOM pollution across tests.

The beforeEach hook creates and appends a container element to document.body, but there's no corresponding cleanup. This will cause the container to accumulate across test runs, potentially causing memory leaks and test interference.

Add an afterEach hook to clean up:

+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
In test/renderer/composables/useMessageCapture.test.ts around lines 23 to 28,
the beforeEach creates and appends a .message-list-container but no cleanup is
run; add an afterEach that removes the appended container to avoid DOM pollution
across tests (e.g., query document.querySelector('.message-list-container') and
remove it if present, or reset document.body.innerHTML = ''), ensuring the DOM
is restored after each test.


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)
})
})
47 changes: 47 additions & 0 deletions test/renderer/composables/useMessageScroll.test.ts
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)
})
})
30 changes: 30 additions & 0 deletions test/renderer/composables/useModelCapabilities.test.ts
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()
})
})
Loading