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,244 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { SwapNodeGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
import type { MissingNodeType } from '@/types/comfy'
import SwapNodeGroupRow from './SwapNodeGroupRow.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
rightSidePanel: {
locateNode: 'Locate Node',
missingNodePacks: {
collapse: 'Collapse',
expand: 'Expand'
}
},
nodeReplacement: {
willBeReplacedBy: 'This node will be replaced by:',
replaceNode: 'Replace Node',
unknownNode: 'Unknown'
}
}
},
missingWarn: false,
fallbackWarn: false
})
function makeGroup(overrides: Partial<SwapNodeGroup> = {}): SwapNodeGroup {
return {
type: 'OldNodeType',
newNodeId: 'NewNodeType',
nodeTypes: [
{ type: 'OldNodeType', nodeId: '1', isReplaceable: true },
{ type: 'OldNodeType', nodeId: '2', isReplaceable: true }
],
...overrides
}
}
function mountRow(
props: Partial<{
group: SwapNodeGroup
showNodeIdBadge: boolean
}> = {}
) {
return mount(SwapNodeGroupRow, {
props: {
group: makeGroup(),
showNodeIdBadge: false,
...props
},
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n],
stubs: {
TransitionCollapse: { template: '<div><slot /></div>' }
}
}
})
}
describe('SwapNodeGroupRow', () => {
describe('Basic Rendering', () => {
it('renders the group type name', () => {
const wrapper = mountRow()
expect(wrapper.text()).toContain('OldNodeType')
})
it('renders node count in parentheses', () => {
const wrapper = mountRow()
expect(wrapper.text()).toContain('(2)')
})
it('renders node count of 5 for 5 nodeTypes', () => {
const wrapper = mountRow({
group: makeGroup({
nodeTypes: Array.from({ length: 5 }, (_, i) => ({
type: 'OldNodeType',
nodeId: String(i),
isReplaceable: true
}))
})
})
expect(wrapper.text()).toContain('(5)')
})
it('renders the replacement target name', () => {
const wrapper = mountRow()
expect(wrapper.text()).toContain('NewNodeType')
})
it('shows "Unknown" when newNodeId is undefined', () => {
const wrapper = mountRow({
group: makeGroup({ newNodeId: undefined })
})
expect(wrapper.text()).toContain('Unknown')
})
it('renders "Replace Node" button', () => {
const wrapper = mountRow()
expect(wrapper.text()).toContain('Replace Node')
})
})
describe('Expand / Collapse', () => {
it('starts collapsed — node list not visible', () => {
const wrapper = mountRow({ showNodeIdBadge: true })
expect(wrapper.text()).not.toContain('#1')
})
it('expands when chevron is clicked', async () => {
const wrapper = mountRow({ showNodeIdBadge: true })
await wrapper.get('button[aria-label="Expand"]').trigger('click')
expect(wrapper.text()).toContain('#1')
expect(wrapper.text()).toContain('#2')
})
it('collapses when chevron is clicked again', async () => {
const wrapper = mountRow({ showNodeIdBadge: true })
await wrapper.get('button[aria-label="Expand"]').trigger('click')
expect(wrapper.text()).toContain('#1')
await wrapper.get('button[aria-label="Collapse"]').trigger('click')
expect(wrapper.text()).not.toContain('#1')
})
it('updates the toggle control state when expanded', async () => {
const wrapper = mountRow()
expect(wrapper.find('button[aria-label="Expand"]').exists()).toBe(true)
await wrapper.get('button[aria-label="Expand"]').trigger('click')
expect(wrapper.find('button[aria-label="Collapse"]').exists()).toBe(true)
})
})
describe('Node Type List (Expanded)', () => {
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: 'OldNodeType', nodeId: '10', isReplaceable: true },
{ type: 'OldNodeType', nodeId: '20', isReplaceable: true },
{ type: 'OldNodeType', nodeId: '30', isReplaceable: true }
]
}),
showNodeIdBadge: true
})
await expand(wrapper)
expect(wrapper.text()).toContain('#10')
expect(wrapper.text()).toContain('#20')
expect(wrapper.text()).toContain('#30')
})
it('shows nodeId badge when showNodeIdBadge is true', async () => {
const wrapper = mountRow({ showNodeIdBadge: true })
await expand(wrapper)
expect(wrapper.text()).toContain('#1')
expect(wrapper.text()).toContain('#2')
})
it('hides nodeId badge when showNodeIdBadge is false', async () => {
const wrapper = mountRow({ showNodeIdBadge: false })
await expand(wrapper)
expect(wrapper.text()).not.toContain('#1')
expect(wrapper.text()).not.toContain('#2')
})
it('renders Locate button for each nodeType with nodeId', async () => {
const wrapper = mountRow({ showNodeIdBadge: true })
await expand(wrapper)
expect(wrapper.findAll('button[aria-label="Locate Node"]')).toHaveLength(
2
)
})
it('does not render Locate button for nodeTypes without nodeId', async () => {
const wrapper = mountRow({
group: makeGroup({
// Intentionally omits nodeId to test graceful handling of incomplete node data
nodeTypes: [
{ type: 'NoIdNode', isReplaceable: true }
] as unknown as MissingNodeType[]
})
})
await expand(wrapper)
expect(wrapper.find('button[aria-label="Locate Node"]').exists()).toBe(
false
)
})
})
describe('Events', () => {
it('emits locate-node with correct nodeId', async () => {
const wrapper = mountRow({ showNodeIdBadge: true })
await wrapper.get('button[aria-label="Expand"]').trigger('click')
const locateBtns = wrapper.findAll('button[aria-label="Locate Node"]')
await locateBtns[0].trigger('click')
expect(wrapper.emitted('locate-node')).toBeTruthy()
expect(wrapper.emitted('locate-node')?.[0]).toEqual(['1'])
await locateBtns[1].trigger('click')
expect(wrapper.emitted('locate-node')?.[1]).toEqual(['2'])
})
it('emits replace with group when Replace button is clicked', async () => {
const group = makeGroup()
const wrapper = mountRow({ group })
const replaceBtn = wrapper
.findAll('button')
.find((b) => b.text().includes('Replace Node'))
if (!replaceBtn) throw new Error('Replace button not found')
await replaceBtn.trigger('click')
expect(wrapper.emitted('replace')).toBeTruthy()
expect(wrapper.emitted('replace')?.[0][0]).toEqual(group)
})
})
describe('Edge Cases', () => {
it('handles empty nodeTypes array', () => {
const wrapper = mountRow({
group: makeGroup({ nodeTypes: [] })
})
expect(wrapper.text()).toContain('(0)')
})
it('handles string nodeType entries', async () => {
const wrapper = mountRow({
group: makeGroup({
// Intentionally uses a plain string entry to test legacy node type handling
nodeTypes: ['StringType'] as unknown as MissingNodeType[]
})
})
await wrapper.get('button[aria-label="Expand"]').trigger('click')
expect(wrapper.text()).toContain('StringType')
})
})
})

