mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-27 00:14:55 +00:00
## Summary Follow-up to #12183: move the debounced, searcher-driven search input out of `src/renderer/...` and into the shared primitives folder, so both the graph (form dropdown node widget) and the shell UI (templates dialog, right side panel tabs) can use it without crossing the renderer layer. ## Changes - **What**: Renamed and relocated `FormSearchInput` → `AsyncSearchInput` at `src/components/ui/search-input/AsyncSearchInput.vue`, joining the existing `SearchInput` / `SearchAutocomplete` siblings. - **What**: Updated all 9 callers (graph form dropdown, right side panel tabs, templates dialog) to import from the new path/name. Test file moved alongside the component. - **Breaking**: None — pure rename + relocate, behavior is identical. ## Review Focus - New name reflects the component's distinguishing feature (the async `searcher` lifecycle: debounce + cancellation + loading state); see Slack thread. - Slack thread captured the discussion — splitting from #12183 so the perf fix can backport cleanly to the release line. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-12185-refactor-promote-FormSearchInput-to-shared-ui-as-AsyncSearchInput-35e6d73d365081c585d8d421ea4353fa) by [Unito](https://www.unito.io) Co-authored-by: Christian Byrne <cbyrne@comfy.org>
307 lines
9.2 KiB
TypeScript
307 lines
9.2 KiB
TypeScript
import { createTestingPinia } from '@pinia/testing'
|
|
import { render, screen } from '@testing-library/vue'
|
|
import userEvent from '@testing-library/user-event'
|
|
import PrimeVue from 'primevue/config'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { createI18n } from 'vue-i18n'
|
|
import TabErrors from './TabErrors.vue'
|
|
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
|
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
|
|
|
vi.mock('@/scripts/app', () => ({
|
|
app: {
|
|
rootGraph: {
|
|
serialize: vi.fn(() => ({})),
|
|
getNodeById: vi.fn()
|
|
}
|
|
}
|
|
}))
|
|
|
|
vi.mock('@/utils/graphTraversalUtil', () => ({
|
|
getNodeByExecutionId: vi.fn(),
|
|
getRootParentNode: vi.fn(() => null),
|
|
forEachNode: vi.fn(),
|
|
mapAllNodes: vi.fn(() => [])
|
|
}))
|
|
|
|
vi.mock('@/composables/useCopyToClipboard', () => ({
|
|
useCopyToClipboard: vi.fn(() => ({
|
|
copyToClipboard: vi.fn()
|
|
}))
|
|
}))
|
|
|
|
vi.mock('@/services/litegraphService', () => ({
|
|
useLitegraphService: vi.fn(() => ({
|
|
fitView: vi.fn()
|
|
}))
|
|
}))
|
|
|
|
describe('TabErrors.vue', () => {
|
|
let i18n: ReturnType<typeof createI18n>
|
|
|
|
beforeEach(() => {
|
|
i18n = createI18n({
|
|
legacy: false,
|
|
locale: 'en',
|
|
messages: {
|
|
en: {
|
|
g: {
|
|
workflow: 'Workflow',
|
|
copy: 'Copy'
|
|
},
|
|
rightSidePanel: {
|
|
noErrors: 'No errors',
|
|
noneSearchDesc: 'No results found',
|
|
missingModels: {
|
|
missingModelsTitle: 'Missing Models',
|
|
downloadAll: 'Download all',
|
|
refresh: 'Refresh',
|
|
refreshing: 'Refreshing missing models.'
|
|
},
|
|
promptErrors: {
|
|
prompt_no_outputs: {
|
|
desc: 'Prompt has no outputs'
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
function renderComponent(initialState = {}) {
|
|
const user = userEvent.setup()
|
|
render(TabErrors, {
|
|
global: {
|
|
plugins: [
|
|
PrimeVue,
|
|
i18n,
|
|
createTestingPinia({
|
|
createSpy: vi.fn,
|
|
initialState
|
|
})
|
|
],
|
|
stubs: {
|
|
AsyncSearchInput: {
|
|
template:
|
|
'<input @input="$emit(\'update:modelValue\', $event.target.value)" />'
|
|
},
|
|
PropertiesAccordionItem: {
|
|
template: '<div><slot name="label" /><slot /></div>'
|
|
},
|
|
Button: {
|
|
template: '<button v-bind="$attrs"><slot /></button>'
|
|
}
|
|
}
|
|
}
|
|
})
|
|
return { user }
|
|
}
|
|
|
|
it('renders "no errors" state when store is empty', () => {
|
|
renderComponent()
|
|
expect(screen.getByText('No errors')).toBeInTheDocument()
|
|
})
|
|
|
|
it('renders prompt-level errors (Group title = error message)', async () => {
|
|
renderComponent({
|
|
executionError: {
|
|
lastPromptError: {
|
|
type: 'prompt_no_outputs',
|
|
message: 'Server Error: No outputs',
|
|
details: 'Error details'
|
|
}
|
|
}
|
|
})
|
|
|
|
expect(screen.getByText('Server Error: No outputs')).toBeInTheDocument()
|
|
expect(screen.getByText('Prompt has no outputs')).toBeInTheDocument()
|
|
expect(screen.queryByText('Error details')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('renders node validation errors grouped by class_type', async () => {
|
|
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
|
|
vi.mocked(getNodeByExecutionId).mockReturnValue({
|
|
title: 'CLIP Text Encode'
|
|
} as ReturnType<typeof getNodeByExecutionId>)
|
|
|
|
renderComponent({
|
|
executionError: {
|
|
lastNodeErrors: {
|
|
'6': {
|
|
class_type: 'CLIPTextEncode',
|
|
errors: [
|
|
{ message: 'Required input is missing', details: 'Input: text' }
|
|
]
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
expect(screen.getByText('CLIPTextEncode')).toBeInTheDocument()
|
|
expect(screen.getByText('#6')).toBeInTheDocument()
|
|
expect(screen.getByText('CLIP Text Encode')).toBeInTheDocument()
|
|
expect(screen.getByText('Required input is missing')).toBeInTheDocument()
|
|
})
|
|
|
|
it('renders runtime execution errors from WebSocket', async () => {
|
|
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
|
|
vi.mocked(getNodeByExecutionId).mockReturnValue({
|
|
title: 'KSampler'
|
|
} as ReturnType<typeof getNodeByExecutionId>)
|
|
|
|
renderComponent({
|
|
executionError: {
|
|
lastExecutionError: {
|
|
prompt_id: 'abc',
|
|
node_id: '10',
|
|
node_type: 'KSampler',
|
|
exception_message: 'Out of memory',
|
|
exception_type: 'RuntimeError',
|
|
traceback: ['Line 1', 'Line 2'],
|
|
timestamp: Date.now()
|
|
}
|
|
}
|
|
})
|
|
|
|
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
|
|
expect(screen.getByText('#10')).toBeInTheDocument()
|
|
expect(screen.getByText('RuntimeError: Out of memory')).toBeInTheDocument()
|
|
expect(screen.getByText(/Line 1/)).toBeInTheDocument()
|
|
})
|
|
|
|
it('filters errors based on search query', async () => {
|
|
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
|
|
vi.mocked(getNodeByExecutionId).mockReturnValue(null)
|
|
|
|
const { user } = renderComponent({
|
|
executionError: {
|
|
lastNodeErrors: {
|
|
'1': {
|
|
class_type: 'CLIPTextEncode',
|
|
errors: [{ message: 'Missing text input' }]
|
|
},
|
|
'2': {
|
|
class_type: 'KSampler',
|
|
errors: [{ message: 'Out of memory' }]
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
expect(screen.getAllByText('CLIPTextEncode').length).toBeGreaterThanOrEqual(
|
|
1
|
|
)
|
|
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
|
|
|
|
await user.type(screen.getByRole('textbox'), 'Missing text input')
|
|
|
|
expect(screen.getAllByText('CLIPTextEncode').length).toBeGreaterThanOrEqual(
|
|
1
|
|
)
|
|
expect(screen.queryByText('KSampler')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('calls copyToClipboard when copy button is clicked', async () => {
|
|
const { useCopyToClipboard } =
|
|
await import('@/composables/useCopyToClipboard')
|
|
const mockCopy = vi.fn()
|
|
vi.mocked(useCopyToClipboard).mockReturnValue({ copyToClipboard: mockCopy })
|
|
|
|
const { user } = renderComponent({
|
|
executionError: {
|
|
lastNodeErrors: {
|
|
'1': {
|
|
class_type: 'TestNode',
|
|
errors: [{ message: 'Test message', details: 'Test details' }]
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
await user.click(screen.getByTestId('error-card-copy'))
|
|
|
|
expect(mockCopy).toHaveBeenCalledWith('Test message\n\nTest details')
|
|
})
|
|
|
|
it('renders single runtime error outside accordion in full-height panel', async () => {
|
|
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
|
|
vi.mocked(getNodeByExecutionId).mockReturnValue({
|
|
title: 'KSampler'
|
|
} as ReturnType<typeof getNodeByExecutionId>)
|
|
|
|
renderComponent({
|
|
executionError: {
|
|
lastExecutionError: {
|
|
prompt_id: 'abc',
|
|
node_id: '10',
|
|
node_type: 'KSampler',
|
|
exception_message: 'Out of memory',
|
|
exception_type: 'RuntimeError',
|
|
traceback: ['Line 1', 'Line 2'],
|
|
timestamp: Date.now()
|
|
}
|
|
}
|
|
})
|
|
|
|
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
|
|
expect(screen.getByText('RuntimeError: Out of memory')).toBeInTheDocument()
|
|
expect(screen.getByTestId('runtime-error-panel')).toBeInTheDocument()
|
|
expect(screen.getAllByText('RuntimeError: Out of memory')).toHaveLength(1)
|
|
})
|
|
|
|
it('shows missing model Refresh in the section header when no model is downloadable', async () => {
|
|
const missingModel = {
|
|
nodeId: '1',
|
|
nodeType: 'CheckpointLoaderSimple',
|
|
widgetName: 'ckpt_name',
|
|
name: 'local-only.safetensors',
|
|
directory: 'checkpoints',
|
|
isMissing: true,
|
|
isAssetSupported: true
|
|
} satisfies MissingModelCandidate
|
|
|
|
const { user } = renderComponent({
|
|
missingModel: {
|
|
missingModelCandidates: [missingModel]
|
|
}
|
|
})
|
|
const missingModelStore = useMissingModelStore()
|
|
|
|
expect(screen.getByText('Missing Models (1)')).toBeInTheDocument()
|
|
expect(
|
|
screen.queryByTestId('missing-model-actions')
|
|
).not.toBeInTheDocument()
|
|
|
|
await user.click(screen.getByTestId('missing-model-header-refresh'))
|
|
|
|
expect(missingModelStore.refreshMissingModels).toHaveBeenCalled()
|
|
})
|
|
|
|
it('keeps missing model Refresh in the card actions when models are downloadable', () => {
|
|
const missingModel = {
|
|
nodeId: '1',
|
|
nodeType: 'CheckpointLoaderSimple',
|
|
widgetName: 'ckpt_name',
|
|
name: 'downloadable.safetensors',
|
|
url: 'https://huggingface.co/comfy/test/resolve/main/downloadable.safetensors',
|
|
directory: 'checkpoints',
|
|
isMissing: true,
|
|
isAssetSupported: true
|
|
} satisfies MissingModelCandidate
|
|
|
|
renderComponent({
|
|
missingModel: {
|
|
missingModelCandidates: [missingModel]
|
|
}
|
|
})
|
|
|
|
expect(
|
|
screen.queryByTestId('missing-model-header-refresh')
|
|
).not.toBeInTheDocument()
|
|
expect(screen.getByTestId('missing-model-actions')).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: /Download all/ })).toBeVisible()
|
|
expect(screen.getByRole('button', { name: 'Refresh' })).toBeVisible()
|
|
})
|
|
})
|