mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-06 15:54:45 +00:00
## Summary Resolve missing resource error groups through the error catalog so missing nodes, replaceable nodes, missing models, and missing media use consistent panel and single-error overlay copy. ## Changes - **What**: Adds missing-resource resolvers for `missing_node`, `swap_nodes`, `missing_model`, and `missing_media` that provide `displayMessage`, `toastTitle`, and `toastMessage` alongside the existing group titles. The Errors tab now renders a group-level `displayMessage` under non-execution group headers, which gives grouped missing-resource cards the same explanatory message path used by validation/runtime errors without adding per-row detail fields that these grouped cards do not need. - **What**: Moves missing node and swap node explanatory copy out of card-local hardcoded text and into `errorCatalog.missingErrors.*` keys. `MissingNodeCard` and `SwapNodesCard` now focus on rendering their grouped rows and actions, while the shared error group header owns the explanatory copy. - **What**: Adds environment-aware copy for missing node packs and missing models. Cloud messages explain unsupported resources and replacement/import paths without suggesting local execution, while OSS messages point users toward installing or downloading the missing resources. - **What**: Adds single-error overlay/toast copy for missing resources. Missing media uses a concise input-focused title/message, missing models distinguish Cloud unsupported models from OSS missing files, and missing nodes/swap nodes use node-type-aware copy. - **What**: Deduplicates missing node and swap node toast decisions by distinct node type so repeated instances of the same missing/replaceable node do not accidentally switch the single-error copy to plural copy. - **What**: Preserves representative missing media candidate metadata so missing media toast copy can use a human-readable node name such as `Load Image is missing a required media file.` - **What**: Removes unused missing-resource resolver fields such as grouped `displayDetails`, grouped `displayItemLabel`, and the unused `mediaTypes` source parameter after deciding those fields do not fit grouped missing-resource cards. - **Breaking**: None. - **Dependencies**: None. ## Review Focus - Missing resource groups are still grouped cards. This PR intentionally gives them group-level display and toast copy, but does not split missing resources into one error item per underlying candidate. - Missing resource count semantics are intentionally not normalized here. Error overlay totals, store counts, and grouped card counts still follow the existing behavior; a follow-up PR can define those count units separately. - The Cloud/OSS message variants remain explicit in the resolver instead of being abstracted into a generic variant helper. That keeps this PR focused on the messaging behavior and avoids a broader resolver refactor. - Only `src/locales/en/main.json` is updated directly. Other locales should be synced by the existing localization flow. ## Screenshots (if applicable) <img width="668" height="245" alt="스크린샷 2026-06-05 오전 3 16 49" src="https://github.com/user-attachments/assets/98b50ac3-67e1-438d-8c37-e06c7bf465ee" /> <img width="666" height="195" alt="스크린샷 2026-06-05 오전 3 16 58" src="https://github.com/user-attachments/assets/92da95b1-03d6-4739-97e6-c573982bfec9" /> <img width="505" height="358" alt="스크린샷 2026-06-05 오전 3 17 27" src="https://github.com/user-attachments/assets/4d0e1a6e-13b9-4097-9fb5-19fe0c5331dc" /> <img width="507" height="324" alt="스크린샷 2026-06-05 오전 3 17 44" src="https://github.com/user-attachments/assets/054e42f8-0d0c-44b5-8a67-e467fc04f1fc" /> ## Validation - `pnpm format` - `pnpm lint` - `pnpm test:unit src/platform/errorCatalog/errorMessageResolver.test.ts src/components/rightSidePanel/errors/useErrorGroups.test.ts src/components/rightSidePanel/errors/TabErrors.test.ts` - `pnpm typecheck` - push hook: `knip --cache`
378 lines
12 KiB
TypeScript
378 lines
12 KiB
TypeScript
import { createTestingPinia } from '@pinia/testing'
|
|
import { render, screen } from '@testing-library/vue'
|
|
import userEvent from '@testing-library/user-event'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { createI18n } from 'vue-i18n'
|
|
|
|
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
|
|
|
|
const mockIsCloud = vi.hoisted(() => ({ value: false }))
|
|
vi.mock('@/platform/distribution/types', () => ({
|
|
get isCloud() {
|
|
return mockIsCloud.value
|
|
}
|
|
}))
|
|
|
|
const mockMissingCoreNodes = vi.hoisted(() => ({
|
|
value: {} as Record<string, { type: string }[]>
|
|
}))
|
|
const mockSystemStats = vi.hoisted(() => ({
|
|
value: null as { system?: { comfyui_version?: string } } | null
|
|
}))
|
|
|
|
vi.mock(
|
|
'@/workbench/extensions/manager/composables/nodePack/useMissingNodes',
|
|
() => ({
|
|
useMissingNodes: () => ({
|
|
missingCoreNodes: mockMissingCoreNodes,
|
|
missingNodePacks: { value: [] },
|
|
isLoading: { value: false },
|
|
error: { value: null },
|
|
hasMissingNodes: { value: false }
|
|
})
|
|
})
|
|
)
|
|
|
|
vi.mock('@/stores/systemStatsStore', () => ({
|
|
useSystemStatsStore: () => ({
|
|
get systemStats() {
|
|
return mockSystemStats.value
|
|
}
|
|
})
|
|
}))
|
|
|
|
const mockApplyChanges = vi.hoisted(() => vi.fn())
|
|
const mockIsRestarting = vi.hoisted(() => ({ value: false }))
|
|
vi.mock('@/workbench/extensions/manager/composables/useApplyChanges', () => ({
|
|
useApplyChanges: () => ({
|
|
get isRestarting() {
|
|
return mockIsRestarting.value
|
|
},
|
|
applyChanges: mockApplyChanges
|
|
})
|
|
}))
|
|
|
|
const mockIsPackInstalled = vi.hoisted(() => vi.fn(() => false))
|
|
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
|
|
useComfyManagerStore: () => ({
|
|
isPackInstalled: mockIsPackInstalled
|
|
})
|
|
}))
|
|
|
|
const mockShouldShowManagerButtons = vi.hoisted(() => ({ value: false }))
|
|
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
|
|
useManagerState: () => ({
|
|
shouldShowManagerButtons: mockShouldShowManagerButtons
|
|
})
|
|
}))
|
|
|
|
vi.mock('./MissingPackGroupRow.vue', () => ({
|
|
default: {
|
|
name: 'MissingPackGroupRow',
|
|
template: `<div class="pack-row" data-testid="pack-row"
|
|
:data-show-info-button="String(showInfoButton)"
|
|
:data-show-node-id-badge="String(showNodeIdBadge)"
|
|
>
|
|
<button data-testid="locate-node" @click="$emit('locate-node', group.nodeTypes[0]?.nodeId)" />
|
|
<button data-testid="open-manager-info" @click="$emit('open-manager-info', group.packId)" />
|
|
</div>`,
|
|
props: ['group', 'showInfoButton', 'showNodeIdBadge'],
|
|
emits: ['locate-node', 'open-manager-info']
|
|
}
|
|
}))
|
|
|
|
import MissingNodeCard from './MissingNodeCard.vue'
|
|
|
|
const i18n = createI18n({
|
|
legacy: false,
|
|
locale: 'en',
|
|
messages: {
|
|
en: {
|
|
rightSidePanel: {
|
|
missingNodePacks: {
|
|
ossManagerDisabledHint:
|
|
'To install missing nodes, first run {pipCmd} in your Python environment to install Node Manager, then restart ComfyUI with the {flag} flag.',
|
|
applyChanges: 'Apply Changes'
|
|
}
|
|
},
|
|
loadWorkflowWarning: {
|
|
outdatedVersion:
|
|
'Some nodes require a newer version of ComfyUI (current: {version}).',
|
|
outdatedVersionGeneric:
|
|
'Some nodes require a newer version of ComfyUI.',
|
|
coreNodesFromVersion: 'Requires ComfyUI {version}:',
|
|
unknownVersion: 'unknown'
|
|
}
|
|
}
|
|
},
|
|
missingWarn: false,
|
|
fallbackWarn: false
|
|
})
|
|
|
|
function makePackGroups(count = 2): MissingPackGroup[] {
|
|
return Array.from({ length: count }, (_, i) => ({
|
|
packId: `pack-${i}`,
|
|
nodeTypes: [
|
|
{ type: `MissingNode${i}`, nodeId: String(i), isReplaceable: false }
|
|
],
|
|
isResolving: false
|
|
}))
|
|
}
|
|
|
|
function renderCard(
|
|
props: Partial<{
|
|
showInfoButton: boolean
|
|
showNodeIdBadge: boolean
|
|
missingPackGroups: MissingPackGroup[]
|
|
}> = {}
|
|
) {
|
|
const user = userEvent.setup()
|
|
const result = render(MissingNodeCard, {
|
|
props: {
|
|
showInfoButton: false,
|
|
showNodeIdBadge: false,
|
|
missingPackGroups: makePackGroups(),
|
|
...props
|
|
},
|
|
global: {
|
|
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
|
|
stubs: {
|
|
DotSpinner: { template: '<span role="status" aria-label="loading" />' }
|
|
}
|
|
}
|
|
})
|
|
return { ...result, user }
|
|
}
|
|
|
|
describe('MissingNodeCard', () => {
|
|
beforeEach(() => {
|
|
mockApplyChanges.mockClear()
|
|
mockIsPackInstalled.mockReset()
|
|
mockIsPackInstalled.mockReturnValue(false)
|
|
mockIsCloud.value = false
|
|
mockShouldShowManagerButtons.value = false
|
|
mockIsRestarting.value = false
|
|
mockMissingCoreNodes.value = {}
|
|
mockSystemStats.value = null
|
|
})
|
|
|
|
describe('Rendering & Props', () => {
|
|
it('renders correct number of MissingPackGroupRow components', () => {
|
|
renderCard({ missingPackGroups: makePackGroups(3) })
|
|
expect(screen.getAllByTestId('pack-row')).toHaveLength(3)
|
|
})
|
|
|
|
it('renders zero rows when missingPackGroups is empty', () => {
|
|
renderCard({ missingPackGroups: [] })
|
|
expect(screen.queryAllByTestId('pack-row')).toHaveLength(0)
|
|
})
|
|
|
|
it('passes props correctly to MissingPackGroupRow children', () => {
|
|
renderCard({
|
|
showInfoButton: true,
|
|
showNodeIdBadge: true
|
|
})
|
|
const row = screen.getAllByTestId('pack-row')[0]
|
|
expect(row.getAttribute('data-show-info-button')).toBe('true')
|
|
expect(row.getAttribute('data-show-node-id-badge')).toBe('true')
|
|
})
|
|
})
|
|
|
|
describe('Manager Disabled Hint', () => {
|
|
it('shows hint when OSS and manager is disabled (showInfoButton false)', () => {
|
|
mockIsCloud.value = false
|
|
renderCard({ showInfoButton: false })
|
|
expect(
|
|
screen.getByText('pip install -U --pre comfyui-manager')
|
|
).toBeInTheDocument()
|
|
expect(screen.getByText('--enable-manager')).toBeInTheDocument()
|
|
})
|
|
|
|
it('hides hint when manager is enabled (showInfoButton true)', () => {
|
|
mockIsCloud.value = false
|
|
renderCard({ showInfoButton: true })
|
|
expect(screen.queryByText('--enable-manager')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('hides hint on Cloud even when showInfoButton is false', () => {
|
|
mockIsCloud.value = true
|
|
renderCard({ showInfoButton: false })
|
|
expect(screen.queryByText('--enable-manager')).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('Apply Changes Section', () => {
|
|
it('hides Apply Changes when manager is not enabled', () => {
|
|
mockShouldShowManagerButtons.value = false
|
|
renderCard()
|
|
expect(screen.queryByText('Apply Changes')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('hides Apply Changes when manager enabled but no packs pending', () => {
|
|
mockShouldShowManagerButtons.value = true
|
|
mockIsPackInstalled.mockReturnValue(false)
|
|
renderCard()
|
|
expect(screen.queryByText('Apply Changes')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('shows Apply Changes when at least one pack is pending restart', () => {
|
|
mockShouldShowManagerButtons.value = true
|
|
mockIsPackInstalled.mockReturnValue(true)
|
|
renderCard()
|
|
expect(screen.getByText('Apply Changes')).toBeInTheDocument()
|
|
})
|
|
|
|
it('displays spinner during restart', () => {
|
|
mockShouldShowManagerButtons.value = true
|
|
mockIsPackInstalled.mockReturnValue(true)
|
|
mockIsRestarting.value = true
|
|
renderCard()
|
|
expect(screen.getByRole('status')).toBeInTheDocument()
|
|
})
|
|
|
|
it('disables button during restart', () => {
|
|
mockShouldShowManagerButtons.value = true
|
|
mockIsPackInstalled.mockReturnValue(true)
|
|
mockIsRestarting.value = true
|
|
renderCard()
|
|
expect(
|
|
screen.getByRole('button', { name: /apply changes/i })
|
|
).toBeDisabled()
|
|
})
|
|
|
|
it('calls applyChanges when Apply Changes button is clicked', async () => {
|
|
mockShouldShowManagerButtons.value = true
|
|
mockIsPackInstalled.mockReturnValue(true)
|
|
const { user } = renderCard()
|
|
await user.click(screen.getByRole('button', { name: /apply changes/i }))
|
|
expect(mockApplyChanges).toHaveBeenCalledOnce()
|
|
})
|
|
})
|
|
|
|
describe('Event Handling', () => {
|
|
it('emits locateNode when child emits locate-node', async () => {
|
|
const onLocateNode = vi.fn()
|
|
const user = userEvent.setup()
|
|
render(MissingNodeCard, {
|
|
props: {
|
|
showInfoButton: false,
|
|
showNodeIdBadge: false,
|
|
missingPackGroups: makePackGroups(),
|
|
onLocateNode
|
|
},
|
|
global: {
|
|
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
|
|
stubs: {
|
|
DotSpinner: {
|
|
template: '<span role="status" aria-label="loading" />'
|
|
}
|
|
}
|
|
}
|
|
})
|
|
await user.click(screen.getAllByTestId('locate-node')[0])
|
|
expect(onLocateNode).toHaveBeenCalledWith('0')
|
|
})
|
|
|
|
it('emits openManagerInfo when child emits open-manager-info', async () => {
|
|
const onOpenManagerInfo = vi.fn()
|
|
const user = userEvent.setup()
|
|
render(MissingNodeCard, {
|
|
props: {
|
|
showInfoButton: false,
|
|
showNodeIdBadge: false,
|
|
missingPackGroups: makePackGroups(),
|
|
onOpenManagerInfo
|
|
},
|
|
global: {
|
|
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
|
|
stubs: {
|
|
DotSpinner: {
|
|
template: '<span role="status" aria-label="loading" />'
|
|
}
|
|
}
|
|
}
|
|
})
|
|
await user.click(screen.getAllByTestId('open-manager-info')[0])
|
|
expect(onOpenManagerInfo).toHaveBeenCalledWith('pack-0')
|
|
})
|
|
})
|
|
|
|
describe('Core Node Version Warning', () => {
|
|
it('does not render warning when no missing core nodes', () => {
|
|
const { container } = renderCard()
|
|
expect(container.textContent).not.toContain('newer version of ComfyUI')
|
|
})
|
|
|
|
it('renders warning with version when missing core nodes exist', () => {
|
|
mockMissingCoreNodes.value = {
|
|
'1.2.0': [{ type: 'TestNode' }]
|
|
}
|
|
mockSystemStats.value = { system: { comfyui_version: '1.0.0' } }
|
|
const { container } = renderCard()
|
|
expect(container.textContent).toContain('(current: 1.0.0)')
|
|
expect(container.textContent).toContain('Requires ComfyUI 1.2.0:')
|
|
expect(container.textContent).toContain('TestNode')
|
|
})
|
|
|
|
it('renders generic message when version is unavailable', () => {
|
|
mockMissingCoreNodes.value = {
|
|
'1.2.0': [{ type: 'TestNode' }]
|
|
}
|
|
renderCard()
|
|
expect(
|
|
screen.getByText('Some nodes require a newer version of ComfyUI.')
|
|
).toBeInTheDocument()
|
|
})
|
|
|
|
it('does not render warning on Cloud', () => {
|
|
mockIsCloud.value = true
|
|
mockMissingCoreNodes.value = {
|
|
'1.2.0': [{ type: 'TestNode' }]
|
|
}
|
|
const { container } = renderCard()
|
|
expect(container.textContent).not.toContain('newer version of ComfyUI')
|
|
})
|
|
|
|
it('deduplicates and sorts node names within a version', () => {
|
|
mockMissingCoreNodes.value = {
|
|
'1.2.0': [
|
|
{ type: 'ZebraNode' },
|
|
{ type: 'AlphaNode' },
|
|
{ type: 'ZebraNode' }
|
|
]
|
|
}
|
|
const { container } = renderCard()
|
|
expect(container.textContent).toContain('AlphaNode, ZebraNode')
|
|
// eslint-disable-next-line testing-library/no-container
|
|
expect(container.textContent?.match(/ZebraNode/g)).toHaveLength(1)
|
|
})
|
|
|
|
it('sorts versions in descending order', () => {
|
|
mockMissingCoreNodes.value = {
|
|
'1.1.0': [{ type: 'Node1' }],
|
|
'1.3.0': [{ type: 'Node3' }],
|
|
'1.2.0': [{ type: 'Node2' }]
|
|
}
|
|
const { container } = renderCard()
|
|
const text = container.textContent ?? ''
|
|
const v13 = text.indexOf('1.3.0')
|
|
const v12 = text.indexOf('1.2.0')
|
|
const v11 = text.indexOf('1.1.0')
|
|
expect(v13).toBeLessThan(v12)
|
|
expect(v12).toBeLessThan(v11)
|
|
})
|
|
|
|
it('handles empty string version key without crashing', () => {
|
|
mockMissingCoreNodes.value = {
|
|
'': [{ type: 'NoVersionNode' }],
|
|
'1.2.0': [{ type: 'VersionedNode' }]
|
|
}
|
|
const { container } = renderCard()
|
|
expect(container.textContent).toContain('Requires ComfyUI 1.2.0:')
|
|
expect(container.textContent).toContain('VersionedNode')
|
|
expect(container.textContent).toContain('unknown')
|
|
expect(container.textContent).toContain('NoVersionNode')
|
|
})
|
|
})
|
|
})
|