Files
ComfyUI_frontend/src/components/searchbox/NodeSearchBoxPopover.test.ts
pythongosssss 517da289f6 feat: Search - add ghost node following setting and increase opacity (#11365)
## Summary

Adds setting to disable the node auto-follow cursor behavior when adding
nodes from the search, and increased the visibilty of Vue ghost nodes.

## Changes

- **What**: 
- add setting
- increase opacity
- add test

## Review Focus

<!-- Critical design decisions or edge cases that need attention -->

<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->

## Screenshots (if applicable)

Before  
<img width="452" height="517" alt="image"
src="https://github.com/user-attachments/assets/369c0d90-5352-482b-a1b3-36180bffb3ee"
/>

After  
<img width="440" height="536" alt="image"
src="https://github.com/user-attachments/assets/2066fdd4-6eb4-4bfb-ac7c-559fc99de57d"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11365-feat-Search-add-ghost-node-following-setting-and-increase-opacity-3466d73d3650811b9c27ed4cc930816d)
by [Unito](https://www.unito.io)
2026-04-28 22:02:33 +00:00

280 lines
8.6 KiB
TypeScript

import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, defineComponent, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
import type { Settings } from '@/schemas/apiSchema'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
import NodeSearchBoxPopover from './NodeSearchBoxPopover.vue'
const coreSettingsById = Object.fromEntries(CORE_SETTINGS.map((s) => [s.id, s]))
const { addNodeOnGraph } = vi.hoisted(() => ({
addNodeOnGraph: vi.fn()
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({
getCanvasCenter: vi.fn(() => [0, 0]),
addNodeOnGraph
})
}))
type EmitAddFilter = (
filter: FuseFilterWithValue<ComfyNodeDefImpl, string>
) => void
type EmitAddNode = (nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) => void
function createFilter(
id: string,
value: string
): FuseFilterWithValue<ComfyNodeDefImpl, string> {
return {
filterDef: { id } as FuseFilter<ComfyNodeDefImpl, string>,
value
}
}
describe('NodeSearchBoxPopover', () => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
function renderComponent(settings: Partial<Settings> = {}) {
let emitAddFilter: EmitAddFilter | null = null
let emitAddNodeV1: EmitAddNode | null = null
let emitAddNodeV2: EmitAddNode | null = null
const NodeSearchBoxStub = defineComponent({
name: 'NodeSearchBox',
props: {
filters: { type: Array, default: () => [] }
},
emits: ['addFilter', 'addNode'],
setup(props, { emit }) {
emitAddFilter = (filter) => emit('addFilter', filter)
emitAddNodeV1 = (nodeDef, dragEvent) =>
emit('addNode', nodeDef, dragEvent)
const filterCount = computed(() => props.filters.length)
return { filterCount }
},
template: '<output aria-label="filter count">{{ filterCount }}</output>'
})
const NodeSearchContentStub = defineComponent({
name: 'NodeSearchContent',
props: {
filters: { type: Array, default: () => [] }
},
emits: ['addFilter', 'removeFilter', 'addNode', 'hoverNode'],
setup(_, { emit }) {
emitAddNodeV2 = (nodeDef, dragEvent) =>
emit('addNode', nodeDef, dragEvent)
return {}
},
template: '<div data-testid="search-content-v2"></div>'
})
const pinia = createTestingPinia({
stubActions: false,
initialState: {
setting: {
settingValues: settings,
settingsById: coreSettingsById
},
searchBox: { visible: false }
}
})
const result = render(NodeSearchBoxPopover, {
global: {
plugins: [i18n, PrimeVue, pinia],
stubs: {
NodeSearchBox: NodeSearchBoxStub,
NodeSearchContent: NodeSearchContentStub,
NodePreviewCard: true,
Dialog: {
template: '<div><slot name="container" /></div>',
props: ['visible', 'modal', 'dismissableMask', 'pt']
}
}
}
})
return {
...result,
get emitAddFilter() {
if (!emitAddFilter) throw new Error('NodeSearchBox stub did not mount')
return emitAddFilter
},
get emitAddNodeV1() {
if (!emitAddNodeV1) throw new Error('NodeSearchBox stub did not mount')
return emitAddNodeV1
},
get emitAddNodeV2() {
if (!emitAddNodeV2)
throw new Error('NodeSearchContent stub did not mount')
return emitAddNodeV2
}
}
}
beforeEach(() => {
addNodeOnGraph.mockReset()
addNodeOnGraph.mockReturnValue(null)
})
describe('addFilter duplicate prevention', () => {
it('should add a filter when no duplicates exist', async () => {
const { emitAddFilter } = renderComponent({
'Comfy.NodeSearchBoxImpl': 'v1 (legacy)'
})
emitAddFilter(createFilter('outputType', 'IMAGE'))
await nextTick()
expect(screen.getByLabelText('filter count')).toHaveTextContent('1')
})
it('should not add a duplicate filter with same id and value', async () => {
const { emitAddFilter } = renderComponent({
'Comfy.NodeSearchBoxImpl': 'v1 (legacy)'
})
emitAddFilter(createFilter('outputType', 'IMAGE'))
await nextTick()
emitAddFilter(createFilter('outputType', 'IMAGE'))
await nextTick()
expect(screen.getByLabelText('filter count')).toHaveTextContent('1')
})
it('should allow filters with same id but different values', async () => {
const { emitAddFilter } = renderComponent({
'Comfy.NodeSearchBoxImpl': 'v1 (legacy)'
})
emitAddFilter(createFilter('outputType', 'IMAGE'))
await nextTick()
emitAddFilter(createFilter('outputType', 'MASK'))
await nextTick()
expect(screen.getByLabelText('filter count')).toHaveTextContent('2')
})
it('should allow filters with different ids but same value', async () => {
const { emitAddFilter } = renderComponent({
'Comfy.NodeSearchBoxImpl': 'v1 (legacy)'
})
emitAddFilter(createFilter('outputType', 'IMAGE'))
await nextTick()
emitAddFilter(createFilter('inputType', 'IMAGE'))
await nextTick()
expect(screen.getByLabelText('filter count')).toHaveTextContent('2')
})
})
describe('addNode ghost flag (FollowCursor setting)', () => {
const nodeDef = { name: 'KSampler' } as ComfyNodeDefImpl
it('should default ghost to true when v2 search is active and FollowCursor is unset', async () => {
const { emitAddNodeV2 } = renderComponent({
'Comfy.NodeSearchBoxImpl': 'default'
})
emitAddNodeV2(nodeDef)
await nextTick()
expect(addNodeOnGraph).toHaveBeenCalledWith(
nodeDef,
expect.objectContaining({ pos: expect.any(Array) }),
expect.objectContaining({ ghost: true })
)
})
it('should pass ghost: true when v2 search is active and FollowCursor is enabled', async () => {
const { emitAddNodeV2 } = renderComponent({
'Comfy.NodeSearchBoxImpl': 'default',
'Comfy.NodeSearchBoxImpl.FollowCursor': true
})
emitAddNodeV2(nodeDef)
await nextTick()
expect(addNodeOnGraph).toHaveBeenCalledWith(
nodeDef,
expect.objectContaining({ pos: expect.any(Array) }),
expect.objectContaining({ ghost: true })
)
})
it('should pass ghost: false when v2 search is active but FollowCursor is disabled', async () => {
const { emitAddNodeV2 } = renderComponent({
'Comfy.NodeSearchBoxImpl': 'default',
'Comfy.NodeSearchBoxImpl.FollowCursor': false
})
emitAddNodeV2(nodeDef)
await nextTick()
expect(addNodeOnGraph).toHaveBeenCalledWith(
nodeDef,
expect.objectContaining({ pos: expect.any(Array) }),
expect.objectContaining({ ghost: false })
)
})
it('should pass ghost: false when v1 legacy search box is used', async () => {
const { emitAddNodeV1 } = renderComponent({
'Comfy.NodeSearchBoxImpl': 'v1 (legacy)',
'Comfy.NodeSearchBoxImpl.FollowCursor': true
})
emitAddNodeV1(nodeDef)
await nextTick()
expect(addNodeOnGraph).toHaveBeenCalledWith(
nodeDef,
expect.objectContaining({ pos: expect.any(Array) }),
expect.objectContaining({ ghost: false })
)
})
it('should pass ghost: false when litegraph legacy search box is used', async () => {
const { emitAddNodeV1 } = renderComponent({
'Comfy.NodeSearchBoxImpl': 'litegraph (legacy)',
'Comfy.NodeSearchBoxImpl.FollowCursor': true
})
emitAddNodeV1(nodeDef)
await nextTick()
expect(addNodeOnGraph).toHaveBeenCalledWith(
nodeDef,
expect.objectContaining({ pos: expect.any(Array) }),
expect.objectContaining({ ghost: false })
)
})
it('should forward the dragEvent through to addNodeOnGraph', async () => {
const dragEvent = new MouseEvent('mousedown')
const { emitAddNodeV2 } = renderComponent({
'Comfy.NodeSearchBoxImpl': 'default',
'Comfy.NodeSearchBoxImpl.FollowCursor': true
})
emitAddNodeV2(nodeDef, dragEvent)
await nextTick()
expect(addNodeOnGraph).toHaveBeenCalledWith(
nodeDef,
expect.objectContaining({ pos: expect.any(Array) }),
expect.objectContaining({ ghost: true, dragEvent })
)
})
})
})