Files
ComfyUI_frontend/src/components/searchbox/v2/NodeSearchContent.test.ts
Alexander Brown f90d6cf607 test: migrate 132 test files from @vue/test-utils to @testing-library/vue (#10965)
## Summary

Migrate 132 test files from `@vue/test-utils` (VTU) to
`@testing-library/vue` (VTL) with `@testing-library/user-event`,
adopting user-centric behavioral testing patterns across the codebase.

## Changes

- **What**: Systematic migration of component/unit tests from VTU's
`mount`/`wrapper` API to VTL's `render`/`screen`/`userEvent` API across
132 files in `src/`
- **Breaking**: None — test-only changes, no production code affected

### Migration breakdown

| Batch | Files | Description |
|-------|-------|-------------|
| 1 | 19 | Simple render/assert tests |
| 2A | 16 | Interactive tests with user events |
| 2B-1 | 14 | Interactive tests (continued) |
| 2B-2 | 32 | Interactive tests (continued) |
| 3A–3E | 51 | Complex tests (stores, composables, heavy mocking) |
| Lint fix | 7 | `await` on `fireEvent` calls for `no-floating-promises`
|
| Review fixes | 15 | Address CodeRabbit feedback (3 rounds) |

### Review feedback addressed

- Removed class-based assertions (`text-ellipsis`, `pr-3`, `.pi-save`,
`.skeleton`, `.bg-black\/15`, Tailwind utilities) in favor of
behavioral/accessible queries
- Added null guards before `querySelector` casts
- Added `expect(roots).toHaveLength(N)` guards before indexed NodeList
access
- Wrapped fake timer tests in `try/finally` for guaranteed cleanup
- Split double-render tests into focused single-render tests
- Replaced CSS class selectors with
`screen.getByText`/`screen.getByRole` queries
- Updated stubs to use semantic `role`/`aria-label` instead of CSS
classes
- Consolidated redundant edge-case tests
- Removed manual `document.body.appendChild` in favor of VTL container
management
- Used distinct mock return values to verify command wiring

### VTU holdouts (2 files)

These files intentionally retain `@vue/test-utils` because their
components use `<script setup>` without `defineExpose`, making internal
computed properties and methods inaccessible via VTL:

1. **`NodeWidgets.test.ts`** — partial VTU for `vm.processedWidgets`
2. **`WidgetSelectDropdown.test.ts`** — full VTU for heavy
`wrapper.vm.*` access

## Follow-up

Deferred items (`ComponentProps` typing, camelCase listener props)
tracked in #10966.

## Review Focus

- Test correctness: all migrated tests preserve original behavioral
coverage
- VTL idioms: proper use of `screen` queries, `userEvent`, and
accessibility-based selectors
- The 2 VTU holdout files are intentional, not oversights

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10965-test-migrate-132-test-files-from-vue-test-utils-to-testing-library-vue-33c6d73d36508199a6a7e513cf5d8296)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-04-08 19:21:42 -07:00

736 lines
22 KiB
TypeScript

