Files
ComfyUI_frontend/browser_tests/tests/nodeSearchBoxV2Extended.spec.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

401 lines
14 KiB
TypeScript

import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
test.describe('Node search box V2 extended', { tag: '@node' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.searchBoxV2.setup()
})
test('Double-click on empty canvas opens search', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.openByDoubleClickCanvas()
await expect(searchBoxV2.dialog).toBeVisible()
})
test('Escape closes search box without adding node', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await searchBoxV2.open()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(searchBoxV2.input).toBeHidden()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount)
})
for (const closeKey of ['Enter', 'Escape'] as const) {
test(`Reopening search after ${closeKey} has no persisted state`, async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press(closeKey)
await expect(searchBoxV2.input).toBeHidden()
await searchBoxV2.open()
await expect(searchBoxV2.input).toHaveValue('')
await expect(searchBoxV2.filterChips).toHaveCount(0)
})
}
test.describe('Category navigation', () => {
test('Category navigation updates results', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.categoryButton('sampling').click()
await expect(searchBoxV2.results.first()).toBeVisible()
const samplingResults = await searchBoxV2.results.allTextContents()
await searchBoxV2.categoryButton('loaders').click()
await expect(searchBoxV2.results.first()).toBeVisible()
await expect
.poll(() => searchBoxV2.results.allTextContents())
.not.toEqual(samplingResults)
})
})
test.describe('Filter workflow', () => {
test('Filter chip removal restores results', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
// Search first to keep the result set under the 64-item cap.
await searchBoxV2.input.fill('Load')
await expect(searchBoxV2.results.first()).toBeVisible()
const unfilteredCount = await searchBoxV2.results.count()
await test.step('Apply Input/MODEL filter', async () => {
await searchBoxV2.applyTypeFilter('input', 'MODEL')
await expect(searchBoxV2.filterChips).toHaveCount(1)
await expect
.poll(() => searchBoxV2.results.count())
.not.toBe(unfilteredCount)
})
await test.step('Remove the filter chip', async () => {
await searchBoxV2.removeFilterChip()
await expect(searchBoxV2.filterChips).toHaveCount(0)
await expect(searchBoxV2.results).toHaveCount(unfilteredCount)
})
})
})
test.describe('Link release', () => {
test('Link release opens search with pre-applied type filter', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await comfyPage.canvasOps.disconnectEdge()
await expect(searchBoxV2.input).toBeVisible()
// disconnectEdge pulls a CLIP link → expect a single CLIP filter chip.
await expect(searchBoxV2.filterChips).toHaveCount(1)
await expect(searchBoxV2.filterChips.first()).toContainText('CLIP')
})
test('Link release auto-connects added node', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
const NODE_TYPE = 'CLIPTextEncode'
const refsBefore = await comfyPage.nodeOps.getNodeRefsByType(NODE_TYPE)
const idsBefore = new Set(refsBefore.map((n) => n.id))
await comfyPage.canvasOps.disconnectEdge()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.input.fill('CLIP Text Encode')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).toBeHidden()
// A new CLIPTextEncode node should have been added.
await expect
.poll(() =>
comfyPage.nodeOps
.getNodeRefsByType(NODE_TYPE)
.then((refs) => refs.length)
)
.toBe(refsBefore.length + 1)
// Verify the auto-connect: the newly-added node's CLIP input must be
// connected (proves the release wasn't just dropped).
const refsAfter = await comfyPage.nodeOps.getNodeRefsByType(NODE_TYPE)
const newNode = refsAfter.find((n) => !idsBefore.has(n.id))
expect(newNode, 'expected a new CLIPTextEncode node').toBeDefined()
const clipInput = await newNode!.getInput(0)
await expect.poll(() => clipInput.getLinkCount()).toBe(1)
})
})
test.describe('Filter combinations', () => {
test('Output type filter filters results', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.input.fill('Load')
await expect(searchBoxV2.results.first()).toBeVisible()
const unfilteredCount = await searchBoxV2.results.count()
await searchBoxV2.applyTypeFilter('output', 'IMAGE')
await expect(searchBoxV2.filterChips).toHaveCount(1)
await expect
.poll(() => searchBoxV2.results.count())
.not.toBe(unfilteredCount)
})
test('Multiple type filters (Input + Output) narrows results', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.applyTypeFilter('input', 'MODEL')
await expect(searchBoxV2.filterChips).toHaveCount(1)
await expect(searchBoxV2.results.first()).toBeVisible()
const singleFilterCount = await searchBoxV2.results.count()
await searchBoxV2.applyTypeFilter('output', 'LATENT')
await expect(searchBoxV2.filterChips).toHaveCount(2)
await expect
.poll(() => searchBoxV2.results.count())
.toBeLessThan(singleFilterCount)
})
test('Root filter + search query narrows results', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.input.fill('Sampler')
await expect(searchBoxV2.results.first()).toBeVisible()
const unfilteredCount = await searchBoxV2.results.count()
await searchBoxV2.rootCategoryButton('comfy').click()
await expect
.poll(() => searchBoxV2.results.count())
.toBeLessThan(unfilteredCount)
await expect.poll(() => searchBoxV2.results.count()).toBeGreaterThan(0)
})
test('Root filter + category selection', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.rootCategoryButton('comfy').click()
await expect(searchBoxV2.results.first()).toBeVisible()
const comfyCount = await searchBoxV2.results.count()
// Under root filter, categories are prefixed (e.g. comfy/sampling).
await searchBoxV2.categoryButton('comfy/sampling').click()
await expect
.poll(() => searchBoxV2.results.count())
.toBeLessThan(comfyCount)
})
})
test.describe('Category sidebar', () => {
test('Category tree expand and collapse', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
const samplingBtn = searchBoxV2.categoryButton('sampling')
const subcategory = searchBoxV2.categoryButton('sampling/custom_sampling')
await test.step('Expanding sampling reveals its subcategories', async () => {
await samplingBtn.click()
await expect(subcategory).toBeVisible()
})
await test.step('Collapsing sampling hides its subcategories', async () => {
await samplingBtn.click()
await expect(subcategory).toBeHidden()
})
})
test('Subcategory narrows results to subset', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.categoryButton('sampling').click()
await expect(searchBoxV2.results.first()).toBeVisible()
const parentCount = await searchBoxV2.results.count()
const subcategory = searchBoxV2.categoryButton('sampling/custom_sampling')
await expect(subcategory).toBeVisible()
await subcategory.click()
await expect
.poll(() => searchBoxV2.results.count())
.toBeLessThan(parentCount)
})
test('Most relevant resets category filter', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await expect(searchBoxV2.results.first()).toBeVisible()
const defaultCount = await searchBoxV2.results.count()
await searchBoxV2.categoryButton('sampling').click()
await expect
.poll(() => searchBoxV2.results.count())
.not.toBe(defaultCount)
await searchBoxV2.categoryButton('most-relevant').click()
await expect(searchBoxV2.results).toHaveCount(defaultCount)
})
test(
'Blueprint root chip filters to published blueprints',
{ tag: ['@subgraph'] },
async ({ comfyPage }) => {
const blueprintName = `chip-test-${crypto.randomUUID().slice(0, 8)}`
const nodeRef = await comfyPage.nodeOps.getNodeRefById('3')
await nodeRef.click('title')
await comfyPage.command.executeCommand('Comfy.Graph.ConvertToSubgraph')
await expect
.poll(() =>
comfyPage.nodeOps
.getNodeRefsByTitle('New Subgraph')
.then((refs) => refs.length)
)
.toBe(1)
const subgraphNodes =
await comfyPage.nodeOps.getNodeRefsByTitle('New Subgraph')
await subgraphNodes[0].click('title')
await comfyPage.command.executeCommand('Comfy.PublishSubgraph', {
name: blueprintName
})
await expect(comfyPage.visibleToasts).toHaveCount(1, { timeout: 5000 })
await comfyPage.toast.closeToasts(1)
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
const blueprintsChip = searchBoxV2.rootCategoryButton(
RootCategory.Blueprint
)
await expect(blueprintsChip).toBeVisible()
await blueprintsChip.click()
// Blueprints persist across tests on the same worker; filter by the
// unique name we just published rather than asserting the full list.
await expect(
searchBoxV2.results.filter({ hasText: blueprintName })
).toHaveCount(1)
}
)
})
test.describe('Search behavior', () => {
test('Search narrows results progressively', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
const getCount = () => searchBoxV2.results.count()
await searchBoxV2.open()
await searchBoxV2.input.fill('S')
await expect(searchBoxV2.results.first()).toBeVisible()
const count1 = await getCount()
await searchBoxV2.input.fill('Sa')
await expect.poll(getCount).toBeLessThan(count1)
const count2 = await getCount()
await searchBoxV2.input.fill('Sampler')
await expect.poll(getCount).toBeLessThan(count2)
})
test('No results shown for nonsensical query', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.input.fill('zzzxxxyyy_nonexistent_node')
await expect(searchBoxV2.noResults).toBeVisible()
await expect(searchBoxV2.results).toHaveCount(0)
})
})
test.describe('Filter chip interaction', () => {
test('Multiple filter chips displayed', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.applyTypeFilter('input', 'MODEL')
await searchBoxV2.applyTypeFilter('output', 'LATENT')
await expect(searchBoxV2.filterChips).toHaveCount(2)
const chipTexts = await searchBoxV2.filterChips.allTextContents()
expect(chipTexts.some((t) => t.includes('MODEL'))).toBe(true)
expect(chipTexts.some((t) => t.includes('LATENT'))).toBe(true)
})
})
test.describe('Settings-driven behavior', () => {
test('Node ID name shown when setting enabled', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl.ShowIdName',
true
)
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.input.fill('VAE Decode')
await expect(searchBoxV2.results.first()).toBeVisible()
await expect(searchBoxV2.nodeIdBadge.first()).toBeVisible()
await expect(searchBoxV2.nodeIdBadge.first()).toContainText('VAEDecode')
})
test('Follow-cursor disabled places node without ghost mode', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl.FollowCursor',
false
)
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await searchBoxV2.open()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
await searchBoxV2.results.first().click()
await expect(searchBoxV2.input).toBeHidden()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount + 1)
await expect(
comfyPage.page.locator('[data-node-id][data-ghost]')
).toHaveCount(0)
})
})
})