refactor(node-replacement): reorganize domain components and expand comprehensive test suite (#9301)

## Summary

Resolves six open issues by reorganizing node replacement components
into a domain-driven folder structure, refactoring event handling to
follow the emit pattern, and adding comprehensive test coverage across
all affected modules.

## Changes

- **What**:
- Moved `SwapNodeGroupRow.vue` and `SwapNodesCard.vue` from
`src/components/rightSidePanel/errors/` to
`src/platform/nodeReplacement/components/` (Issues #9255)
- Moved `useMissingNodeScan.ts` from `src/composables/` to
`src/platform/nodeReplacement/missingNodeScan.ts`, renamed to reflect it
is a plain function not a Vue composable (Issues #9254)
- Refactored `SwapNodeGroupRow.vue` to emit a `'replace'` event instead
of calling `useNodeReplacement()` and `useExecutionErrorStore()`
directly; replacement logic now handled in `TabErrors.vue` (Issue #9267)
- Added unit tests for `removeMissingNodesByType`
(`executionErrorStore.test.ts`), `scanMissingNodes`
(`missingNodeScan.test.ts`), and `swapNodeGroups` computed
(`swapNodeGroups.test.ts`, `useErrorGroups.test.ts`) (Issue #9270)
- Added placeholder detection tests covering unregistered-type detection
when `has_errors` is false, and exclusion of registered types
(`useNodeReplacement.test.ts`) (Issue #9271)
- Added component tests for `MissingNodeCard` and `MissingPackGroupRow`
covering rendering, expand/collapse, events, install states, and edge
cases (Issue #9231)
- Added component tests for `SwapNodeGroupRow` and `SwapNodesCard`
(Issues #9255, #9267)

## Additional Changes (Post-Review)

- **Edge case guard in placeholder detection**
(`useNodeReplacement.ts`): When `last_serialization.type` is absent (old
serialization format), the predicate falls back to `n.type`, which the
app may have already run through `sanitizeNodeName` — stripping HTML
special characters (`& < > " ' \` =`). In that case, a `Set.has()`
lookup against the original unsanitized type name would silently miss,
causing replacement to be skipped.

Fixed by including sanitized variants of each target type in the
`targetTypes` Set at construction time. For the overwhelmingly common
case (no special characters in type names), the Set deduplicates the
entries and runtime behavior is identical to before.

A regression test was added to cover the specific scenario:
`last_serialization.type` absent + live `n.type` already sanitized.

## Review Focus

- `TabErrors.vue`: confirm the new `@replace` event handler correctly
replaces nodes and removes them from missing nodes list (mirrors the old
inline logic in `SwapNodeGroupRow`)
- `missingNodeScan.ts`: filename/export name change from
`useMissingNodeScan` — verify all call sites updated via `app.ts`
- Test mocking strategy: module-level `vi.mock()` factories use closures
over `ref`/plain objects to allow per-test overrides without global
mutable state

- Fixes #9231
- Fixes #9254
- Fixes #9255
- Fixes #9267
- Fixes #9270
- Fixes #9271
This commit is contained in:
jaeone94
2026-02-28 23:17:30 +09:00
committed by GitHub
parent 45f112e226
commit a0e518aa98
15 changed files with 2356 additions and 25 deletions

View File

@@ -0,0 +1,214 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import PrimeVue from 'primevue/config'
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
const mockIsCloud = { value: false }
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud.value
}
}))
const mockApplyChanges = vi.fn()
const mockIsRestarting = ref(false)
vi.mock('@/workbench/extensions/manager/composables/useApplyChanges', () => ({
useApplyChanges: () => ({
isRestarting: mockIsRestarting,
applyChanges: mockApplyChanges
})
}))
const mockIsPackInstalled = vi.fn(() => false)
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: () => ({
isPackInstalled: mockIsPackInstalled
})
}))
const mockShouldShowManagerButtons = { value: false }
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
useManagerState: () => ({
shouldShowManagerButtons: mockShouldShowManagerButtons
})
}))
vi.mock('./MissingPackGroupRow.vue', () => ({
default: {
name: 'MissingPackGroupRow',
template: '<div class="pack-row" />',
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: {
ossMessage: 'Missing node packs detected. Install them.',
cloudMessage: 'Unsupported node packs detected.',
applyChanges: 'Apply Changes'
}
}
}
},
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 mountCard(
props: Partial<{
showInfoButton: boolean
showNodeIdBadge: boolean
missingPackGroups: MissingPackGroup[]
}> = {}
) {
return mount(MissingNodeCard, {
props: {
showInfoButton: false,
showNodeIdBadge: false,
missingPackGroups: makePackGroups(),
...props
},
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n],
stubs: {
DotSpinner: { template: '<span role="status" aria-label="loading" />' }
}
}
})
}
describe('MissingNodeCard', () => {
beforeEach(() => {
mockApplyChanges.mockClear()
mockIsPackInstalled.mockReset()
mockIsPackInstalled.mockReturnValue(false)
mockIsCloud.value = false
mockShouldShowManagerButtons.value = false
mockIsRestarting.value = false
})
describe('Rendering & Props', () => {
it('renders cloud message when isCloud is true', () => {
mockIsCloud.value = true
const wrapper = mountCard()
expect(wrapper.text()).toContain('Unsupported node packs detected')
})
it('renders OSS message when isCloud is false', () => {
const wrapper = mountCard()
expect(wrapper.text()).toContain('Missing node packs detected')
})
it('renders correct number of MissingPackGroupRow components', () => {
const wrapper = mountCard({ missingPackGroups: makePackGroups(3) })
expect(
wrapper.findAllComponents({ name: 'MissingPackGroupRow' })
).toHaveLength(3)
})
it('renders zero rows when missingPackGroups is empty', () => {
const wrapper = mountCard({ missingPackGroups: [] })
expect(
wrapper.findAllComponents({ name: 'MissingPackGroupRow' })
).toHaveLength(0)
})
it('passes props correctly to MissingPackGroupRow children', () => {
const wrapper = mountCard({
showInfoButton: true,
showNodeIdBadge: true
})
const row = wrapper.findComponent({ name: 'MissingPackGroupRow' })
expect(row.props('showInfoButton')).toBe(true)
expect(row.props('showNodeIdBadge')).toBe(true)
})
})
describe('Apply Changes Section', () => {
it('hides Apply Changes when manager is not enabled', () => {
mockShouldShowManagerButtons.value = false
const wrapper = mountCard()
expect(wrapper.text()).not.toContain('Apply Changes')
})
it('hides Apply Changes when manager enabled but no packs pending', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(false)
const wrapper = mountCard()
expect(wrapper.text()).not.toContain('Apply Changes')
})
it('shows Apply Changes when at least one pack is pending restart', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(true)
const wrapper = mountCard()
expect(wrapper.text()).toContain('Apply Changes')
})
it('displays spinner during restart', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(true)
mockIsRestarting.value = true
const wrapper = mountCard()
expect(wrapper.find('[role="status"]').exists()).toBe(true)
})
it('disables button during restart', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(true)
mockIsRestarting.value = true
const wrapper = mountCard()
const btn = wrapper.find('button')
expect(btn.attributes('disabled')).toBeDefined()
})
it('calls applyChanges when Apply Changes button is clicked', async () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(true)
const wrapper = mountCard()
const btn = wrapper.find('button')
await btn.trigger('click')
expect(mockApplyChanges).toHaveBeenCalledOnce()
})
})
describe('Event Handling', () => {
it('emits locateNode when child emits locate-node', async () => {
const wrapper = mountCard()
const row = wrapper.findComponent({ name: 'MissingPackGroupRow' })
await row.vm.$emit('locate-node', '42')
expect(wrapper.emitted('locateNode')).toBeTruthy()
expect(wrapper.emitted('locateNode')?.[0]).toEqual(['42'])
})
it('emits openManagerInfo when child emits open-manager-info', async () => {
const wrapper = mountCard()
const row = wrapper.findComponent({ name: 'MissingPackGroupRow' })
await row.vm.$emit('open-manager-info', 'pack-0')
expect(wrapper.emitted('openManagerInfo')).toBeTruthy()
expect(wrapper.emitted('openManagerInfo')?.[0]).toEqual(['pack-0'])
})
})
})