View File

@@ -0,0 +1,144 @@
<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 '@/components/rightSidePanel/errors/useErrorGroups'
const props = defineProps<{
group: SwapNodeGroup
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{
'locate-node': [nodeId: string]
replace: [group: SwapNodeGroup]
}>()
const { t } = useI18n()
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() {
emit('replace', props.group)
}
</script>

View File

@@ -0,0 +1,129 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { SwapNodeGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
vi.mock('./SwapNodeGroupRow.vue', () => ({
default: {
name: 'SwapNodeGroupRow',
template: '<div class="swap-row" />',
props: ['group', 'showNodeIdBadge'],
emits: ['locate-node', 'replace']
}
}))
import SwapNodesCard from './SwapNodesCard.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false
})
function makeGroups(count = 2): SwapNodeGroup[] {
return Array.from({ length: count }, (_, i) => ({
type: `Type${i}`,
newNodeId: `NewType${i}`,
nodeTypes: [{ type: `Type${i}`, nodeId: String(i), isReplaceable: true }]
}))
}
function mountCard(
props: Partial<{
swapNodeGroups: SwapNodeGroup[]
showNodeIdBadge: boolean
}> = {}
) {
return mount(SwapNodesCard, {
props: {
swapNodeGroups: makeGroups(),
showNodeIdBadge: false,
...props
},
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n]
}
})
}
describe('SwapNodesCard', () => {
describe('Rendering', () => {
it('renders guidance message', () => {
const wrapper = mountCard()
expect(wrapper.find('p').exists()).toBe(true)
})
it('renders correct number of SwapNodeGroupRow components', () => {
const wrapper = mountCard({ swapNodeGroups: makeGroups(3) })
expect(
wrapper.findAllComponents({ name: 'SwapNodeGroupRow' })
).toHaveLength(3)
})
it('renders zero rows when swapNodeGroups is empty', () => {
const wrapper = mountCard({ swapNodeGroups: [] })
expect(
wrapper.findAllComponents({ name: 'SwapNodeGroupRow' })
).toHaveLength(0)
})
it('renders one row when swapNodeGroups has one entry', () => {
const wrapper = mountCard({ swapNodeGroups: makeGroups(1) })
expect(
wrapper.findAllComponents({ name: 'SwapNodeGroupRow' })
).toHaveLength(1)
})
it('passes showNodeIdBadge to children', () => {
const wrapper = mountCard({
swapNodeGroups: makeGroups(1),
showNodeIdBadge: true
})
const row = wrapper.findComponent({ name: 'SwapNodeGroupRow' })
expect(row.props('showNodeIdBadge')).toBe(true)
})
it('passes group prop to children', () => {
const groups = makeGroups(1)
const wrapper = mountCard({ swapNodeGroups: groups })
const row = wrapper.findComponent({ name: 'SwapNodeGroupRow' })
expect(row.props('group')).toEqual(groups[0])
})
})
describe('Events', () => {
it('bubbles locate-node event from child', async () => {
const wrapper = mountCard()
const row = wrapper.findComponent({ name: 'SwapNodeGroupRow' })
await row.vm.$emit('locate-node', '42')
expect(wrapper.emitted('locate-node')).toBeTruthy()
expect(wrapper.emitted('locate-node')?.[0]).toEqual(['42'])
})
it('bubbles replace event from child', async () => {
const groups = makeGroups(1)
const wrapper = mountCard({ swapNodeGroups: groups })
const row = wrapper.findComponent({ name: 'SwapNodeGroupRow' })
await row.vm.$emit('replace', groups[0])
expect(wrapper.emitted('replace')).toBeTruthy()
expect(wrapper.emitted('replace')?.[0][0]).toEqual(groups[0])
})
it('bubbles events from correct child when multiple rows', async () => {
const groups = makeGroups(3)
const wrapper = mountCard({ swapNodeGroups: groups })
const rows = wrapper.findAllComponents({ name: 'SwapNodeGroupRow' })
await rows[2].vm.$emit('locate-node', '99')
expect(wrapper.emitted('locate-node')?.[0]).toEqual(['99'])
await rows[1].vm.$emit('replace', groups[1])
expect(wrapper.emitted('replace')?.[0][0]).toEqual(groups[1])
})
})
})