import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import NodeSearchContent from '@/components/searchbox/v2/NodeSearchContent.vue'
import {
createMockNodeDef,
setupTestPinia,
testI18n
} from '@/components/searchbox/v2/__test__/testUtils'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn((key: string) => {
if (key === 'Comfy.NodeLibrary.Bookmarks.V2') return []
if (key === 'Comfy.NodeLibrary.BookmarksCustomization') return {}
return undefined
}),
set: vi.fn()
}))
}))
describe('NodeSearchContent', () => {
beforeEach(() => {
setupTestPinia()
vi.restoreAllMocks()
})
async function renderComponent(props = {}) {
const user = userEvent.setup()
const onAddNode = vi.fn()
const onHoverNode = vi.fn()
const onRemoveFilter = vi.fn()
const onAddFilter = vi.fn()
render(NodeSearchContent, {
props: {
filters: [],
onAddNode,
onHoverNode,
onRemoveFilter,
onAddFilter,
...props
},
global: {
plugins: [testI18n],
stubs: {
NodeSearchListItem: {
template:
'<div class="node-item" data-testid="node-item">{{ nodeDef.display_name }}</div>',
props: [
'nodeDef',
'currentQuery',
'showDescription',
'showSourceBadge',
'hideBookmarkIcon'
]
}
}
}
})
await nextTick()
return { user, onAddNode, onHoverNode, onRemoveFilter, onAddFilter }
}
async function setupFavorites(
nodes: Parameters<typeof createMockNodeDef>[0][]
) {
useNodeDefStore().updateNodeDefs(nodes.map(createMockNodeDef))
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockReturnValue(true)
const result = await renderComponent()
await result.user.click(screen.getByTestId('category-favorites'))
await nextTick()
return result
}
describe('category selection', () => {
it('should show top nodes when Most relevant is selected', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'FrequentNode',
display_name: 'Frequent Node'
}),
createMockNodeDef({ name: 'RareNode', display_name: 'Rare Node' })
])
vi.spyOn(useNodeFrequencyStore(), 'topNodeDefs', 'get').mockReturnValue([
useNodeDefStore().nodeDefsByName['FrequentNode']
])
await renderComponent()
const items = screen.getAllByTestId('node-item')
expect(items).toHaveLength(1)
expect(items[0]).toHaveTextContent('Frequent Node')
})
it('should show only bookmarked nodes when Favorites is selected', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'BookmarkedNode',
display_name: 'Bookmarked Node'
}),
createMockNodeDef({
name: 'RegularNode',
display_name: 'Regular Node'
})
])
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockImplementation(
(node: ComfyNodeDefImpl) => node.name === 'BookmarkedNode'
)
const { user } = await renderComponent()
await user.click(screen.getByTestId('category-favorites'))
await nextTick()
const items = screen.getAllByTestId('node-item')
expect(items).toHaveLength(1)
expect(items[0]).toHaveTextContent('Bookmarked')
})
it('should show empty state when no bookmarks exist', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', display_name: 'Node One' })
])
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockReturnValue(false)
const { user } = await renderComponent()
await user.click(screen.getByTestId('category-favorites'))
await nextTick()
expect(screen.getByText('No results')).toBeInTheDocument()
})
it('should show only CustomNodes when Extensions is selected', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'CoreNode',
display_name: 'Core Node',
python_module: 'nodes'
}),
createMockNodeDef({
name: 'CustomNode',
display_name: 'Custom Node',
python_module: 'custom_nodes.my_extension'
})
])
await nextTick()
expect(useNodeDefStore().nodeDefsByName['CoreNode'].nodeSource.type).toBe(
NodeSourceType.Core
)
expect(
useNodeDefStore().nodeDefsByName['CustomNode'].nodeSource.type
).toBe(NodeSourceType.CustomNodes)
const { user } = await renderComponent()
await user.click(screen.getByTestId('category-extensions'))
await nextTick()
const items = screen.getAllByTestId('node-item')
expect(items).toHaveLength(1)
expect(items[0]).toHaveTextContent('Custom Node')
})
it('should hide Essentials category when no essential nodes exist', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'RegularNode',
display_name: 'Regular Node'
})
])
await renderComponent()
expect(
screen.queryByTestId('category-essentials')
).not.toBeInTheDocument()
})
it('should show only essential nodes when Essentials is selected', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'EssentialNode',
display_name: 'Essential Node',
essentials_category: 'basic'
}),
createMockNodeDef({
name: 'RegularNode',
display_name: 'Regular Node'
})
])
await nextTick()
const { user } = await renderComponent()
await user.click(screen.getByTestId('category-essentials'))
await nextTick()
const items = screen.getAllByTestId('node-item')
expect(items).toHaveLength(1)
expect(items[0]).toHaveTextContent('Essential Node')
})
it('should include subcategory nodes when parent category is selected', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'KSampler',
display_name: 'KSampler',
category: 'sampling'
}),
createMockNodeDef({
name: 'LoadCheckpoint',
display_name: 'Load Checkpoint',
category: 'loaders'
}),
createMockNodeDef({
name: 'KSamplerAdvanced',
display_name: 'KSampler Advanced',
category: 'sampling/advanced'
})
])
const { user } = await renderComponent()
await user.click(screen.getByTestId('category-sampling'))
await nextTick()
const texts = screen.getAllByTestId('node-item').map((i) => i.textContent)
expect(texts).toHaveLength(2)
expect(texts).toContain('KSampler')
expect(texts).toContain('KSampler Advanced')
})
})
describe('search and category interaction', () => {
it('should override category to most-relevant when search query is active', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'KSampler',
display_name: 'KSampler',
category: 'sampling'
}),
createMockNodeDef({
name: 'LoadCheckpoint',
display_name: 'Load Checkpoint',
category: 'loaders'
})
])
const { user } = await renderComponent()
await user.click(screen.getByTestId('category-sampling'))
await nextTick()
expect(screen.getAllByTestId('node-item')).toHaveLength(1)
const input = screen.getByRole('combobox')
await user.type(input, 'Load')
await nextTick()
const texts = screen.getAllByTestId('node-item').map((i) => i.textContent)
expect(texts.some((t) => t?.includes('Load Checkpoint'))).toBe(true)
})
it('should clear search query when category changes', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'TestNode', display_name: 'Test Node' })
])
const { user } = await renderComponent()
const input = screen.getByRole('combobox')
await user.type(input, 'test query')
await nextTick()
expect(input).toHaveValue('test query')
await user.click(screen.getByTestId('category-favorites'))
await nextTick()
expect(input).toHaveValue('')
})
it('should reset selected index when search query changes', async () => {
const { user } = await setupFavorites([
{ name: 'Node1', display_name: 'Node One' },
{ name: 'Node2', display_name: 'Node Two' }
])
const input = screen.getByRole('combobox')
await user.click(input)
await user.keyboard('{ArrowDown}')
await nextTick()
expect(screen.getAllByTestId('result-item')[1]).toHaveAttribute(
'aria-selected',
'true'
)
await user.type(input, 'Node')
await nextTick()
expect(screen.getAllByTestId('result-item')[0]).toHaveAttribute(
'aria-selected',
'true'
)
})
it('should reset selected index when category changes', async () => {
const { user } = await setupFavorites([
{ name: 'Node1', display_name: 'Node One' },
{ name: 'Node2', display_name: 'Node Two' }
])
await user.click(screen.getByRole('combobox'))
await user.keyboard('{ArrowDown}')
await nextTick()
await user.click(screen.getByTestId('category-most-relevant'))
await nextTick()
await user.click(screen.getByTestId('category-favorites'))
await nextTick()
expect(screen.getAllByTestId('result-item')[0]).toHaveAttribute(
'aria-selected',
'true'
)
})
})
describe('keyboard and mouse interaction', () => {
it('should navigate results with ArrowDown/ArrowUp and clamp to bounds', async () => {
const { user } = await setupFavorites([
{ name: 'Node1', display_name: 'Node One' },
{ name: 'Node2', display_name: 'Node Two' },
{ name: 'Node3', display_name: 'Node Three' }
])
await user.click(screen.getByRole('combobox'))
const selectedIndex = () =>
screen
.getAllByTestId('result-item')
.findIndex((r) => r.getAttribute('aria-selected') === 'true')
expect(selectedIndex()).toBe(0)
await user.keyboard('{ArrowDown}')
await nextTick()
expect(selectedIndex()).toBe(1)
await user.keyboard('{ArrowDown}')
await nextTick()
expect(selectedIndex()).toBe(2)
await user.keyboard('{ArrowUp}')
await nextTick()
expect(selectedIndex()).toBe(1)
// Navigate to first, then try going above — should clamp
await user.keyboard('{ArrowUp}')
await nextTick()
expect(selectedIndex()).toBe(0)
await user.keyboard('{ArrowUp}')
await nextTick()
expect(selectedIndex()).toBe(0)
})
it('should select current result with Enter key', async () => {
const { user, onAddNode } = await setupFavorites([
{ name: 'TestNode', display_name: 'Test Node' }
])
await user.click(screen.getByRole('combobox'))
await user.keyboard('{Enter}')
await nextTick()
expect(onAddNode).toHaveBeenCalledWith(
expect.objectContaining({ name: 'TestNode' })
)
})
it('should select item on hover', async () => {
const { user } = await setupFavorites([
{ name: 'Node1', display_name: 'Node One' },
{ name: 'Node2', display_name: 'Node Two' }
])
const results = screen.getAllByTestId('result-item')
await user.hover(results[1])
await nextTick()
expect(results[1]).toHaveAttribute('aria-selected', 'true')
})
it('should add node on click', async () => {
const { user, onAddNode } = await setupFavorites([
{ name: 'TestNode', display_name: 'Test Node' }
])
await user.click(screen.getAllByTestId('result-item')[0])
await nextTick()
expect(onAddNode).toHaveBeenCalledWith(
expect.objectContaining({ name: 'TestNode' }),
expect.any(PointerEvent)
)
})
})
describe('hoverNode emission', () => {
it('should emit hoverNode with the currently selected node', async () => {
const { onHoverNode } = await setupFavorites([
{ name: 'HoverNode', display_name: 'Hover Node' }
])
const calls = onHoverNode.mock.calls
expect(calls[calls.length - 1][0]).toMatchObject({
name: 'HoverNode'
})
})
it('should emit null hoverNode when no results', async () => {
const { user, onHoverNode } = await renderComponent()
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockReturnValue(false)
await user.click(screen.getByTestId('category-favorites'))
await nextTick()
const calls = onHoverNode.mock.calls
expect(calls[calls.length - 1][0]).toBeNull()
})
})
describe('filter integration', () => {
it('should display active filters in the input area', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'ImageNode',
display_name: 'Image Node',
input: { required: { image: ['IMAGE', {}] } }
})
])
await renderComponent({
filters: [
{
filterDef: useNodeDefStore().nodeSearchService.inputTypeFilter,
value: 'IMAGE'
}
]
})
expect(screen.getAllByTestId('filter-chip').length).toBeGreaterThan(0)
})
})
describe('chip removal', () => {
function createFilters(count: number) {
const types = ['IMAGE', 'LATENT', 'MODEL']
useNodeDefStore().updateNodeDefs(
types.slice(0, count).map((type) =>
createMockNodeDef({
name: `${type}Node`,
display_name: `${type} Node`,
input: {
required: { [type.toLowerCase()]: [type, {}] }
}
})
)
)
return types.slice(0, count).map((type) => ({
filterDef: useNodeDefStore().nodeSearchService.inputTypeFilter,
value: type
}))
}
it('should emit removeFilter on backspace', async () => {
const filters = createFilters(1)
const { user, onRemoveFilter } = await renderComponent({ filters })
await user.click(screen.getByRole('combobox'))
await user.keyboard('{Backspace}')
await nextTick()
await user.keyboard('{Backspace}')
await nextTick()
expect(onRemoveFilter).toHaveBeenCalledTimes(1)
expect(onRemoveFilter).toHaveBeenCalledWith(
expect.objectContaining({ value: 'IMAGE' })
)
})
it('should not interact with chips when no filters exist', async () => {
const { user, onRemoveFilter } = await renderComponent({ filters: [] })
await user.click(screen.getByRole('combobox'))
await user.keyboard('{Backspace}')
await nextTick()
expect(onRemoveFilter).not.toHaveBeenCalled()
})
it('should remove chip when clicking its delete button', async () => {
const filters = createFilters(1)
const { user, onRemoveFilter } = await renderComponent({ filters })
await user.click(screen.getByTestId('chip-delete'))
await nextTick()
expect(onRemoveFilter).toHaveBeenCalledTimes(1)
expect(onRemoveFilter).toHaveBeenCalledWith(
expect.objectContaining({ value: 'IMAGE' })
)
})
})
describe('filter selection mode', () => {
function setupNodesWithTypes() {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'ImageNode',
display_name: 'Image Node',
input: { required: { image: ['IMAGE', {}] } },
output: ['IMAGE']
}),
createMockNodeDef({
name: 'LatentNode',
display_name: 'Latent Node',
input: { required: { latent: ['LATENT', {}] } },
output: ['LATENT']
}),
createMockNodeDef({
name: 'ModelNode',
display_name: 'Model Node',
input: { required: { model: ['MODEL', {}] } },
output: ['MODEL']
})
])
}
function findFilterBarButton(label: string) {
return screen.getAllByRole('button').find((b) => b.textContent === label)
}
async function enterFilterMode(user: ReturnType<typeof userEvent.setup>) {
const btn = findFilterBarButton('Input')
expect(btn).toBeDefined()
await user.click(btn!)
await nextTick()
}
function hasSidebar() {
return screen.queryByTestId('category-most-relevant') !== null
}
it('should enter filter mode when a filter chip is selected', async () => {
setupNodesWithTypes()
const { user } = await renderComponent()
expect(hasSidebar()).toBe(true)
await enterFilterMode(user)
expect(hasSidebar()).toBe(false)
expect(screen.getAllByTestId('filter-option').length).toBeGreaterThan(0)
})
it('should show available filter options sorted alphabetically', async () => {
setupNodesWithTypes()
const { user } = await renderComponent()
await enterFilterMode(user)
const texts = screen.getAllByTestId('filter-option').map(
(o) =>
/* eslint-disable testing-library/no-node-access */
(o.querySelectorAll('span')[0] as HTMLElement)?.textContent
?.replace(/^[•·]\s*/, '')
.trim() ?? ''
/* eslint-enable testing-library/no-node-access */
)
expect(texts).toContain('IMAGE')
expect(texts).toContain('LATENT')
expect(texts).toContain('MODEL')
expect(texts).toEqual([...texts].sort())
})
it('should filter options when typing in filter mode', async () => {
setupNodesWithTypes()
const { user } = await renderComponent()
await enterFilterMode(user)
await user.type(screen.getByRole('combobox'), 'IMAGE')
await nextTick()
const texts = screen.getAllByTestId('filter-option').map(
(o) =>
/* eslint-disable testing-library/no-node-access */
(o.querySelectorAll('span')[0] as HTMLElement)?.textContent
?.replace(/^[•·]\s*/, '')
.trim() ?? ''
/* eslint-enable testing-library/no-node-access */
)
expect(texts).toContain('IMAGE')
expect(texts).not.toContain('MODEL')
})
it('should show no results when filter query has no matches', async () => {
setupNodesWithTypes()
const { user } = await renderComponent()
await enterFilterMode(user)
await user.type(screen.getByRole('combobox'), 'NONEXISTENT_TYPE')
await nextTick()
expect(screen.getByText('No results')).toBeInTheDocument()
})
it('should emit addFilter when a filter option is clicked', async () => {
setupNodesWithTypes()
const { user, onAddFilter } = await renderComponent()
await enterFilterMode(user)
const imageOption = screen
.getAllByTestId('filter-option')
.find((o) => o.textContent?.includes('IMAGE'))
await user.click(imageOption!)
await nextTick()
expect(onAddFilter).toHaveBeenCalledWith(
expect.objectContaining({
filterDef: expect.objectContaining({ id: 'input' }),
value: 'IMAGE'
})
)
})
it('should exit filter mode after applying a filter', async () => {
setupNodesWithTypes()
const { user } = await renderComponent()
await enterFilterMode(user)
await user.click(screen.getAllByTestId('filter-option')[0])
await nextTick()
await nextTick()
expect(hasSidebar()).toBe(true)
})
it('should emit addFilter when Enter is pressed on selected option', async () => {
setupNodesWithTypes()
const { user, onAddFilter } = await renderComponent()
await enterFilterMode(user)
await user.click(screen.getByRole('combobox'))
await user.keyboard('{Enter}')
await nextTick()
expect(onAddFilter).toHaveBeenCalledWith(
expect.objectContaining({
filterDef: expect.objectContaining({ id: 'input' }),
value: 'IMAGE'
})
)
})
it('should navigate filter options with ArrowDown/ArrowUp', async () => {
setupNodesWithTypes()
const { user } = await renderComponent()
await enterFilterMode(user)
await user.click(screen.getByRole('combobox'))
expect(screen.getAllByTestId('filter-option')[0]).toHaveAttribute(
'aria-selected',
'true'
)
await user.keyboard('{ArrowDown}')
await nextTick()
expect(screen.getAllByTestId('filter-option')[1]).toHaveAttribute(
'aria-selected',
'true'
)
await user.keyboard('{ArrowUp}')
await nextTick()
expect(screen.getAllByTestId('filter-option')[0]).toHaveAttribute(
'aria-selected',
'true'
)
})
it('should toggle filter mode off when same chip is clicked again', async () => {
setupNodesWithTypes()
const { user } = await renderComponent()
await enterFilterMode(user)
await user.click(findFilterBarButton('Input')!)
await nextTick()
await nextTick()
expect(hasSidebar()).toBe(true)
})
it('should reset filter query when re-entering filter mode', async () => {
setupNodesWithTypes()
const { user } = await renderComponent()
await enterFilterMode(user)
const input = screen.getByRole('combobox')
await user.type(input, 'IMAGE')
await nextTick()
await user.click(findFilterBarButton('Input')!)
await nextTick()
await nextTick()
await enterFilterMode(user)
expect(input).toHaveValue('')
})
it('should exit filter mode when cancel button is clicked', async () => {
setupNodesWithTypes()
const { user } = await renderComponent()
await enterFilterMode(user)
expect(hasSidebar()).toBe(false)
await user.click(screen.getByTestId('cancel-filter'))
await nextTick()
await nextTick()
expect(hasSidebar()).toBe(true)
})
})
})