View File

@@ -0,0 +1,368 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import PrimeVue from 'primevue/config'
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
const mockInstallAllPacks = vi.fn()
const mockIsInstalling = ref(false)
const mockIsPackInstalled = vi.fn(() => false)
const mockShouldShowManagerButtons = { value: false }
const mockOpenManager = vi.fn()
const mockMissingNodePacks = ref<Array<{ id: string; name: string }>>([])
const mockIsLoading = ref(false)
vi.mock(
'@/workbench/extensions/manager/composables/nodePack/useMissingNodes',
() => ({
useMissingNodes: () => ({
missingNodePacks: mockMissingNodePacks,
isLoading: mockIsLoading
})
})
)
vi.mock(
'@/workbench/extensions/manager/composables/nodePack/usePackInstall',
() => ({
usePackInstall: () => ({
isInstalling: mockIsInstalling,
installAllPacks: mockInstallAllPacks
})
})
)
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: () => ({
isPackInstalled: mockIsPackInstalled
})
}))
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
useManagerState: () => ({
shouldShowManagerButtons: mockShouldShowManagerButtons,
openManager: mockOpenManager
})
}))
vi.mock('@/workbench/extensions/manager/types/comfyManagerTypes', () => ({
ManagerTab: { Missing: 'missing', All: 'all' }
}))
import MissingPackGroupRow from './MissingPackGroupRow.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
loading: 'Loading'
},
rightSidePanel: {
locateNode: 'Locate node on canvas',
missingNodePacks: {
unknownPack: 'Unknown pack',
installNodePack: 'Install node pack',
installing: 'Installing...',
installed: 'Installed',
searchInManager: 'Search in Node Manager',
viewInManager: 'View in Manager',
collapse: 'Collapse',
expand: 'Expand'
}
}
}
},
missingWarn: false,
fallbackWarn: false
})
function makeGroup(
overrides: Partial<MissingPackGroup> = {}
): MissingPackGroup {
return {
packId: 'my-pack',
nodeTypes: [
{ type: 'MissingA', nodeId: '10', isReplaceable: false },
{ type: 'MissingB', nodeId: '11', isReplaceable: false }
],
isResolving: false,
...overrides
}
}
function mountRow(
props: Partial<{
group: MissingPackGroup
showInfoButton: boolean
showNodeIdBadge: boolean
}> = {}
) {
return mount(MissingPackGroupRow, {
props: {
group: makeGroup(),
showInfoButton: false,
showNodeIdBadge: false,
...props
},
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n],
stubs: {
TransitionCollapse: { template: '<div><slot /></div>' },
DotSpinner: {
template: '<span role="status" aria-label="loading" />'
}
}
}
})
}
describe('MissingPackGroupRow', () => {
beforeEach(() => {
mockInstallAllPacks.mockClear()
mockOpenManager.mockClear()
mockIsPackInstalled.mockReset()
mockIsPackInstalled.mockReturnValue(false)
mockShouldShowManagerButtons.value = false
mockIsInstalling.value = false
mockMissingNodePacks.value = []
mockIsLoading.value = false
})
describe('Basic Rendering', () => {
it('renders pack name from packId', () => {
const wrapper = mountRow()
expect(wrapper.text()).toContain('my-pack')
})
it('renders "Unknown pack" when packId is null', () => {
const wrapper = mountRow({ group: makeGroup({ packId: null }) })
expect(wrapper.text()).toContain('Unknown pack')
})
it('renders loading text when isResolving is true', () => {
const wrapper = mountRow({ group: makeGroup({ isResolving: true }) })
expect(wrapper.text()).toContain('Loading')
})
it('renders node count', () => {
const wrapper = mountRow()
expect(wrapper.text()).toContain('(2)')
})
it('renders count of 5 for 5 nodeTypes', () => {
const wrapper = mountRow({
group: makeGroup({
nodeTypes: Array.from({ length: 5 }, (_, i) => ({
type: `Node${i}`,
nodeId: String(i),
isReplaceable: false
}))
})
})
expect(wrapper.text()).toContain('(5)')
})
})
describe('Expand / Collapse', () => {
it('starts collapsed', () => {
const wrapper = mountRow()
expect(wrapper.text()).not.toContain('MissingA')
})
it('expands when chevron is clicked', async () => {
const wrapper = mountRow()
await wrapper.get('button[aria-label="Expand"]').trigger('click')
expect(wrapper.text()).toContain('MissingA')
expect(wrapper.text()).toContain('MissingB')
})
it('collapses when chevron is clicked again', async () => {
const wrapper = mountRow()
await wrapper.get('button[aria-label="Expand"]').trigger('click')
expect(wrapper.text()).toContain('MissingA')
await wrapper.get('button[aria-label="Collapse"]').trigger('click')
expect(wrapper.text()).not.toContain('MissingA')
})
})
describe('Node Type List', () => {
async function expand(wrapper: ReturnType<typeof mountRow>) {
await wrapper.get('button[aria-label="Expand"]').trigger('click')
}
it('renders all nodeTypes when expanded', async () => {
const wrapper = mountRow({
group: makeGroup({
nodeTypes: [
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeB', nodeId: '2', isReplaceable: false },
{ type: 'NodeC', nodeId: '3', isReplaceable: false }
]
})
})
await expand(wrapper)
expect(wrapper.text()).toContain('NodeA')
expect(wrapper.text()).toContain('NodeB')
expect(wrapper.text()).toContain('NodeC')
})
it('shows nodeId badge when showNodeIdBadge is true', async () => {
const wrapper = mountRow({ showNodeIdBadge: true })
await expand(wrapper)
expect(wrapper.text()).toContain('#10')
})
it('hides nodeId badge when showNodeIdBadge is false', async () => {
const wrapper = mountRow({ showNodeIdBadge: false })
await expand(wrapper)
expect(wrapper.text()).not.toContain('#10')
})
it('emits locateNode when Locate button is clicked', async () => {
const wrapper = mountRow({ showNodeIdBadge: true })
await expand(wrapper)
await wrapper
.get('button[aria-label="Locate node on canvas"]')
.trigger('click')
expect(wrapper.emitted('locateNode')).toBeTruthy()
expect(wrapper.emitted('locateNode')?.[0]).toEqual(['10'])
})
it('does not show Locate for nodeType without nodeId', async () => {
const wrapper = mountRow({
group: makeGroup({
nodeTypes: [{ type: 'NoId', isReplaceable: false } as never]
})
})
await expand(wrapper)
expect(
wrapper.find('button[aria-label="Locate node on canvas"]').exists()
).toBe(false)
})
it('handles mixed nodeTypes with and without nodeId', async () => {
const wrapper = mountRow({
showNodeIdBadge: true,
group: makeGroup({
nodeTypes: [
{ type: 'WithId', nodeId: '100', isReplaceable: false },
{ type: 'WithoutId', isReplaceable: false } as never
]
})
})
await expand(wrapper)
expect(wrapper.text()).toContain('WithId')
expect(wrapper.text()).toContain('WithoutId')
expect(
wrapper.findAll('button[aria-label="Locate node on canvas"]')
).toHaveLength(1)
})
})
describe('Manager Integration', () => {
it('hides install UI when shouldShowManagerButtons is false', () => {
mockShouldShowManagerButtons.value = false
const wrapper = mountRow()
expect(wrapper.text()).not.toContain('Install node pack')
})
it('hides install UI when packId is null', () => {
mockShouldShowManagerButtons.value = true
const wrapper = mountRow({ group: makeGroup({ packId: null }) })
expect(wrapper.text()).not.toContain('Install node pack')
})
it('shows "Search in Node Manager" when packId exists but pack not in registry', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = []
const wrapper = mountRow()
expect(wrapper.text()).toContain('Search in Node Manager')
})
it('shows "Installed" state when pack is installed', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(true)
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
const wrapper = mountRow()
expect(wrapper.text()).toContain('Installed')
})
it('shows spinner when installing', () => {
mockShouldShowManagerButtons.value = true
mockIsInstalling.value = true
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
const wrapper = mountRow()
expect(wrapper.find('[role="status"]').exists()).toBe(true)
})
it('shows install button when not installed and pack found', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
const wrapper = mountRow()
expect(wrapper.text()).toContain('Install node pack')
})
it('calls installAllPacks when Install button is clicked', async () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
const wrapper = mountRow()
await wrapper.get('button:not([aria-label])').trigger('click')
expect(mockInstallAllPacks).toHaveBeenCalledOnce()
})
it('shows loading spinner when registry is loading', () => {
mockShouldShowManagerButtons.value = true
mockIsLoading.value = true
const wrapper = mountRow()
expect(wrapper.find('[role="status"]').exists()).toBe(true)
})
})
describe('Info Button', () => {
it('shows Info button when showInfoButton true and packId not null', () => {
const wrapper = mountRow({ showInfoButton: true })
expect(
wrapper.find('button[aria-label="View in Manager"]').exists()
).toBe(true)
})
it('hides Info button when showInfoButton is false', () => {
const wrapper = mountRow({ showInfoButton: false })
expect(
wrapper.find('button[aria-label="View in Manager"]').exists()
).toBe(false)
})
it('hides Info button when packId is null', () => {
const wrapper = mountRow({
showInfoButton: true,
group: makeGroup({ packId: null })
})
expect(
wrapper.find('button[aria-label="View in Manager"]').exists()
).toBe(false)
})
it('emits openManagerInfo when Info button is clicked', async () => {
const wrapper = mountRow({ showInfoButton: true })
await wrapper.get('button[aria-label="View in Manager"]').trigger('click')
expect(wrapper.emitted('openManagerInfo')).toBeTruthy()
expect(wrapper.emitted('openManagerInfo')?.[0]).toEqual(['my-pack'])
})
})
describe('Edge Cases', () => {
it('handles empty nodeTypes array', () => {
const wrapper = mountRow({ group: makeGroup({ nodeTypes: [] }) })
expect(wrapper.text()).toContain('(0)')
})
})
})