View File

@@ -0,0 +1,40 @@
<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)"
@replace="emit('replace', $event)"
/>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { SwapNodeGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
import SwapNodeGroupRow from '@/platform/nodeReplacement/components/SwapNodeGroupRow.vue'
const { t } = useI18n()
const { swapNodeGroups, showNodeIdBadge } = defineProps<{
swapNodeGroups: SwapNodeGroup[]
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{
'locate-node': [nodeId: string]
replace: [group: SwapNodeGroup]
}>()
</script>

View File

@@ -0,0 +1,232 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>()
return {
...actual,
LiteGraph: {
...(actual.LiteGraph as Record<string, unknown>),
registered_node_types: {} as Record<string, unknown>
}
}
})
vi.mock('@/utils/graphTraversalUtil', () => ({
collectAllNodes: vi.fn(),
getExecutionIdByNode: vi.fn()
}))
vi.mock('@/workbench/extensions/manager/utils/missingNodeErrorUtil', () => ({
getCnrIdFromNode: vi.fn(() => null)
}))
vi.mock('@/i18n', () => ({
st: vi.fn((_key: string, fallback: string) => fallback)
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
vi.mock('@/stores/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn(() => true)
})
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn(() => true)
})
}))
import {
collectAllNodes,
getExecutionIdByNode
} from '@/utils/graphTraversalUtil'
import { getCnrIdFromNode } from '@/workbench/extensions/manager/utils/missingNodeErrorUtil'
import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacementStore'
import { rescanAndSurfaceMissingNodes } from './missingNodeScan'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
function mockNode(
id: number,
type: string,
overrides: Partial<LGraphNode> = {}
): LGraphNode {
return {
id,
type,
last_serialization: { type },
...overrides
} as unknown as LGraphNode
}
function mockGraph(): LGraph {
return {} as unknown as LGraph
}
function getMissingNodesError(
store: ReturnType<typeof useExecutionErrorStore>
) {
const error = store.missingNodesError
if (!error) throw new Error('Expected missingNodesError to be defined')
return error
}
describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
// Reset registered_node_types
const reg = LiteGraph.registered_node_types as Record<string, unknown>
for (const key of Object.keys(reg)) {
delete reg[key]
}
})
it('returns empty when all nodes are registered', () => {
const reg = LiteGraph.registered_node_types as Record<string, unknown>
reg['KSampler'] = {}
vi.mocked(collectAllNodes).mockReturnValue([mockNode(1, 'KSampler')])
vi.mocked(getExecutionIdByNode).mockReturnValue(null)
rescanAndSurfaceMissingNodes(mockGraph())
const store = useExecutionErrorStore()
expect(store.missingNodesError).toBeNull()
})
it('detects unregistered nodes as missing', () => {
vi.mocked(collectAllNodes).mockReturnValue([
mockNode(1, 'OldNode'),
mockNode(2, 'AnotherOldNode')
])
vi.mocked(getExecutionIdByNode).mockReturnValue(null)
rescanAndSurfaceMissingNodes(mockGraph())
const store = useExecutionErrorStore()
const error = getMissingNodesError(store)
expect(error.nodeTypes).toHaveLength(2)
})
it('skips registered nodes and lists only unregistered', () => {
const reg = LiteGraph.registered_node_types as Record<string, unknown>
reg['RegisteredNode'] = {}
vi.mocked(collectAllNodes).mockReturnValue([
mockNode(1, 'RegisteredNode'),
mockNode(2, 'UnregisteredNode')
])
vi.mocked(getExecutionIdByNode).mockReturnValue(null)
rescanAndSurfaceMissingNodes(mockGraph())
const store = useExecutionErrorStore()
const error = getMissingNodesError(store)
expect(error.nodeTypes).toHaveLength(1)
const missing = error.nodeTypes[0]
expect(typeof missing !== 'string' && missing.type).toBe('UnregisteredNode')
})
it('uses executionId when available for nodeId', () => {
vi.mocked(collectAllNodes).mockReturnValue([mockNode(1, 'Missing')])
vi.mocked(getExecutionIdByNode).mockReturnValue('exec-42')
rescanAndSurfaceMissingNodes(mockGraph())
const store = useExecutionErrorStore()
const error = getMissingNodesError(store)
const missing = error.nodeTypes[0]
expect(typeof missing !== 'string' && missing.nodeId).toBe('exec-42')
})
it('falls back to node.id when executionId is null', () => {
vi.mocked(collectAllNodes).mockReturnValue([mockNode(99, 'Missing')])
vi.mocked(getExecutionIdByNode).mockReturnValue(null)
rescanAndSurfaceMissingNodes(mockGraph())
const store = useExecutionErrorStore()
const error = getMissingNodesError(store)
const missing = error.nodeTypes[0]
expect(typeof missing !== 'string' && missing.nodeId).toBe('99')
})
it('populates cnrId from getCnrIdFromNode', () => {
vi.mocked(collectAllNodes).mockReturnValue([mockNode(1, 'Missing')])
vi.mocked(getExecutionIdByNode).mockReturnValue(null)
vi.mocked(getCnrIdFromNode).mockReturnValue('comfy-nodes/my-pack')
rescanAndSurfaceMissingNodes(mockGraph())
const store = useExecutionErrorStore()
const error = getMissingNodesError(store)
const missing = error.nodeTypes[0]
expect(typeof missing !== 'string' && missing.cnrId).toBe(
'comfy-nodes/my-pack'
)
})
it('marks node as replaceable when replacement exists', () => {
vi.mocked(collectAllNodes).mockReturnValue([mockNode(1, 'OldNode')])
vi.mocked(getExecutionIdByNode).mockReturnValue(null)
const replacementStore = useNodeReplacementStore()
replacementStore.replacements = {
OldNode: [
{
old_node_id: 'OldNode',
new_node_id: 'NewNode',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
}
]
}
rescanAndSurfaceMissingNodes(mockGraph())
const store = useExecutionErrorStore()
const error = getMissingNodesError(store)
const missing = error.nodeTypes[0]
expect(typeof missing !== 'string' && missing.isReplaceable).toBe(true)
expect(
typeof missing !== 'string' && missing.replacement?.new_node_id
).toBe('NewNode')
})
it('marks node as not replaceable when no replacement', () => {
vi.mocked(collectAllNodes).mockReturnValue([mockNode(1, 'OldNode')])
vi.mocked(getExecutionIdByNode).mockReturnValue(null)
rescanAndSurfaceMissingNodes(mockGraph())
const store = useExecutionErrorStore()
const error = getMissingNodesError(store)
const missing = error.nodeTypes[0]
expect(typeof missing !== 'string' && missing.isReplaceable).toBe(false)
})
it('uses last_serialization.type over node.type', () => {
const node = mockNode(1, 'LiveType')
node.last_serialization = {
type: 'OriginalType'
} as unknown as LGraphNode['last_serialization']
vi.mocked(collectAllNodes).mockReturnValue([node])
vi.mocked(getExecutionIdByNode).mockReturnValue(null)
rescanAndSurfaceMissingNodes(mockGraph())
const store = useExecutionErrorStore()
const error = getMissingNodesError(store)
const missing = error.nodeTypes[0]
expect(typeof missing !== 'string' && missing.type).toBe('OriginalType')
})
})

