mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-03 20:20:03 +00:00
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:
214
src/components/rightSidePanel/errors/MissingNodeCard.test.ts
Normal file
214
src/components/rightSidePanel/errors/MissingNodeCard.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
})
|
||||
368
src/components/rightSidePanel/errors/MissingPackGroupRow.test.ts
Normal file
368
src/components/rightSidePanel/errors/MissingPackGroupRow.test.ts
Normal 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)')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
187
src/components/rightSidePanel/errors/swapNodeGroups.test.ts
Normal file
187
src/components/rightSidePanel/errors/swapNodeGroups.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
439
src/components/rightSidePanel/errors/useErrorGroups.test.ts
Normal file
439
src/components/rightSidePanel/errors/useErrorGroups.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
244
src/platform/nodeReplacement/components/SwapNodeGroupRow.test.ts
Normal file
244
src/platform/nodeReplacement/components/SwapNodeGroupRow.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -102,9 +102,7 @@ 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'
|
||||
import type { SwapNodeGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
|
||||
const props = defineProps<{
|
||||
group: SwapNodeGroup
|
||||
@@ -113,11 +111,10 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
'locate-node': [nodeId: string]
|
||||
replace: [group: SwapNodeGroup]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { replaceNodesInPlace } = useNodeReplacement()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
|
||||
const expanded = ref(false)
|
||||
|
||||
@@ -142,9 +139,6 @@ function handleLocateNode(nodeType: MissingNodeType) {
|
||||
}
|
||||
|
||||
function handleReplaceNode() {
|
||||
const replaced = replaceNodesInPlace(props.group.nodeTypes)
|
||||
if (replaced.length > 0) {
|
||||
executionErrorStore.removeMissingNodesByType([props.group.type])
|
||||
}
|
||||
emit('replace', props.group)
|
||||
}
|
||||
</script>
|
||||
129
src/platform/nodeReplacement/components/SwapNodesCard.test.ts
Normal file
129
src/platform/nodeReplacement/components/SwapNodesCard.test.ts
Normal 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])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -16,14 +16,15 @@
|
||||
: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 './useErrorGroups'
|
||||
import SwapNodeGroupRow from './SwapNodeGroupRow.vue'
|
||||
import type { SwapNodeGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
import SwapNodeGroupRow from '@/platform/nodeReplacement/components/SwapNodeGroupRow.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -34,5 +35,6 @@ const { swapNodeGroups, showNodeIdBadge } = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
'locate-node': [nodeId: string]
|
||||
replace: [group: SwapNodeGroup]
|
||||
}>()
|
||||
</script>
|
||||
232
src/platform/nodeReplacement/missingNodeScan.test.ts
Normal file
232
src/platform/nodeReplacement/missingNodeScan.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ import type { ExtensionManager } from '@/types/extensionTypes'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { graphToPrompt } from '@/utils/executionUtil'
|
||||
import { getCnrIdFromProperties } from '@/workbench/extensions/manager/utils/missingNodeErrorUtil'
|
||||
import { rescanAndSurfaceMissingNodes } from '@/composables/useMissingNodeScan'
|
||||
import { rescanAndSurfaceMissingNodes } from '@/platform/nodeReplacement/missingNodeScan'
|
||||
import { anyItemOverlapsRect } from '@/utils/mathUtil'
|
||||
import {
|
||||
collectAllNodes,
|
||||
|
||||
159
src/stores/executionErrorStore.test.ts
Normal file
159
src/stores/executionErrorStore.test.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/i18n', () => ({
|
||||
st: vi.fn((_key: string, fallback: string) => fallback)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
get: vi.fn(() => false)
|
||||
}))
|
||||
}))
|
||||
|
||||
import { useExecutionErrorStore } from './executionErrorStore'
|
||||
|
||||
describe('executionErrorStore — missing node operations', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
describe('setMissingNodeTypes', () => {
|
||||
it('sets missingNodesError with provided types', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
const types: MissingNodeType[] = [
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
|
||||
]
|
||||
store.setMissingNodeTypes(types)
|
||||
|
||||
expect(store.missingNodesError).not.toBeNull()
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
|
||||
expect(store.hasMissingNodes).toBe(true)
|
||||
})
|
||||
|
||||
it('clears missingNodesError when given empty array', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.setMissingNodeTypes([
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
|
||||
])
|
||||
expect(store.missingNodesError).not.toBeNull()
|
||||
|
||||
store.setMissingNodeTypes([])
|
||||
expect(store.missingNodesError).toBeNull()
|
||||
expect(store.hasMissingNodes).toBe(false)
|
||||
})
|
||||
|
||||
it('deduplicates string entries by value', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.setMissingNodeTypes([
|
||||
'NodeA',
|
||||
'NodeA',
|
||||
'NodeB'
|
||||
] as MissingNodeType[])
|
||||
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('deduplicates object entries by nodeId when present', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.setMissingNodeTypes([
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
|
||||
{ type: 'NodeA', nodeId: '2', isReplaceable: false }
|
||||
])
|
||||
|
||||
// Same nodeId='1' deduplicated, nodeId='2' kept
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('deduplicates object entries by type when nodeId is absent', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.setMissingNodeTypes([
|
||||
{ type: 'NodeA', isReplaceable: false },
|
||||
{ type: 'NodeA', isReplaceable: true }
|
||||
] as MissingNodeType[])
|
||||
|
||||
// Same type, no nodeId → deduplicated
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps distinct nodeIds even when type is the same', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.setMissingNodeTypes([
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
|
||||
{ type: 'NodeA', nodeId: '2', isReplaceable: false },
|
||||
{ type: 'NodeA', nodeId: '3', isReplaceable: false }
|
||||
])
|
||||
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeMissingNodesByType', () => {
|
||||
it('removes matching types from the missing nodes list', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.setMissingNodeTypes([
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
|
||||
{ type: 'NodeB', nodeId: '2', isReplaceable: false },
|
||||
{ type: 'NodeC', nodeId: '3', isReplaceable: false }
|
||||
])
|
||||
|
||||
store.removeMissingNodesByType(['NodeA', 'NodeC'])
|
||||
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
|
||||
const remaining = store.missingNodesError?.nodeTypes[0]
|
||||
expect(typeof remaining !== 'string' && remaining?.type).toBe('NodeB')
|
||||
})
|
||||
|
||||
it('clears missingNodesError when all types are removed', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.setMissingNodeTypes([
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
|
||||
])
|
||||
|
||||
store.removeMissingNodesByType(['NodeA'])
|
||||
|
||||
expect(store.missingNodesError).toBeNull()
|
||||
expect(store.hasMissingNodes).toBe(false)
|
||||
})
|
||||
|
||||
it('does nothing when missingNodesError is null', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
expect(store.missingNodesError).toBeNull()
|
||||
|
||||
// Should not throw
|
||||
store.removeMissingNodesByType(['NodeA'])
|
||||
expect(store.missingNodesError).toBeNull()
|
||||
})
|
||||
|
||||
it('does nothing when removing non-existent types', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.setMissingNodeTypes([
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
|
||||
])
|
||||
|
||||
store.removeMissingNodesByType(['NonExistent'])
|
||||
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('handles removing from string entries', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.setMissingNodeTypes([
|
||||
'StringNodeA',
|
||||
'StringNodeB'
|
||||
] as MissingNodeType[])
|
||||
|
||||
store.removeMissingNodesByType(['StringNodeA'])
|
||||
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user