View File

@@ -1,150 +0,0 @@
<template>
<div class="flex flex-col w-full mb-4">
<!-- Type header row: type name + chevron -->
<div class="flex h-8 items-center w-full">
<p
class="flex-1 min-w-0 text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap text-foreground"
>
{{ `${group.type} (${group.nodeTypes.length})` }}
</p>
<Button
variant="textonly"
size="icon-sm"
:class="
cn(
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
{ 'rotate-180': expanded }
)
"
:aria-label="
expanded
? t('rightSidePanel.missingNodePacks.collapse', 'Collapse')
: t('rightSidePanel.missingNodePacks.expand', 'Expand')
"
@click="toggleExpand"
>
<i
class="icon-[lucide--chevron-down] size-4 text-muted-foreground group-hover:text-base-foreground"
/>
</Button>
</div>
<!-- Sub-labels: individual node instances, each with their own Locate button -->
<TransitionCollapse>
<div
v-if="expanded"
class="flex flex-col gap-0.5 pl-2 mb-2 overflow-hidden"
>
<div
v-for="nodeType in group.nodeTypes"
:key="getKey(nodeType)"
class="flex h-7 items-center"
>
<span
v-if="
showNodeIdBadge &&
typeof nodeType !== 'string' &&
nodeType.nodeId != null
"
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 text-xs font-mono text-muted-foreground font-bold mr-1"
>
#{{ nodeType.nodeId }}
</span>
<p class="flex-1 min-w-0 text-xs text-muted-foreground truncate">
{{ getLabel(nodeType) }}
</p>
<Button
v-if="typeof nodeType !== 'string' && nodeType.nodeId != null"
variant="textonly"
size="icon-sm"
class="size-6 text-muted-foreground hover:text-base-foreground shrink-0 mr-1"
:aria-label="t('rightSidePanel.locateNode', 'Locate Node')"
@click="handleLocateNode(nodeType)"
>
<i class="icon-[lucide--locate] size-3" />
</Button>
</div>
</div>
</TransitionCollapse>
<!-- Description rows: what it is replaced by -->
<div class="flex flex-col text-[13px] mb-2 mt-1 px-1 gap-0.5">
<span class="text-muted-foreground">{{
t('nodeReplacement.willBeReplacedBy', 'This node will be replaced by:')
}}</span>
<span class="font-bold text-foreground">{{
group.newNodeId ?? t('nodeReplacement.unknownNode', 'Unknown')
}}</span>
</div>
<!-- Replace Action Button -->
<div class="flex items-start w-full pt-1 pb-1">
<Button
variant="secondary"
size="md"
class="flex flex-1 w-full"
@click="handleReplaceNode"
>
<i class="icon-[lucide--repeat] size-4 text-foreground shrink-0 mr-1" />
<span class="text-sm text-foreground truncate min-w-0">
{{ t('nodeReplacement.replaceNode', 'Replace Node') }}
</span>
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
import type { MissingNodeType } from '@/types/comfy'
import type { SwapNodeGroup } from './useErrorGroups'
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
const props = defineProps<{
group: SwapNodeGroup
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{
'locate-node': [nodeId: string]
}>()
const { t } = useI18n()
const { replaceNodesInPlace } = useNodeReplacement()
const executionErrorStore = useExecutionErrorStore()
const expanded = ref(false)
function toggleExpand() {
expanded.value = !expanded.value
}
function getKey(nodeType: MissingNodeType): string {
if (typeof nodeType === 'string') return nodeType
return nodeType.nodeId != null ? String(nodeType.nodeId) : nodeType.type
}
function getLabel(nodeType: MissingNodeType): string {
return typeof nodeType === 'string' ? nodeType : nodeType.type
}
function handleLocateNode(nodeType: MissingNodeType) {
if (typeof nodeType === 'string') return
if (nodeType.nodeId != null) {
emit('locate-node', String(nodeType.nodeId))
}
}
function handleReplaceNode() {
const replaced = replaceNodesInPlace(props.group.nodeTypes)
if (replaced.length > 0) {
executionErrorStore.removeMissingNodesByType([props.group.type])
}
}
</script>

View File

@@ -1,38 +0,0 @@
<template>
<div class="px-4 pb-2 mt-2">
<!-- Sub-label: guidance message shown above all swap groups -->
<p class="m-0 pb-5 text-sm text-muted-foreground leading-relaxed">
{{
t(
'nodeReplacement.swapNodesGuide',
'The following nodes can be automatically replaced with compatible alternatives.'
)
}}
</p>
<!-- Group Rows -->
<SwapNodeGroupRow
v-for="group in swapNodeGroups"
:key="group.type"
:group="group"
:show-node-id-badge="showNodeIdBadge"
@locate-node="emit('locate-node', $event)"
/>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { SwapNodeGroup } from './useErrorGroups'
import SwapNodeGroupRow from './SwapNodeGroupRow.vue'
const { t } = useI18n()
const { swapNodeGroups, showNodeIdBadge } = defineProps<{
swapNodeGroups: SwapNodeGroup[]
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{
'locate-node': [nodeId: string]
}>()
</script>

View File

@@ -109,6 +109,7 @@
:swap-node-groups="swapNodeGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-node="handleLocateMissingNode"
@replace="handleReplaceGroup"
/>
<!-- Execution Errors -->
@@ -179,14 +180,14 @@ import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import ErrorNodeCard from './ErrorNodeCard.vue'
import MissingNodeCard from './MissingNodeCard.vue'
import SwapNodesCard from './SwapNodesCard.vue'
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useErrorGroups } from './useErrorGroups'
import type { SwapNodeGroup } from './useErrorGroups'
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
@@ -199,8 +200,7 @@ const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
const { missingNodePacks } = useMissingNodes()
const { isInstalling: isInstallingAll, installAllPacks: installAll } =
usePackInstall(() => missingNodePacks.value)
const { replaceNodesInPlace } = useNodeReplacement()
const executionErrorStore = useExecutionErrorStore()
const { replaceGroup, replaceAllGroups } = useNodeReplacement()
const searchQuery = ref('')
@@ -264,12 +264,12 @@ function handleOpenManagerInfo(packId: string) {
}
}
function handleReplaceGroup(group: SwapNodeGroup) {
replaceGroup(group)
}
function handleReplaceAll() {
const allNodeTypes = swapNodeGroups.value.flatMap((g) => g.nodeTypes)
const replaced = replaceNodesInPlace(allNodeTypes)
if (replaced.length > 0) {
executionErrorStore.removeMissingNodesByType(replaced)
}
replaceAllGroups(swapNodeGroups.value)
}
function handleEnterSubgraph(nodeId: string) {

View File

@@ -0,0 +1,187 @@
import { createPinia, setActivePinia } from 'pinia'
import { nextTick, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MissingNodeType } from '@/types/comfy'
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: {
serialize: vi.fn(() => ({})),
getNodeById: vi.fn()
}
}
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByExecutionId: vi.fn(),
getExecutionIdByNode: vi.fn(),
getRootParentNode: vi.fn(() => null),
forEachNode: vi.fn(),
mapAllNodes: vi.fn(() => [])
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
vi.mock('@/i18n', () => ({
st: vi.fn((_key: string, fallback: string) => fallback)
}))
vi.mock('@/stores/comfyRegistryStore', () => ({
useComfyRegistryStore: () => ({
inferPackFromNodeName: vi.fn()
})
}))
vi.mock('@/utils/nodeTitleUtil', () => ({
resolveNodeDisplayName: vi.fn(() => '')
}))
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn(() => false)
}))
vi.mock('@/utils/executableGroupNodeDto', () => ({
isGroupNode: vi.fn(() => false)
}))
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useErrorGroups } from './useErrorGroups'
function makeMissingNodeType(
type: string,
opts: {
nodeId?: string
isReplaceable?: boolean
replacement?: { new_node_id: string }
} = {}
): MissingNodeType {
return {
type,
nodeId: opts.nodeId ?? '1',
isReplaceable: opts.isReplaceable ?? false,
replacement: opts.replacement
? {
old_node_id: type,
new_node_id: opts.replacement.new_node_id,
old_widget_ids: null,
input_mapping: null,
output_mapping: null
}
: undefined
}
}
describe('swapNodeGroups computed', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
function getSwapNodeGroups(nodeTypes: MissingNodeType[]) {
const store = useExecutionErrorStore()
store.surfaceMissingNodes(nodeTypes)
const searchQuery = ref('')
const t = (key: string) => key
const { swapNodeGroups } = useErrorGroups(searchQuery, t)
return swapNodeGroups
}
it('returns empty array when no missing nodes', () => {
const swap = getSwapNodeGroups([])
expect(swap.value).toEqual([])
})
it('returns empty array when no nodes are replaceable', () => {
const swap = getSwapNodeGroups([
makeMissingNodeType('NodeA', { isReplaceable: false }),
makeMissingNodeType('NodeB', { isReplaceable: false })
])
expect(swap.value).toEqual([])
})
it('groups replaceable nodes by type', async () => {
const swap = getSwapNodeGroups([
makeMissingNodeType('OldNode', {
nodeId: '1',
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
}),
makeMissingNodeType('OldNode', {
nodeId: '2',
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
})
])
await nextTick()
expect(swap.value).toHaveLength(1)
expect(swap.value[0].type).toBe('OldNode')
expect(swap.value[0].newNodeId).toBe('NewNode')
expect(swap.value[0].nodeTypes).toHaveLength(2)
})
it('creates separate groups for different types', async () => {
const swap = getSwapNodeGroups([
makeMissingNodeType('TypeA', {
nodeId: '1',
isReplaceable: true,
replacement: { new_node_id: 'NewA' }
}),
makeMissingNodeType('TypeB', {
nodeId: '2',
isReplaceable: true,
replacement: { new_node_id: 'NewB' }
})
])
await nextTick()
expect(swap.value).toHaveLength(2)
expect(swap.value.map((g) => g.type)).toEqual(['TypeA', 'TypeB'])
})
it('sorts groups alphabetically by type', async () => {
const swap = getSwapNodeGroups([
makeMissingNodeType('Zebra', {
nodeId: '1',
isReplaceable: true,
replacement: { new_node_id: 'NewZ' }
}),
makeMissingNodeType('Alpha', {
nodeId: '2',
isReplaceable: true,
replacement: { new_node_id: 'NewA' }
})
])
await nextTick()
expect(swap.value[0].type).toBe('Alpha')
expect(swap.value[1].type).toBe('Zebra')
})
it('excludes string nodeType entries', async () => {
const swap = getSwapNodeGroups([
'StringGroupNode' as unknown as MissingNodeType,
makeMissingNodeType('OldNode', {
nodeId: '1',
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
})
])
await nextTick()
expect(swap.value).toHaveLength(1)
expect(swap.value[0].type).toBe('OldNode')
})
it('sets newNodeId to undefined when replacement is missing', async () => {
const swap = getSwapNodeGroups([
makeMissingNodeType('OldNode', {
nodeId: '1',
isReplaceable: true
// no replacement
})
])
await nextTick()
expect(swap.value).toHaveLength(1)
expect(swap.value[0].newNodeId).toBeUndefined()
})
})