View File

@@ -0,0 +1,44 @@
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacementStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { MissingNodeType } from '@/types/comfy'
import {
collectAllNodes,
getExecutionIdByNode
} from '@/utils/graphTraversalUtil'
import { getCnrIdFromNode } from '@/workbench/extensions/manager/utils/missingNodeErrorUtil'
/** Scan the live graph for unregistered node types and build a full MissingNodeType list. */
function scanMissingNodes(rootGraph: LGraph): MissingNodeType[] {
const nodeReplacementStore = useNodeReplacementStore()
const missingNodeTypes: MissingNodeType[] = []
const allNodes = collectAllNodes(rootGraph)
for (const node of allNodes) {
const originalType = node.last_serialization?.type ?? node.type ?? 'Unknown'
if (originalType in LiteGraph.registered_node_types) continue
const cnrId = getCnrIdFromNode(node)
const replacement = nodeReplacementStore.getReplacementFor(originalType)
const executionId = getExecutionIdByNode(rootGraph, node)
missingNodeTypes.push({
type: originalType,
nodeId: executionId ?? String(node.id),
cnrId,
isReplaceable: replacement !== null,
replacement: replacement ?? undefined
})
}
return missingNodeTypes
}
/** Re-scan the graph for missing nodes and update the error store. */
export function rescanAndSurfaceMissingNodes(rootGraph: LGraph): void {
const types = scanMissingNodes(rootGraph)
useExecutionErrorStore().surfaceMissingNodes(types)
}

View File

@@ -44,6 +44,15 @@ vi.mock('@/i18n', () => ({
params ? `${key}:${JSON.stringify(params)}` : key
}))
const { mockRemoveMissingNodesByType } = vi.hoisted(() => ({
mockRemoveMissingNodesByType: vi.fn()
}))
vi.mock('@/stores/executionErrorStore', () => ({
useExecutionErrorStore: vi.fn(() => ({
removeMissingNodesByType: mockRemoveMissingNodesByType
}))
}))
import { app } from '@/scripts/app'
import { collectAllNodes } from '@/utils/graphTraversalUtil'
import { useNodeReplacement } from './useNodeReplacement'
@@ -651,4 +660,319 @@ describe('useNodeReplacement', () => {
expect(result).toEqual(['ImageBatch'])
})
})
describe('placeholder detection predicate', () => {
/**
* replaceNodesInPlace calls collectAllNodes with a predicate.
* These tests capture the predicate by inspecting the mock call
* and verify it matches only nodes whose serialized type is in
* the targetTypes set — regardless of has_errors or registered_node_types.
*/
function capturedPredicate(): (n: LGraphNode) => boolean {
const calls = vi.mocked(collectAllNodes).mock.calls
expect(calls.length).toBeGreaterThan(0)
return calls[calls.length - 1][1] as (n: LGraphNode) => boolean
}
it('should detect placeholder when type is in targetTypes even if has_errors is false', () => {
const placeholder = createPlaceholderNode(1, 'OldNode')
placeholder.has_errors = false
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([])
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('OldNode', {
new_node_id: 'NewNode',
old_node_id: 'OldNode',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
])
const predicate = capturedPredicate()
expect(predicate(placeholder)).toBe(true)
})
it('should detect placeholder when type is in targetTypes even if type is registered', () => {
// Simulate the pack being reinstalled — type is now registered
;(LiteGraph.registered_node_types as Record<string, unknown>)['OldNode'] =
{}
const placeholder = createPlaceholderNode(1, 'OldNode')
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([])
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('OldNode', {
new_node_id: 'NewNode',
old_node_id: 'OldNode',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
])
const predicate = capturedPredicate()
expect(predicate(placeholder)).toBe(true)
// Cleanup
delete (LiteGraph.registered_node_types as Record<string, unknown>)[
'OldNode'
]
})
it('should exclude nodes whose type is NOT in targetTypes', () => {
const unrelatedNode = createPlaceholderNode(1, 'UnrelatedNode')
const graph = createMockGraph([unrelatedNode])
unrelatedNode.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([])
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('SomeOtherNode', {
new_node_id: 'NewNode',
old_node_id: 'SomeOtherNode',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
])
const predicate = capturedPredicate()
expect(predicate(unrelatedNode)).toBe(false)
})
it('should exclude nodes without last_serialization', () => {
const freshNode = createPlaceholderNode(1, 'OldNode')
freshNode.last_serialization =
undefined as unknown as LGraphNode['last_serialization']
const graph = createMockGraph([freshNode])
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([])
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('OldNode', {
new_node_id: 'NewNode',
old_node_id: 'OldNode',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
])
const predicate = capturedPredicate()
expect(predicate(freshNode)).toBe(false)
})
it('should fall back to node.type when last_serialization.type is undefined', () => {
const node = createPlaceholderNode(1, 'FallbackType')
node.last_serialization!.type = undefined as unknown as string
node.type = 'FallbackType'
const graph = createMockGraph([node])
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([])
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('FallbackType', {
new_node_id: 'NewNode',
old_node_id: 'FallbackType',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
])
const predicate = capturedPredicate()
expect(predicate(node)).toBe(true)
})
it('should match node via sanitized type when last_serialization.type is absent and live type contains HTML special chars', () => {
// Simulates an old serialization format (no last_serialization.type)
// where app.ts has already run sanitizeNodeName on n.type,
// stripping '&' from "OldNode&Special" → "OldNodeSpecial".
// targetTypes still holds the original unsanitized name "OldNode&Special",
// so the predicate must fall back to checking sanitizeNodeName(originalType).
const node = createPlaceholderNode(1, 'OldNodeSpecial')
node.last_serialization!.type = undefined as unknown as string
// Simulate what sanitizeNodeName does to '&' in the live type
node.type = 'OldNodeSpecial' // '&' already stripped by sanitizeNodeName
const graph = createMockGraph([node])
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([])
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
// targetTypes will contain the original name with '&'
makeMissingNodeType('OldNode&Special', {
new_node_id: 'NewNode',
old_node_id: 'OldNode&Special',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
])
const predicate = capturedPredicate()
// Without the sanitize fallback this would return false.
expect(predicate(node)).toBe(true)
})
})
describe('replaceGroup', () => {
it('calls removeMissingNodesByType with replaced types on success', () => {
const placeholder = createPlaceholderNode(1, 'OldNode')
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
const newNode = createNewNode()
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceGroup } = useNodeReplacement()
replaceGroup({
type: 'OldNode',
nodeTypes: [
makeMissingNodeType('OldNode', {
new_node_id: 'NewNode',
old_node_id: 'OldNode',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
]
})
expect(mockRemoveMissingNodesByType).toHaveBeenCalledWith(['OldNode'])
})
it('does not call removeMissingNodesByType when no nodes are replaced', () => {
const graph = createMockGraph([])
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([])
const { replaceGroup } = useNodeReplacement()
replaceGroup({
type: 'OldNode',
nodeTypes: [
makeMissingNodeType('OldNode', {
new_node_id: 'NewNode',
old_node_id: 'OldNode',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
]
})
expect(mockRemoveMissingNodesByType).not.toHaveBeenCalled()
})
})
describe('replaceAllGroups', () => {
it('calls removeMissingNodesByType with all successfully replaced types', () => {
const p1 = createPlaceholderNode(1, 'TypeA')
const p2 = createPlaceholderNode(2, 'TypeB')
const graph = createMockGraph([p1, p2])
p1.graph = graph
p2.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([p1, p2])
vi.mocked(LiteGraph.createNode)
.mockReturnValueOnce(createNewNode())
.mockReturnValueOnce(createNewNode())
const { replaceAllGroups } = useNodeReplacement()
replaceAllGroups([
{
type: 'TypeA',
nodeTypes: [
makeMissingNodeType('TypeA', {
new_node_id: 'NewA',
old_node_id: 'TypeA',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
]
},
{
type: 'TypeB',
nodeTypes: [
makeMissingNodeType('TypeB', {
new_node_id: 'NewB',
old_node_id: 'TypeB',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
]
}
])
expect(mockRemoveMissingNodesByType).toHaveBeenCalledWith(
expect.arrayContaining(['TypeA', 'TypeB'])
)
})
it('removes only the types that were actually replaced when some fail', () => {
const p1 = createPlaceholderNode(1, 'TypeA')
const graph = createMockGraph([p1])
p1.graph = graph
Object.assign(app, { rootGraph: graph })
// Only TypeA appears as a placeholder; TypeB has no matching node
vi.mocked(collectAllNodes).mockReturnValue([p1])
vi.mocked(LiteGraph.createNode).mockReturnValueOnce(createNewNode())
const { replaceAllGroups } = useNodeReplacement()
replaceAllGroups([
{
type: 'TypeA',
nodeTypes: [
makeMissingNodeType('TypeA', {
new_node_id: 'NewA',
old_node_id: 'TypeA',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
]
},
{
type: 'TypeB',
nodeTypes: [
makeMissingNodeType('TypeB', {
new_node_id: 'NewB',
old_node_id: 'TypeB',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
]
}
])
// Only TypeA was replaced; TypeB had no matching placeholder
expect(mockRemoveMissingNodesByType).toHaveBeenCalledWith(['TypeA'])
})
})
})

View File

@@ -7,9 +7,15 @@ import type { NodeReplacement } from '@/platform/nodeReplacement/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app, sanitizeNodeName } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { MissingNodeType } from '@/types/comfy'
import { collectAllNodes } from '@/utils/graphTraversalUtil'
interface ReplacementGroup {
type: string
nodeTypes: MissingNodeType[]
}
/** Compares sanitized type strings to match placeholder → missing node type. */
function findMatchingType(
node: LGraphNode,
@@ -230,15 +236,23 @@ export function useNodeReplacement() {
// and the missing nodes detected at that point — not from the current
// registered_node_types. This ensures replacement still works even if
// the user has since installed the missing node pack.
const targetTypes = new Set(
selectedTypes.map((t) => (typeof t === 'string' ? t : t.type))
)
// Also include sanitized variants so that when the fallback path reads
// n.type (which app.ts may have already run through sanitizeNodeName),
// we can still match against the original type stored in selectedTypes.
const targetTypes = new Set([
...selectedTypes.map((t) => (typeof t === 'string' ? t : t.type)),
...selectedTypes.map((t) =>
sanitizeNodeName(typeof t === 'string' ? t : t.type)
)
])
try {
const placeholders = collectAllNodes(graph, (n) => {
if (!n.last_serialization) return false
// Prefer the original serialized type; fall back to the live type
// for nodes whose serialization predates the type field.
// n.type may have been sanitized by app.ts (HTML special chars stripped);
// the sanitized variants in targetTypes ensure we still match correctly.
const originalType = n.last_serialization.type ?? n.type
return !!originalType && targetTypes.has(originalType)
})
@@ -314,7 +328,32 @@ export function useNodeReplacement() {
return replacedTypes
}
/**
* Replaces all nodes in a single swap group and removes successfully
* replaced types from the execution error store.
*/
function replaceGroup(group: ReplacementGroup): void {
const replaced = replaceNodesInPlace(group.nodeTypes)
if (replaced.length > 0) {
useExecutionErrorStore().removeMissingNodesByType(replaced)
}
}
/**
* Replaces every available node across all swap groups and removes
* the succeeded types from the execution error store.
*/
function replaceAllGroups(groups: ReplacementGroup[]): void {
const allNodeTypes = groups.flatMap((g) => g.nodeTypes)
const replaced = replaceNodesInPlace(allNodeTypes)
if (replaced.length > 0) {
useExecutionErrorStore().removeMissingNodesByType(replaced)
}
}
return {
replaceNodesInPlace
replaceNodesInPlace,
replaceGroup,
replaceAllGroups
}
}