View File

@@ -0,0 +1,439 @@
import { createPinia, setActivePinia } from 'pinia'
import { nextTick, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MissingNodeType } from '@/types/comfy'
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: {
serialize: vi.fn(() => ({})),
getNodeById: vi.fn()
}
}
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByExecutionId: vi.fn(),
getExecutionIdByNode: vi.fn(),
getRootParentNode: vi.fn(() => null),
forEachNode: vi.fn(),
mapAllNodes: vi.fn(() => [])
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
vi.mock('@/i18n', () => ({
st: vi.fn((_key: string, fallback: string) => fallback)
}))
vi.mock('@/stores/comfyRegistryStore', () => ({
useComfyRegistryStore: () => ({
inferPackFromNodeName: vi.fn()
})
}))
vi.mock('@/utils/nodeTitleUtil', () => ({
resolveNodeDisplayName: vi.fn(() => '')
}))
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn(() => false)
}))
vi.mock('@/utils/executableGroupNodeDto', () => ({
isGroupNode: vi.fn(() => false)
}))
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useErrorGroups } from './useErrorGroups'
function makeMissingNodeType(
type: string,
opts: {
nodeId?: string
isReplaceable?: boolean
cnrId?: string
replacement?: { new_node_id: string }
} = {}
): MissingNodeType {
return {
type,
nodeId: opts.nodeId ?? '1',
isReplaceable: opts.isReplaceable ?? false,
cnrId: opts.cnrId,
replacement: opts.replacement
? {
old_node_id: type,
new_node_id: opts.replacement.new_node_id,
old_widget_ids: null,
input_mapping: null,
output_mapping: null
}
: undefined
}
}
function createErrorGroups() {
const store = useExecutionErrorStore()
const searchQuery = ref('')
const t = (key: string) => key
const groups = useErrorGroups(searchQuery, t)
return { store, searchQuery, groups }
}
describe('useErrorGroups', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
describe('missingPackGroups', () => {
it('returns empty array when no missing nodes', () => {
const { groups } = createErrorGroups()
expect(groups.missingPackGroups.value).toEqual([])
})
it('groups non-replaceable nodes by cnrId', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'pack-1' }),
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' }),
makeMissingNodeType('NodeC', { cnrId: 'pack-2', nodeId: '3' })
])
await nextTick()
expect(groups.missingPackGroups.value).toHaveLength(2)
const pack1 = groups.missingPackGroups.value.find(
(g) => g.packId === 'pack-1'
)
expect(pack1?.nodeTypes).toHaveLength(2)
const pack2 = groups.missingPackGroups.value.find(
(g) => g.packId === 'pack-2'
)
expect(pack2?.nodeTypes).toHaveLength(1)
})
it('excludes replaceable nodes from missingPackGroups', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
}),
makeMissingNodeType('MissingNode', {
nodeId: '2',
cnrId: 'some-pack'
})
])
await nextTick()
expect(groups.missingPackGroups.value).toHaveLength(1)
expect(groups.missingPackGroups.value[0].packId).toBe('some-pack')
})
it('groups nodes without cnrId under null packId', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('UnknownNode', { nodeId: '1' }),
makeMissingNodeType('AnotherUnknown', { nodeId: '2' })
])
await nextTick()
expect(groups.missingPackGroups.value).toHaveLength(1)
expect(groups.missingPackGroups.value[0].packId).toBeNull()
expect(groups.missingPackGroups.value[0].nodeTypes).toHaveLength(2)
})
it('sorts groups alphabetically with null packId last', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'zebra-pack' }),
makeMissingNodeType('NodeB', { nodeId: '2' }),
makeMissingNodeType('NodeC', { cnrId: 'alpha-pack', nodeId: '3' })
])
await nextTick()
const packIds = groups.missingPackGroups.value.map((g) => g.packId)
expect(packIds).toEqual(['alpha-pack', 'zebra-pack', null])
})
it('sorts nodeTypes within each group alphabetically by type then nodeId', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' }),
makeMissingNodeType('NodeA', { cnrId: 'pack-1', nodeId: '3' }),
makeMissingNodeType('NodeA', { cnrId: 'pack-1', nodeId: '1' })
])
await nextTick()
const group = groups.missingPackGroups.value[0]
const types = group.nodeTypes.map((n) =>
typeof n === 'string' ? n : `${n.type}:${n.nodeId}`
)
expect(types).toEqual(['NodeA:1', 'NodeA:3', 'NodeB:2'])
})
it('handles string nodeType entries', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
'StringGroupNode' as unknown as MissingNodeType
])
await nextTick()
expect(groups.missingPackGroups.value).toHaveLength(1)
expect(groups.missingPackGroups.value[0].packId).toBeNull()
})
})
describe('allErrorGroups', () => {
it('returns empty array when no errors', () => {
const { groups } = createErrorGroups()
expect(groups.allErrorGroups.value).toEqual([])
})
it('includes missing_node group when missing nodes exist', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
])
await nextTick()
const missingGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'missing_node'
)
expect(missingGroup).toBeDefined()
})
it('includes swap_nodes group when replaceable nodes exist', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
})
])
await nextTick()
const swapGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'swap_nodes'
)
expect(swapGroup).toBeDefined()
})
it('includes both swap_nodes and missing_node when both exist', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
}),
makeMissingNodeType('MissingNode', {
nodeId: '2',
cnrId: 'some-pack'
})
])
await nextTick()
const types = groups.allErrorGroups.value.map((g) => g.type)
expect(types).toContain('swap_nodes')
expect(types).toContain('missing_node')
})
it('swap_nodes has lower priority than missing_node', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
}),
makeMissingNodeType('MissingNode', {
nodeId: '2',
cnrId: 'some-pack'
})
])
await nextTick()
const swapIdx = groups.allErrorGroups.value.findIndex(
(g) => g.type === 'swap_nodes'
)
const missingIdx = groups.allErrorGroups.value.findIndex(
(g) => g.type === 'missing_node'
)
expect(swapIdx).toBeLessThan(missingIdx)
})
it('includes execution error groups from node errors', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [
{
type: 'value_not_valid',
message: 'Value not valid',
details: 'some detail'
}
]
}
}
await nextTick()
const execGroups = groups.allErrorGroups.value.filter(
(g) => g.type === 'execution'
)
expect(execGroups.length).toBeGreaterThan(0)
})
it('includes execution error from runtime errors', async () => {
const { store, groups } = createErrorGroups()
store.lastExecutionError = {
prompt_id: 'test-prompt',
timestamp: Date.now(),
node_id: 5,
node_type: 'KSampler',
executed: [],
exception_type: 'RuntimeError',
exception_message: 'CUDA out of memory',
traceback: ['line 1', 'line 2'],
current_inputs: {},
current_outputs: {}
}
await nextTick()
const execGroups = groups.allErrorGroups.value.filter(
(g) => g.type === 'execution'
)
expect(execGroups.length).toBeGreaterThan(0)
})
it('includes prompt error when present', async () => {
const { store, groups } = createErrorGroups()
store.lastPromptError = {
type: 'prompt_no_outputs',
message: 'No outputs',
details: ''
}
await nextTick()
const promptGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution' && g.title === 'No outputs'
)
expect(promptGroup).toBeDefined()
})
})
describe('filteredGroups', () => {
it('returns all groups when search query is empty', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'value_error', message: 'Bad value', details: '' }]
}
}
await nextTick()
expect(groups.filteredGroups.value.length).toBeGreaterThan(0)
})
it('filters groups based on search query', async () => {
const { store, groups, searchQuery } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [
{
type: 'value_error',
message: 'Value error in sampler',
details: ''
}
]
},
'2': {
class_type: 'CLIPLoader',
dependent_outputs: [],
errors: [
{
type: 'file_not_found',
message: 'File not found',
details: ''
}
]
}
}
await nextTick()
searchQuery.value = 'sampler'
await nextTick()
const executionGroups = groups.filteredGroups.value.filter(
(g) => g.type === 'execution'
)
for (const group of executionGroups) {
if (group.type !== 'execution') continue
const hasMatch = group.cards.some(
(c) =>
c.title.toLowerCase().includes('sampler') ||
c.errors.some((e) => e.message.toLowerCase().includes('sampler'))
)
expect(hasMatch).toBe(true)
}
})
})
describe('groupedErrorMessages', () => {
it('returns empty array when no errors', () => {
const { groups } = createErrorGroups()
expect(groups.groupedErrorMessages.value).toEqual([])
})
it('collects unique error messages from node errors', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [
{ type: 'err_a', message: 'Error A', details: '' },
{ type: 'err_b', message: 'Error B', details: '' }
]
},
'2': {
class_type: 'CLIPLoader',
dependent_outputs: [],
errors: [{ type: 'err_a', message: 'Error A', details: '' }]
}
}
await nextTick()
const messages = groups.groupedErrorMessages.value
expect(messages).toContain('Error A')
expect(messages).toContain('Error B')
// Deduplication: Error A appears twice but should only be listed once
expect(messages.filter((m) => m === 'Error A')).toHaveLength(1)
})
it('includes missing node group title as message', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
])
await nextTick()
expect(groups.groupedErrorMessages.value.length).toBeGreaterThan(0)
})
})
describe('collapseState', () => {
it('returns a reactive object', () => {
const { groups } = createErrorGroups()
expect(groups.collapseState).toBeDefined()
expect(typeof groups.collapseState).toBe('object')
})
})
})