Compare commits

...

3 Commits

Author SHA1 Message Date
jaeone94
2edd60c796 fix: address CodeRabbit review — bug fixes, test hardening, code quality
- Fix focusedErrorNodeId watch collapsing non-execution groups (#10085)
- Fix missingNodeCount always returning 0/1 instead of actual count
- Add ShowErrorsTab setting to useNodeErrorFlagSync watch sources
- Guard rootGraph.serialize() with try/catch, use const declarations
- Deduplicate cancelled guard in useErrorReport
- Await initial systemStats loading before refetching
- Fix mock return type null → undefined in missingNodeScan test
- Update stale doc comments referencing executionErrorStore
- Strengthen test assertions: onWidgetChanged restoration, runtime
  error uniqueness, missing-node title verification, fallback logs
2026-03-20 20:58:00 +09:00
jaeone94
9f3cacfd78 fix: address review findings — cleanup hooks, store placement, code quality
- Fix per-node callback restoration on cleanup in useErrorClearingHooks
- Add unmount cancellation guard to useErrorReport async onMounted
- Move missingNodesErrorStore to platform/nodeReplacement (DDD alignment)
- Extract activeMissingNodeGraphIds to component-level computed
- Extract useErrorActions composable (deduplicate telemetry+command pattern)
- Add data-testid to copy button, use in test selector
- Add tests for onNodeRemoved restoration and double-install guard
- Return watch stop handle from useNodeErrorFlagSync
- Add asyncResolvedIds eviction on missingNodesError reset
- Add console.warn to silent catch blocks and empty array guard
- Hoist useCommandStore to setup scope, fix floating promises
- Replace replace() with replaceAll() in testid expression
- Convert function expression to declaration in FindIssueButton
2026-03-20 20:00:31 +09:00
jaeone94
3858432fe6 refactor: error system cleanup — store separation, DDD fix, test improvements
- Extract missingNodesErrorStore from executionErrorStore, remove delegation pattern
- Extract useNodeErrorFlagSync composable for node error flag reconciliation
- Move getCnrIdFromNode/getCnrIdFromProperties to platform layer (DDD fix)
- Add explicit callback cleanup on node removal in useErrorClearingHooks
- Fix mock hoisting inconsistency in MissingNodeCard.test.ts
- Add data-testid to error groups, image/video error spans
- Update E2E tests to use scoped locators and testids

Fixes #9875, Fixes #10027, Fixes #10033, Fixes #10085
2026-03-20 19:58:11 +09:00
34 changed files with 685 additions and 464 deletions

View File

@@ -31,7 +31,9 @@ export const TestIds = {
runtimeErrorPanel: 'runtime-error-panel',
missingNodeCard: 'missing-node-card',
about: 'about-panel',
whatsNewSection: 'whats-new-section'
whatsNewSection: 'whats-new-section',
missingNodePacksGroup: 'error-group-missing-node',
missingModelsGroup: 'error-group-missing-model'
},
topbar: {
queueButton: 'queue-button',
@@ -72,6 +74,10 @@ export const TestIds = {
},
user: {
currentUserIndicator: 'current-user-indicator'
},
errors: {
imageLoadError: 'error-loading-image',
videoLoadError: 'error-loading-video'
}
} as const
@@ -96,3 +102,4 @@ export type TestIdValue =
(id: string) => string
>
| (typeof TestIds.user)[keyof typeof TestIds.user]
| (typeof TestIds.errors)[keyof typeof TestIds.errors]

View File

@@ -28,7 +28,7 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
)
await expect(errorOverlay).toBeVisible()
const missingNodesTitle = comfyPage.page.getByText(/Missing Node Packs/)
const missingNodesTitle = errorOverlay.getByText(/Missing Node Packs/)
await expect(missingNodesTitle).toBeVisible()
})
@@ -42,7 +42,7 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
)
await expect(errorOverlay).toBeVisible()
const missingNodesTitle = comfyPage.page.getByText(/Missing Node Packs/)
const missingNodesTitle = errorOverlay.getByText(/Missing Node Packs/)
await expect(missingNodesTitle).toBeVisible()
// Click "See Errors" to open the errors tab and verify subgraph node content
@@ -204,7 +204,7 @@ test.describe('Missing models in Error Tab', () => {
)
await expect(errorOverlay).toBeVisible()
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
const missingModelsTitle = errorOverlay.getByText(/Missing Models/)
await expect(missingModelsTitle).toBeVisible()
})
@@ -220,7 +220,7 @@ test.describe('Missing models in Error Tab', () => {
)
await expect(errorOverlay).toBeVisible()
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
const missingModelsTitle = errorOverlay.getByText(/Missing Models/)
await expect(missingModelsTitle).toBeVisible()
})
@@ -231,13 +231,13 @@ test.describe('Missing models in Error Tab', () => {
'missing/model_metadata_widget_mismatch'
)
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
await expect(missingModelsTitle).not.toBeVisible()
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).not.toBeVisible()
const missingModelsTitle = errorOverlay.getByText(/Missing Models/)
await expect(missingModelsTitle).not.toBeVisible()
})
// Flaky test after parallelization

View File

@@ -2,6 +2,7 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
import { TestIds } from '../../../../fixtures/selectors'
test.describe('Vue Upload Widgets', () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -19,10 +20,14 @@ test.describe('Vue Upload Widgets', () => {
).not.toBeVisible()
await expect
.poll(() => comfyPage.page.getByText('Error loading image').count())
.poll(() =>
comfyPage.page.getByTestId(TestIds.errors.imageLoadError).count()
)
.toBeGreaterThan(0)
await expect
.poll(() => comfyPage.page.getByText('Error loading video').count())
.poll(() =>
comfyPage.page.getByTestId(TestIds.errors.videoLoadError).count()
)
.toBeGreaterThan(0)
})
})

View File

@@ -19,10 +19,7 @@ const props = defineProps<{
const queryString = computed(() => props.errorMessage + ' is:issue')
/**
* Open GitHub issues search and track telemetry.
*/
const openGitHubIssues = () => {
function openGitHubIssues() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_dialog_find_existing_issues_clicked'
})

View File

@@ -9,12 +9,15 @@ import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'
import { useGraphHierarchy } from '@/composables/graph/useGraphHierarchy'
import { st } from '@/i18n'
import { app } from '@/scripts/app'
import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
@@ -38,12 +41,21 @@ import TabErrors from './errors/TabErrors.vue'
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
const missingNodesErrorStore = useMissingNodesErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
const settingStore = useSettingStore()
const { t } = useI18n()
const { hasAnyError, allErrorExecutionIds, activeMissingNodeGraphIds } =
storeToRefs(executionErrorStore)
const { hasAnyError, allErrorExecutionIds } = storeToRefs(executionErrorStore)
const activeMissingNodeGraphIds = computed<Set<string>>(() => {
if (!app.isGraphReady) return new Set()
return getActiveGraphNodeIds(
app.rootGraph,
canvasStore.currentGraph ?? app.rootGraph,
missingNodesErrorStore.missingAncestorExecutionIds
)
})
const { activeMissingModelGraphIds } = storeToRefs(missingModelStore)

View File

@@ -237,6 +237,11 @@ describe('ErrorNodeCard.vue', () => {
// Report is still generated with fallback log message
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
expect(mockGenerateErrorReport).toHaveBeenCalledWith(
expect.objectContaining({
serverLogs: 'Failed to retrieve server logs'
})
)
expect(wrapper.text()).toContain('ComfyUI Error Report')
})

View File

@@ -99,6 +99,7 @@
variant="secondary"
size="sm"
class="h-8 w-1/3 justify-center gap-1 rounded-lg text-xs"
data-testid="error-card-copy"
@click="handleCopyError(idx)"
>
{{ t('g.copy') }}
@@ -125,12 +126,10 @@
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useExternalLink } from '@/composables/useExternalLink'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
import { cn } from '@/utils/tailwindUtil'
import type { ErrorCardData, ErrorItem } from './types'
import { useErrorActions } from './useErrorActions'
import { useErrorReport } from './useErrorReport'
const {
@@ -154,10 +153,8 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const telemetry = useTelemetry()
const { staticUrls } = useExternalLink()
const commandStore = useCommandStore()
const { displayedDetailsMap } = useErrorReport(() => card)
const { findOnGitHub, contactSupport: handleGetHelp } = useErrorActions()
function handleLocateNode() {
if (card.nodeId) {
@@ -178,23 +175,6 @@ function handleCopyError(idx: number) {
}
function handleCheckGithub(error: ErrorItem) {
telemetry?.trackUiButtonClicked({
button_id: 'error_tab_find_existing_issues_clicked'
})
const query = encodeURIComponent(error.message + ' is:issue')
window.open(
`${staticUrls.githubIssues}?q=${query}`,
'_blank',
'noopener,noreferrer'
)
}
function handleGetHelp() {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'error_dialog'
})
commandStore.execute('Comfy.ContactSupport')
findOnGitHub(error.message)
}
</script>

View File

@@ -1,7 +1,5 @@
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'
@@ -42,23 +40,25 @@ vi.mock('@/stores/systemStatsStore', () => ({
})
}))
const mockApplyChanges = vi.fn()
const mockIsRestarting = ref(false)
const mockApplyChanges = vi.hoisted(() => vi.fn())
const mockIsRestarting = vi.hoisted(() => ({ value: false }))
vi.mock('@/workbench/extensions/manager/composables/useApplyChanges', () => ({
useApplyChanges: () => ({
isRestarting: mockIsRestarting,
get isRestarting() {
return mockIsRestarting.value
},
applyChanges: mockApplyChanges
})
}))
const mockIsPackInstalled = vi.fn(() => false)
const mockIsPackInstalled = vi.hoisted(() => vi.fn(() => false))
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: () => ({
isPackInstalled: mockIsPackInstalled
})
}))
const mockShouldShowManagerButtons = { value: false }
const mockShouldShowManagerButtons = vi.hoisted(() => ({ value: false }))
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
useManagerState: () => ({
shouldShowManagerButtons: mockShouldShowManagerButtons
@@ -128,7 +128,7 @@ function mountCard(
...props
},
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n],
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
stubs: {
DotSpinner: { template: '<span role="status" aria-label="loading" />' }
}

View File

@@ -209,12 +209,9 @@ describe('TabErrors.vue', () => {
}
})
// Find the copy button by text (rendered inside ErrorNodeCard)
const copyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Copy'))
expect(copyButton).toBeTruthy()
await copyButton!.trigger('click')
const copyButton = wrapper.find('[data-testid="error-card-copy"]')
expect(copyButton.exists()).toBe(true)
await copyButton.trigger('click')
expect(mockCopy).toHaveBeenCalledWith('Test message\n\nTest details')
})
@@ -245,5 +242,9 @@ describe('TabErrors.vue', () => {
// Should render in the dedicated runtime error panel, not inside accordion
const runtimePanel = wrapper.find('[data-testid="runtime-error-panel"]')
expect(runtimePanel.exists()).toBe(true)
// Verify the error message appears exactly once (not duplicated in accordion)
expect(
wrapper.text().match(/RuntimeError: Out of memory/g) ?? []
).toHaveLength(1)
})
})

View File

@@ -53,6 +53,7 @@
<PropertiesAccordionItem
v-for="group in filteredGroups"
:key="group.title"
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
:collapse="isSectionCollapsed(group.title) && !isSearching"
class="border-b border-interface-stroke"
:size="getGroupSize(group)"
@@ -209,12 +210,9 @@
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useFocusNode } from '@/composables/canvas/useFocusNode'
import { useExternalLink } from '@/composables/useExternalLink'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
@@ -238,6 +236,7 @@ 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 { useErrorActions } from './useErrorActions'
import { useErrorGroups } from './useErrorGroups'
import type { SwapNodeGroup } from './useErrorGroups'
import type { ErrorGroup } from './types'
@@ -246,7 +245,7 @@ import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacemen
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const { focusNode, enterSubgraph } = useFocusNode()
const { staticUrls } = useExternalLink()
const { openGitHubIssues, contactSupport } = useErrorActions()
const settingStore = useSettingStore()
const rightSidePanelStore = useRightSidePanelStore()
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
@@ -372,13 +371,13 @@ watch(
if (!graphNodeId) return
const prefix = `${graphNodeId}:`
for (const group of allErrorGroups.value) {
const hasMatch =
group.type === 'execution' &&
group.cards.some(
(card) =>
card.graphNodeId === graphNodeId ||
(card.nodeId?.startsWith(prefix) ?? false)
)
if (group.type !== 'execution') continue
const hasMatch = group.cards.some(
(card) =>
card.graphNodeId === graphNodeId ||
(card.nodeId?.startsWith(prefix) ?? false)
)
setSectionCollapsed(group.title, !hasMatch)
}
rightSidePanelStore.focusedErrorNodeId = null
@@ -418,20 +417,4 @@ function handleReplaceAll() {
function handleEnterSubgraph(nodeId: string) {
enterSubgraph(nodeId, errorNodeCache.value)
}
function openGitHubIssues() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_tab_github_issues_clicked'
})
window.open(staticUrls.githubIssues, '_blank', 'noopener,noreferrer')
}
async function contactSupport() {
useTelemetry()?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'error_dialog'
})
useCommandStore().execute('Comfy.ContactSupport')
}
</script>

View File

@@ -48,6 +48,7 @@ vi.mock('@/utils/executableGroupNodeDto', () => ({
}))
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useErrorGroups } from './useErrorGroups'
function makeMissingNodeType(
@@ -80,8 +81,11 @@ describe('swapNodeGroups computed', () => {
})
function getSwapNodeGroups(nodeTypes: MissingNodeType[]) {
const store = useExecutionErrorStore()
store.surfaceMissingNodes(nodeTypes)
const executionErrorStore = useExecutionErrorStore()
useMissingNodesErrorStore().surfaceMissingNodes(
nodeTypes,
executionErrorStore.showErrorOverlay
)
const searchQuery = ref('')
const t = (key: string) => key

View File

@@ -0,0 +1,39 @@
import { useCommandStore } from '@/stores/commandStore'
import { useExternalLink } from '@/composables/useExternalLink'
import { useTelemetry } from '@/platform/telemetry'
export function useErrorActions() {
const telemetry = useTelemetry()
const commandStore = useCommandStore()
const { staticUrls } = useExternalLink()
function openGitHubIssues() {
telemetry?.trackUiButtonClicked({
button_id: 'error_tab_github_issues_clicked'
})
window.open(staticUrls.githubIssues, '_blank', 'noopener,noreferrer')
}
function contactSupport() {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'error_dialog'
})
void commandStore.execute('Comfy.ContactSupport')
}
function findOnGitHub(errorMessage: string) {
telemetry?.trackUiButtonClicked({
button_id: 'error_tab_find_existing_issues_clicked'
})
const query = encodeURIComponent(errorMessage + ' is:issue')
window.open(
`${staticUrls.githubIssues}?q=${query}`,
'_blank',
'noopener,noreferrer'
)
}
return { openGitHubIssues, contactSupport, findOnGitHub }
}

View File

@@ -58,6 +58,7 @@ vi.mock(
)
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useErrorGroups } from './useErrorGroups'
function makeMissingNodeType(
@@ -126,8 +127,9 @@ describe('useErrorGroups', () => {
})
it('groups non-replaceable nodes by cnrId', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'pack-1' }),
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' }),
makeMissingNodeType('NodeC', { cnrId: 'pack-2', nodeId: '3' })
@@ -146,8 +148,9 @@ describe('useErrorGroups', () => {
})
it('excludes replaceable nodes from missingPackGroups', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
@@ -164,8 +167,9 @@ describe('useErrorGroups', () => {
})
it('groups nodes without cnrId under null packId', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('UnknownNode', { nodeId: '1' }),
makeMissingNodeType('AnotherUnknown', { nodeId: '2' })
])
@@ -177,8 +181,9 @@ describe('useErrorGroups', () => {
})
it('sorts groups alphabetically with null packId last', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'zebra-pack' }),
makeMissingNodeType('NodeB', { nodeId: '2' }),
makeMissingNodeType('NodeC', { cnrId: 'alpha-pack', nodeId: '3' })
@@ -190,8 +195,9 @@ describe('useErrorGroups', () => {
})
it('sorts nodeTypes within each group alphabetically by type then nodeId', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' }),
makeMissingNodeType('NodeA', { cnrId: 'pack-1', nodeId: '3' }),
makeMissingNodeType('NodeA', { cnrId: 'pack-1', nodeId: '1' })
@@ -206,8 +212,9 @@ describe('useErrorGroups', () => {
})
it('handles string nodeType entries', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
'StringGroupNode' as unknown as MissingNodeType
])
await nextTick()
@@ -224,8 +231,9 @@ describe('useErrorGroups', () => {
})
it('includes missing_node group when missing nodes exist', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
])
await nextTick()
@@ -237,8 +245,9 @@ describe('useErrorGroups', () => {
})
it('includes swap_nodes group when replaceable nodes exist', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
@@ -253,8 +262,9 @@ describe('useErrorGroups', () => {
})
it('includes both swap_nodes and missing_node when both exist', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
@@ -272,8 +282,9 @@ describe('useErrorGroups', () => {
})
it('swap_nodes has lower priority than missing_node', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
@@ -533,13 +544,18 @@ describe('useErrorGroups', () => {
})
it('includes missing node group title as message', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
])
await nextTick()
expect(groups.groupedErrorMessages.value.length).toBeGreaterThan(0)
const missingGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'missing_node'
)
expect(missingGroup).toBeDefined()
expect(groups.groupedErrorMessages.value).toContain(missingGroup!.title)
})
})

View File

@@ -5,6 +5,7 @@ import type { IFuseOptions } from 'fuse.js'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
@@ -195,12 +196,8 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
cardIndex: ci,
searchableNodeId: card.nodeId ?? '',
searchableNodeTitle: card.nodeTitle ?? '',
searchableMessage: card.errors
.map((e: ErrorItem) => e.message)
.join(' '),
searchableDetails: card.errors
.map((e: ErrorItem) => e.details ?? '')
.join(' ')
searchableMessage: card.errors.map((e) => e.message).join(' '),
searchableDetails: card.errors.map((e) => e.details ?? '').join(' ')
})
}
}
@@ -240,6 +237,7 @@ export function useErrorGroups(
t: (key: string) => string
) {
const executionErrorStore = useExecutionErrorStore()
const missingNodesStore = useMissingNodesErrorStore()
const missingModelStore = useMissingModelStore()
const canvasStore = useCanvasStore()
const { inferPackFromNodeName } = useComfyRegistryStore()
@@ -285,7 +283,7 @@ export function useErrorGroups(
const missingNodeCache = computed(() => {
const map = new Map<string, LGraphNode>()
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
const nodeTypes = missingNodesStore.missingNodesError?.nodeTypes ?? []
for (const nodeType of nodeTypes) {
if (typeof nodeType === 'string') continue
if (nodeType.nodeId == null) continue
@@ -407,7 +405,7 @@ export function useErrorGroups(
const asyncResolvedIds = ref<Map<string, string | null>>(new Map())
const pendingTypes = computed(() =>
(executionErrorStore.missingNodesError?.nodeTypes ?? []).filter(
(missingNodesStore.missingNodesError?.nodeTypes ?? []).filter(
(n): n is Exclude<MissingNodeType, string> =>
typeof n !== 'string' && !n.cnrId
)
@@ -448,6 +446,8 @@ export function useErrorGroups(
for (const r of results) {
if (r.status === 'fulfilled') {
final.set(r.value.type, r.value.packId)
} else {
console.warn('Failed to resolve pack ID:', r.reason)
}
}
// Clear any remaining RESOLVING markers for failed lookups
@@ -459,8 +459,18 @@ export function useErrorGroups(
{ immediate: true }
)
// Evict stale entries when missing nodes are cleared
watch(
() => missingNodesStore.missingNodesError,
(error) => {
if (!error && asyncResolvedIds.value.size > 0) {
asyncResolvedIds.value = new Map()
}
}
)
const missingPackGroups = computed<MissingPackGroup[]>(() => {
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
const nodeTypes = missingNodesStore.missingNodesError?.nodeTypes ?? []
const map = new Map<
string | null,
{ nodeTypes: MissingNodeType[]; isResolving: boolean }
@@ -522,7 +532,7 @@ export function useErrorGroups(
})
const swapNodeGroups = computed<SwapNodeGroup[]>(() => {
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
const nodeTypes = missingNodesStore.missingNodesError?.nodeTypes ?? []
const map = new Map<string, SwapNodeGroup>()
for (const nodeType of nodeTypes) {
@@ -546,7 +556,7 @@ export function useErrorGroups(
/** Builds an ErrorGroup from missingNodesError. Returns [] when none present. */
function buildMissingNodeGroups(): ErrorGroup[] {
const error = executionErrorStore.missingNodesError
const error = missingNodesStore.missingNodesError
if (!error) return []
const groups: ErrorGroup[] = []

View File

@@ -2,6 +2,8 @@ import { computed, onMounted, onUnmounted, reactive, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { until } from '@vueuse/core'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
@@ -40,24 +42,33 @@ export function useErrorReport(cardSource: MaybeRefOrGetter<ErrorCardData>) {
if (runtimeErrors.length === 0) return
if (!systemStatsStore.systemStats) {
try {
await systemStatsStore.refetchSystemStats()
} catch {
return
if (systemStatsStore.isLoading) {
await until(systemStatsStore.isLoading).toBe(false)
} else {
try {
await systemStatsStore.refetchSystemStats()
} catch (e) {
console.warn('Failed to fetch system stats for error report:', e)
return
}
}
}
if (cancelled || !systemStatsStore.systemStats) return
let logs: string
try {
logs = await api.getLogs()
} catch {
logs = 'Failed to retrieve server logs'
}
if (!systemStatsStore.systemStats || cancelled) return
const logs = await api
.getLogs()
.catch(() => 'Failed to retrieve server logs')
if (cancelled) return
const workflow = app.rootGraph.serialize()
const workflow = (() => {
try {
return app.rootGraph.serialize()
} catch (e) {
console.warn('Failed to serialize workflow for error report:', e)
return null
}
})()
if (!workflow) return
for (const { error, idx } of runtimeErrors) {
try {
@@ -72,8 +83,8 @@ export function useErrorReport(cardSource: MaybeRefOrGetter<ErrorCardData>) {
workflow
})
enrichedDetails[idx] = report
} catch {
// Fallback: keep original error.details
} catch (e) {
console.warn('Failed to generate error report:', e)
}
}
})

View File

@@ -315,6 +315,45 @@ describe('installErrorClearingHooks lifecycle', () => {
cleanup()
expect(graph.onNodeAdded).toBe(originalHook)
})
it('restores original node callbacks when a node is removed', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addInput('clip', 'CLIP')
node.addWidget('number', 'steps', 20, () => undefined, {})
const originalOnConnectionsChange = vi.fn()
const originalOnWidgetChanged = vi.fn()
node.onConnectionsChange = originalOnConnectionsChange
node.onWidgetChanged = originalOnWidgetChanged
graph.add(node)
installErrorClearingHooks(graph)
// Callbacks should be chained (not the originals)
expect(node.onConnectionsChange).not.toBe(originalOnConnectionsChange)
expect(node.onWidgetChanged).not.toBe(originalOnWidgetChanged)
// Simulate node removal via the graph hook
graph.onNodeRemoved!(node)
// Original callbacks should be restored
expect(node.onConnectionsChange).toBe(originalOnConnectionsChange)
expect(node.onWidgetChanged).toBe(originalOnWidgetChanged)
})
it('does not double-wrap callbacks when installErrorClearingHooks is called twice', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addInput('clip', 'CLIP')
graph.add(node)
installErrorClearingHooks(graph)
const chainedAfterFirst = node.onConnectionsChange
// Install again on the same graph — should be a no-op for existing nodes
installErrorClearingHooks(graph)
expect(node.onConnectionsChange).toBe(chainedAfterFirst)
})
})
describe('clearWidgetRelatedErrors parameter routing', () => {

View File

@@ -35,10 +35,22 @@ function resolvePromotedExecId(
const hookedNodes = new WeakSet<LGraphNode>()
type OriginalCallbacks = {
onConnectionsChange: LGraphNode['onConnectionsChange']
onWidgetChanged: LGraphNode['onWidgetChanged']
}
const originalCallbacks = new WeakMap<LGraphNode, OriginalCallbacks>()
function installNodeHooks(node: LGraphNode): void {
if (hookedNodes.has(node)) return
hookedNodes.add(node)
originalCallbacks.set(node, {
onConnectionsChange: node.onConnectionsChange,
onWidgetChanged: node.onWidgetChanged
})
node.onConnectionsChange = useChainCallback(
node.onConnectionsChange,
function (type, slotIndex, isConnected) {
@@ -82,6 +94,15 @@ function installNodeHooks(node: LGraphNode): void {
)
}
function restoreNodeHooks(node: LGraphNode): void {
const originals = originalCallbacks.get(node)
if (!originals) return
node.onConnectionsChange = originals.onConnectionsChange
node.onWidgetChanged = originals.onWidgetChanged
originalCallbacks.delete(node)
hookedNodes.delete(node)
}
function installNodeHooksRecursive(node: LGraphNode): void {
installNodeHooks(node)
if (node.isSubgraphNode?.()) {
@@ -91,6 +112,15 @@ function installNodeHooksRecursive(node: LGraphNode): void {
}
}
function restoreNodeHooksRecursive(node: LGraphNode): void {
restoreNodeHooks(node)
if (node.isSubgraphNode?.()) {
for (const innerNode of node.subgraph._nodes ?? []) {
restoreNodeHooksRecursive(innerNode)
}
}
}
export function installErrorClearingHooks(graph: LGraph): () => void {
for (const node of graph._nodes ?? []) {
installNodeHooksRecursive(node)
@@ -102,7 +132,17 @@ export function installErrorClearingHooks(graph: LGraph): () => void {
originalOnNodeAdded?.call(this, node)
}
const originalOnNodeRemoved = graph.onNodeRemoved
graph.onNodeRemoved = function (node: LGraphNode) {
restoreNodeHooksRecursive(node)
originalOnNodeRemoved?.call(this, node)
}
return () => {
for (const node of graph._nodes ?? []) {
restoreNodeHooksRecursive(node)
}
graph.onNodeAdded = originalOnNodeAdded || undefined
graph.onNodeRemoved = originalOnNodeRemoved || undefined
}
}

View File

@@ -0,0 +1,111 @@
import type { Ref } from 'vue'
import { computed, watch } from 'vue'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import type { NodeError } from '@/schemas/apiSchema'
import { getParentExecutionIds } from '@/types/nodeIdentification'
import { forEachNode, getNodeByExecutionId } from '@/utils/graphTraversalUtil'
function setNodeHasErrors(node: LGraphNode, hasErrors: boolean): void {
if (node.has_errors === hasErrors) return
const oldValue = node.has_errors
node.has_errors = hasErrors
node.graph?.trigger('node:property:changed', {
type: 'node:property:changed',
nodeId: node.id,
property: 'has_errors',
oldValue,
newValue: hasErrors
})
}
/**
* Single-pass reconciliation of node error flags.
* Collects the set of nodes that should have errors, then walks all nodes
* once, setting each flag exactly once. This avoids the redundant
* true→false→true transition (and duplicate events) that a clear-then-apply
* approach would cause.
*/
function reconcileNodeErrorFlags(
rootGraph: LGraph,
nodeErrors: Record<string, NodeError> | null,
missingModelExecIds: Set<string>
): void {
// Collect nodes and slot info that should be flagged
// Includes both error-owning nodes and their ancestor containers
const flaggedNodes = new Set<LGraphNode>()
const errorSlots = new Map<LGraphNode, Set<string>>()
if (nodeErrors) {
for (const [executionId, nodeError] of Object.entries(nodeErrors)) {
const node = getNodeByExecutionId(rootGraph, executionId)
if (!node) continue
flaggedNodes.add(node)
const slotNames = new Set<string>()
for (const error of nodeError.errors) {
const name = error.extra_info?.input_name
if (name) slotNames.add(name)
}
if (slotNames.size > 0) errorSlots.set(node, slotNames)
for (const parentId of getParentExecutionIds(executionId)) {
const parentNode = getNodeByExecutionId(rootGraph, parentId)
if (parentNode) flaggedNodes.add(parentNode)
}
}
}
for (const execId of missingModelExecIds) {
const node = getNodeByExecutionId(rootGraph, execId)
if (node) flaggedNodes.add(node)
}
forEachNode(rootGraph, (node) => {
setNodeHasErrors(node, flaggedNodes.has(node))
if (node.inputs) {
const nodeSlotNames = errorSlots.get(node)
for (const slot of node.inputs) {
slot.hasErrors = !!nodeSlotNames?.has(slot.name)
}
}
})
}
export function useNodeErrorFlagSync(
lastNodeErrors: Ref<Record<string, NodeError> | null>,
missingModelStore: ReturnType<typeof useMissingModelStore>
): () => void {
const settingStore = useSettingStore()
const showErrorsTab = computed(() =>
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')
)
const stop = watch(
[
lastNodeErrors,
() => missingModelStore.missingModelNodeIds,
showErrorsTab
],
() => {
if (!app.isGraphReady) return
// Legacy (LGraphNode) only: suppress missing-model error flags when
// the Errors tab is hidden, since legacy nodes lack the per-widget
// red highlight that Vue nodes use to indicate *why* a node has errors.
// Vue nodes compute hasAnyError independently and are unaffected.
reconcileNodeErrorFlags(
app.rootGraph,
lastNodeErrors.value,
showErrorsTab.value
? missingModelStore.missingModelAncestorExecutionIds
: new Set()
)
},
{ flush: 'post' }
)
return stop
}

View File

@@ -2,10 +2,7 @@ import { describe, expect, it } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
getCnrIdFromProperties,
getCnrIdFromNode
} from './missingNodeErrorUtil'
import { getCnrIdFromNode, getCnrIdFromProperties } from './cnrIdUtil'
describe('getCnrIdFromProperties', () => {
it('returns cnr_id when present', () => {

View File

@@ -20,8 +20,8 @@ vi.mock('@/utils/graphTraversalUtil', () => ({
getExecutionIdByNode: vi.fn()
}))
vi.mock('@/workbench/extensions/manager/utils/missingNodeErrorUtil', () => ({
getCnrIdFromNode: vi.fn(() => null)
vi.mock('@/platform/nodeReplacement/cnrIdUtil', () => ({
getCnrIdFromNode: vi.fn(() => undefined)
}))
vi.mock('@/i18n', () => ({
@@ -48,11 +48,10 @@ import {
collectAllNodes,
getExecutionIdByNode
} from '@/utils/graphTraversalUtil'
// eslint-disable-next-line import-x/no-restricted-paths
import { getCnrIdFromNode } from '@/workbench/extensions/manager/utils/missingNodeErrorUtil'
import { getCnrIdFromNode } from '@/platform/nodeReplacement/cnrIdUtil'
import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacementStore'
import { rescanAndSurfaceMissingNodes } from './missingNodeScan'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
function mockNode(
id: number,
@@ -72,7 +71,7 @@ function mockGraph(): LGraph {
}
function getMissingNodesError(
store: ReturnType<typeof useExecutionErrorStore>
store: ReturnType<typeof useMissingNodesErrorStore>
) {
const error = store.missingNodesError
if (!error) throw new Error('Expected missingNodesError to be defined')
@@ -99,7 +98,7 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
rescanAndSurfaceMissingNodes(mockGraph())
const store = useExecutionErrorStore()
const store = useMissingNodesErrorStore()
expect(store.missingNodesError).toBeNull()
})
@@ -112,7 +111,7 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
rescanAndSurfaceMissingNodes(mockGraph())
const store = useExecutionErrorStore()
const store = useMissingNodesErrorStore()
const error = getMissingNodesError(store)
expect(error.nodeTypes).toHaveLength(2)
})
@@ -129,7 +128,7 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
rescanAndSurfaceMissingNodes(mockGraph())
const store = useExecutionErrorStore()
const store = useMissingNodesErrorStore()
const error = getMissingNodesError(store)
expect(error.nodeTypes).toHaveLength(1)
const missing = error.nodeTypes[0]
@@ -142,7 +141,7 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
rescanAndSurfaceMissingNodes(mockGraph())
const store = useExecutionErrorStore()
const store = useMissingNodesErrorStore()
const error = getMissingNodesError(store)
const missing = error.nodeTypes[0]
expect(typeof missing !== 'string' && missing.nodeId).toBe('exec-42')
@@ -154,7 +153,7 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
rescanAndSurfaceMissingNodes(mockGraph())
const store = useExecutionErrorStore()
const store = useMissingNodesErrorStore()
const error = getMissingNodesError(store)
const missing = error.nodeTypes[0]
expect(typeof missing !== 'string' && missing.nodeId).toBe('99')
@@ -167,7 +166,7 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
rescanAndSurfaceMissingNodes(mockGraph())
const store = useExecutionErrorStore()
const store = useMissingNodesErrorStore()
const error = getMissingNodesError(store)
const missing = error.nodeTypes[0]
expect(typeof missing !== 'string' && missing.cnrId).toBe(
@@ -194,7 +193,7 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
rescanAndSurfaceMissingNodes(mockGraph())
const store = useExecutionErrorStore()
const store = useMissingNodesErrorStore()
const error = getMissingNodesError(store)
const missing = error.nodeTypes[0]
expect(typeof missing !== 'string' && missing.isReplaceable).toBe(true)
@@ -209,7 +208,7 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
rescanAndSurfaceMissingNodes(mockGraph())
const store = useExecutionErrorStore()
const store = useMissingNodesErrorStore()
const error = getMissingNodesError(store)
const missing = error.nodeTypes[0]
expect(typeof missing !== 'string' && missing.isReplaceable).toBe(false)
@@ -225,7 +224,7 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
rescanAndSurfaceMissingNodes(mockGraph())
const store = useExecutionErrorStore()
const store = useMissingNodesErrorStore()
const error = getMissingNodesError(store)
const missing = error.nodeTypes[0]
expect(typeof missing !== 'string' && missing.type).toBe('OriginalType')

View File

@@ -2,13 +2,13 @@ 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 { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import type { MissingNodeType } from '@/types/comfy'
import {
collectAllNodes,
getExecutionIdByNode
} from '@/utils/graphTraversalUtil'
// eslint-disable-next-line import-x/no-restricted-paths
import { getCnrIdFromNode } from '@/workbench/extensions/manager/utils/missingNodeErrorUtil'
import { getCnrIdFromNode } from '@/platform/nodeReplacement/cnrIdUtil'
/** Scan the live graph for unregistered node types and build a full MissingNodeType list. */
function scanMissingNodes(rootGraph: LGraph): MissingNodeType[] {
@@ -41,5 +41,6 @@ function scanMissingNodes(rootGraph: LGraph): MissingNodeType[] {
/** 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)
const { showErrorOverlay } = useExecutionErrorStore()
useMissingNodesErrorStore().surfaceMissingNodes(types, showErrorOverlay)
}

View File

@@ -0,0 +1,130 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { st } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import type { MissingNodeType } from '@/types/comfy'
import { getAncestorExecutionIds } from '@/types/nodeIdentification'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { getExecutionIdByNode } from '@/utils/graphTraversalUtil'
interface MissingNodesError {
message: string
nodeTypes: MissingNodeType[]
}
export const useMissingNodesErrorStore = defineStore(
'missingNodesError',
() => {
const missingNodesError = ref<MissingNodesError | null>(null)
function setMissingNodeTypes(types: MissingNodeType[]) {
if (!types.length) {
missingNodesError.value = null
return
}
const seen = new Set<string>()
const uniqueTypes = types.filter((node) => {
// For string entries (group nodes), deduplicate by the string itself.
// For object entries, prefer nodeId so multiple instances of the same
// type are kept as separate rows; fall back to type if nodeId is absent.
const isString = typeof node === 'string'
let key: string
if (isString) {
key = node
} else if (node.nodeId != null) {
key = String(node.nodeId)
} else {
key = node.type
}
if (seen.has(key)) return false
seen.add(key)
return true
})
missingNodesError.value = {
message: isCloud
? st(
'rightSidePanel.missingNodePacks.unsupportedTitle',
'Unsupported Node Packs'
)
: st('rightSidePanel.missingNodePacks.title', 'Missing Node Packs'),
nodeTypes: uniqueTypes
}
}
/** Set missing node types and open the error overlay if the Errors tab is enabled. */
function surfaceMissingNodes(
types: MissingNodeType[],
showErrorOverlay: () => void
) {
setMissingNodeTypes(types)
if (
types.length &&
useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')
) {
showErrorOverlay()
}
}
/** Remove specific node types from the missing nodes list (e.g. after replacement). */
function removeMissingNodesByType(typesToRemove: string[]) {
if (!missingNodesError.value) return
const removeSet = new Set(typesToRemove)
const remaining = missingNodesError.value.nodeTypes.filter((node) => {
const nodeType = typeof node === 'string' ? node : node.type
return !removeSet.has(nodeType)
})
setMissingNodeTypes(remaining)
}
const hasMissingNodes = computed(() => !!missingNodesError.value)
const missingNodeCount = computed(
() => missingNodesError.value?.nodeTypes.length ?? 0
)
/**
* Set of all execution ID prefixes derived from missing node execution IDs,
* including the missing nodes themselves.
*
* Example: missing node at "65:70:63" → Set { "65", "65:70", "65:70:63" }
*/
const missingAncestorExecutionIds = computed<Set<NodeExecutionId>>(() => {
const ids = new Set<NodeExecutionId>()
const error = missingNodesError.value
if (!error) return ids
for (const nodeType of error.nodeTypes) {
if (typeof nodeType === 'string') continue
if (nodeType.nodeId == null) continue
for (const id of getAncestorExecutionIds(String(nodeType.nodeId))) {
ids.add(id)
}
}
return ids
})
/** True if the node has a missing node inside it at any nesting depth. */
function isContainerWithMissingNode(node: LGraphNode): boolean {
if (!app.isGraphReady) return false
const execId = getExecutionIdByNode(app.rootGraph, node)
if (!execId) return false
return missingAncestorExecutionIds.value.has(execId)
}
return {
missingNodesError,
setMissingNodeTypes,
surfaceMissingNodes,
removeMissingNodesByType,
hasMissingNodes,
missingNodeCount,
missingAncestorExecutionIds,
isContainerWithMissingNode
}
}
)

View File

@@ -47,8 +47,8 @@ vi.mock('@/i18n', () => ({
const { mockRemoveMissingNodesByType } = vi.hoisted(() => ({
mockRemoveMissingNodesByType: vi.fn()
}))
vi.mock('@/stores/executionErrorStore', () => ({
useExecutionErrorStore: vi.fn(() => ({
vi.mock('@/platform/nodeReplacement/missingNodesErrorStore', () => ({
useMissingNodesErrorStore: vi.fn(() => ({
removeMissingNodesByType: mockRemoveMissingNodesByType
}))
}))

View File

@@ -7,7 +7,7 @@ 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 { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import type { MissingNodeType } from '@/types/comfy'
import { collectAllNodes } from '@/utils/graphTraversalUtil'
@@ -329,24 +329,24 @@ export function useNodeReplacement() {
/**
* Replaces all nodes in a single swap group and removes successfully
* replaced types from the execution error store.
* replaced types from the missing nodes error store.
*/
function replaceGroup(group: ReplacementGroup): void {
const replaced = replaceNodesInPlace(group.nodeTypes)
if (replaced.length > 0) {
useExecutionErrorStore().removeMissingNodesByType(replaced)
useMissingNodesErrorStore().removeMissingNodesByType(replaced)
}
}
/**
* Replaces every available node across all swap groups and removes
* the succeeded types from the execution error store.
* the succeeded types from the missing nodes 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)
useMissingNodesErrorStore().removeMissingNodesByType(replaced)
}
}

View File

@@ -12,7 +12,7 @@ import { useToastStore } from '@/platform/updates/common/toastStore'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { app } from '@/scripts/app'
import { useAppMode } from '@/composables/useAppMode'
import type { AppMode } from '@/composables/useAppMode'
@@ -160,7 +160,7 @@ describe('useWorkflowService', () => {
useWorkflowService().showPendingWarnings(workflow)
expect(
useExecutionErrorStore().surfaceMissingNodes
useMissingNodesErrorStore().surfaceMissingNodes
).not.toHaveBeenCalled()
})
@@ -170,9 +170,9 @@ describe('useWorkflowService', () => {
useWorkflowService().showPendingWarnings(workflow)
expect(useExecutionErrorStore().surfaceMissingNodes).toHaveBeenCalledWith(
missingNodeTypes
)
expect(
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenCalledWith(missingNodeTypes, expect.any(Function))
expect(workflow.pendingWarnings).toBeNull()
})
@@ -185,9 +185,9 @@ describe('useWorkflowService', () => {
useWorkflowService().showPendingWarnings(workflow)
expect(useExecutionErrorStore().surfaceMissingNodes).toHaveBeenCalledWith(
['CustomNode1']
)
expect(
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenCalledWith(['CustomNode1'], expect.any(Function))
expect(workflow.pendingWarnings).toBeNull()
})
@@ -201,7 +201,7 @@ describe('useWorkflowService', () => {
service.showPendingWarnings(workflow)
expect(
useExecutionErrorStore().surfaceMissingNodes
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenCalledTimes(1)
})
})
@@ -226,7 +226,7 @@ describe('useWorkflowService', () => {
)
expect(
useExecutionErrorStore().surfaceMissingNodes
useMissingNodesErrorStore().surfaceMissingNodes
).not.toHaveBeenCalled()
await useWorkflowService().openWorkflow(workflow)
@@ -238,9 +238,9 @@ describe('useWorkflowService', () => {
workflow,
expect.objectContaining({ deferWarnings: true })
)
expect(useExecutionErrorStore().surfaceMissingNodes).toHaveBeenCalledWith(
['CustomNode1']
)
expect(
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenCalledWith(['CustomNode1'], expect.any(Function))
expect(workflow.pendingWarnings).toBeNull()
})
@@ -258,21 +258,21 @@ describe('useWorkflowService', () => {
await service.openWorkflow(workflow1)
expect(
useExecutionErrorStore().surfaceMissingNodes
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenCalledTimes(1)
expect(useExecutionErrorStore().surfaceMissingNodes).toHaveBeenCalledWith(
['MissingNodeA']
)
expect(
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenCalledWith(['MissingNodeA'], expect.any(Function))
expect(workflow1.pendingWarnings).toBeNull()
expect(workflow2.pendingWarnings).not.toBeNull()
await service.openWorkflow(workflow2)
expect(
useExecutionErrorStore().surfaceMissingNodes
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenCalledTimes(2)
expect(
useExecutionErrorStore().surfaceMissingNodes
).toHaveBeenLastCalledWith(['MissingNodeB'])
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenLastCalledWith(['MissingNodeB'], expect.any(Function))
expect(workflow2.pendingWarnings).toBeNull()
})
@@ -286,12 +286,12 @@ describe('useWorkflowService', () => {
await service.openWorkflow(workflow, { force: true })
expect(
useExecutionErrorStore().surfaceMissingNodes
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenCalledTimes(1)
await service.openWorkflow(workflow, { force: true })
expect(
useExecutionErrorStore().surfaceMissingNodes
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenCalledTimes(1)
})
})

View File

@@ -22,6 +22,7 @@ import { useAppMode } from '@/composables/useAppMode'
import type { AppMode } from '@/composables/useAppMode'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import {
appendJsonExt,
@@ -42,6 +43,7 @@ export const useWorkflowService = () => {
const workflowThumbnail = useWorkflowThumbnail()
const domWidgetStore = useDomWidgetStore()
const executionErrorStore = useExecutionErrorStore()
const missingNodesErrorStore = useMissingNodesErrorStore()
const workflowDraftStore = useWorkflowDraftStore()
function confirmOverwrite(targetPath: string) {
@@ -540,7 +542,10 @@ export const useWorkflowService = () => {
wf.pendingWarnings = null
if (missingNodeTypes?.length) {
executionErrorStore.surfaceMissingNodes(missingNodeTypes)
missingNodesErrorStore.surfaceMissingNodes(
missingNodeTypes,
executionErrorStore.showErrorOverlay
)
}
}

View File

@@ -101,7 +101,11 @@
<!-- Video Dimensions -->
<div class="mt-2 text-center text-xs text-muted-foreground">
<span v-if="videoError" class="text-red-400">
<span
v-if="videoError"
class="text-red-400"
data-testid="error-loading-video"
>
{{ $t('g.errorLoadingVideo') }}
</span>
<span v-else-if="showLoader" class="text-smoke-400">

View File

@@ -90,7 +90,11 @@
<!-- Image Dimensions -->
<div class="pt-2 text-center text-xs text-base-foreground">
<span v-if="imageError" class="text-red-400">
<span
v-if="imageError"
class="text-red-400"
data-testid="error-loading-image"
>
{{ $t('g.errorLoadingImage') }}
</span>
<span v-else-if="showLoader" class="text-base-foreground">

View File

@@ -304,6 +304,7 @@ import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeS
import { app } from '@/scripts/app'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { isVideoOutput } from '@/utils/litegraphUtil'
@@ -355,6 +356,7 @@ const nodeLocatorId = computed(() => getLocatorIdFromNodeData(nodeData))
const { executing, progress } = useNodeExecutionState(nodeLocatorId)
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
const missingNodesErrorStore = useMissingNodesErrorStore()
const hasExecutionError = computed(
() => executionErrorStore.lastExecutionErrorNodeId === nodeData.id
)
@@ -368,7 +370,7 @@ const hasAnyError = computed((): boolean => {
missingModelStore.hasMissingModelOnNode(nodeLocatorId.value) ||
(lgraphNode.value &&
(executionErrorStore.isContainerWithInternalError(lgraphNode.value) ||
executionErrorStore.isContainerWithMissingNode(lgraphNode.value) ||
missingNodesErrorStore.isContainerWithMissingNode(lgraphNode.value) ||
missingModelStore.isContainerWithMissingModel(lgraphNode.value)))
)
})

View File

@@ -65,6 +65,7 @@ import { useCommandStore } from '@/stores/commandStore'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useExtensionStore } from '@/stores/extensionStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
@@ -83,7 +84,7 @@ import type { ComfyExtension, MissingNodeType } from '@/types/comfy'
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 { getCnrIdFromProperties } from '@/platform/nodeReplacement/cnrIdUtil'
import { rescanAndSurfaceMissingNodes } from '@/platform/nodeReplacement/missingNodeScan'
import {
scanAllModelCandidates,
@@ -1105,7 +1106,11 @@ export class ComfyApp {
}
private showMissingNodesError(missingNodeTypes: MissingNodeType[]) {
useExecutionErrorStore().surfaceMissingNodes(missingNodeTypes)
const { showErrorOverlay } = useExecutionErrorStore()
useMissingNodesErrorStore().surfaceMissingNodes(
missingNodeTypes,
showErrorOverlay
)
}
async loadGraphData(

View File

@@ -34,6 +34,7 @@ vi.mock(
)
import { useExecutionErrorStore } from './executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
describe('executionErrorStore — missing node operations', () => {
beforeEach(() => {
@@ -42,7 +43,7 @@ describe('executionErrorStore — missing node operations', () => {
describe('setMissingNodeTypes', () => {
it('sets missingNodesError with provided types', () => {
const store = useExecutionErrorStore()
const store = useMissingNodesErrorStore()
const types: MissingNodeType[] = [
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
]
@@ -54,7 +55,7 @@ describe('executionErrorStore — missing node operations', () => {
})
it('clears missingNodesError when given empty array', () => {
const store = useExecutionErrorStore()
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
])
@@ -66,7 +67,7 @@ describe('executionErrorStore — missing node operations', () => {
})
it('deduplicates string entries by value', () => {
const store = useExecutionErrorStore()
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
'NodeA',
'NodeA',
@@ -77,7 +78,7 @@ describe('executionErrorStore — missing node operations', () => {
})
it('deduplicates object entries by nodeId when present', () => {
const store = useExecutionErrorStore()
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
@@ -89,7 +90,7 @@ describe('executionErrorStore — missing node operations', () => {
})
it('deduplicates object entries by type when nodeId is absent', () => {
const store = useExecutionErrorStore()
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', isReplaceable: false },
{ type: 'NodeA', isReplaceable: true }
@@ -100,7 +101,7 @@ describe('executionErrorStore — missing node operations', () => {
})
it('keeps distinct nodeIds even when type is the same', () => {
const store = useExecutionErrorStore()
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeA', nodeId: '2', isReplaceable: false },
@@ -117,52 +118,63 @@ describe('executionErrorStore — missing node operations', () => {
})
it('stores missing node types when called', () => {
const store = useExecutionErrorStore()
const missingNodesStore = useMissingNodesErrorStore()
const executionErrorStore = useExecutionErrorStore()
const types: MissingNodeType[] = [
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
]
store.surfaceMissingNodes(types)
missingNodesStore.surfaceMissingNodes(types, () =>
executionErrorStore.showErrorOverlay()
)
expect(store.missingNodesError).not.toBeNull()
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
expect(store.hasMissingNodes).toBe(true)
expect(missingNodesStore.missingNodesError).not.toBeNull()
expect(missingNodesStore.missingNodesError?.nodeTypes).toHaveLength(1)
expect(missingNodesStore.hasMissingNodes).toBe(true)
})
it('opens error overlay when ShowErrorsTab setting is true', () => {
mockShowErrorsTab.value = true
const store = useExecutionErrorStore()
store.surfaceMissingNodes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
])
const missingNodesStore = useMissingNodesErrorStore()
const executionErrorStore = useExecutionErrorStore()
missingNodesStore.surfaceMissingNodes(
[{ type: 'NodeA', nodeId: '1', isReplaceable: false }],
() => executionErrorStore.showErrorOverlay()
)
expect(store.isErrorOverlayOpen).toBe(true)
expect(executionErrorStore.isErrorOverlayOpen).toBe(true)
})
it('does not open error overlay when ShowErrorsTab setting is false', () => {
mockShowErrorsTab.value = false
const store = useExecutionErrorStore()
store.surfaceMissingNodes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
])
const missingNodesStore = useMissingNodesErrorStore()
const executionErrorStore = useExecutionErrorStore()
missingNodesStore.surfaceMissingNodes(
[{ type: 'NodeA', nodeId: '1', isReplaceable: false }],
() => executionErrorStore.showErrorOverlay()
)
expect(store.isErrorOverlayOpen).toBe(false)
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
})
it('deduplicates node types', () => {
const store = useExecutionErrorStore()
store.surfaceMissingNodes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeB', nodeId: '2', isReplaceable: false }
])
const missingNodesStore = useMissingNodesErrorStore()
const executionErrorStore = useExecutionErrorStore()
missingNodesStore.surfaceMissingNodes(
[
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeB', nodeId: '2', isReplaceable: false }
],
() => executionErrorStore.showErrorOverlay()
)
expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
expect(missingNodesStore.missingNodesError?.nodeTypes).toHaveLength(2)
})
})
describe('removeMissingNodesByType', () => {
it('removes matching types from the missing nodes list', () => {
const store = useExecutionErrorStore()
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeB', nodeId: '2', isReplaceable: false },
@@ -177,7 +189,7 @@ describe('executionErrorStore — missing node operations', () => {
})
it('clears missingNodesError when all types are removed', () => {
const store = useExecutionErrorStore()
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
])
@@ -189,7 +201,7 @@ describe('executionErrorStore — missing node operations', () => {
})
it('does nothing when missingNodesError is null', () => {
const store = useExecutionErrorStore()
const store = useMissingNodesErrorStore()
expect(store.missingNodesError).toBeNull()
// Should not throw
@@ -198,7 +210,7 @@ describe('executionErrorStore — missing node operations', () => {
})
it('does nothing when removing non-existent types', () => {
const store = useExecutionErrorStore()
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
])
@@ -209,7 +221,7 @@ describe('executionErrorStore — missing node operations', () => {
})
it('handles removing from string entries', () => {
const store = useExecutionErrorStore()
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
'StringNodeA',
'StringNodeB'
@@ -537,16 +549,18 @@ describe('executionErrorStore — node error operations', () => {
})
describe('clearAllErrors', () => {
let store: ReturnType<typeof useExecutionErrorStore>
let executionErrorStore: ReturnType<typeof useExecutionErrorStore>
let missingNodesStore: ReturnType<typeof useMissingNodesErrorStore>
beforeEach(() => {
const pinia = createPinia()
setActivePinia(pinia)
store = useExecutionErrorStore()
executionErrorStore = useExecutionErrorStore()
missingNodesStore = useMissingNodesErrorStore()
})
it('resets all error categories and closes error overlay', () => {
store.lastExecutionError = {
executionErrorStore.lastExecutionError = {
prompt_id: 'test',
timestamp: 0,
node_id: '1',
@@ -556,8 +570,12 @@ describe('clearAllErrors', () => {
exception_type: 'RuntimeError',
traceback: []
}
store.lastPromptError = { type: 'execution', message: 'fail', details: '' }
store.lastNodeErrors = {
executionErrorStore.lastPromptError = {
type: 'execution',
message: 'fail',
details: ''
}
executionErrorStore.lastNodeErrors = {
'1': {
errors: [
{
@@ -571,19 +589,18 @@ describe('clearAllErrors', () => {
class_type: 'Test'
}
}
store.missingNodesError = {
message: 'Missing nodes',
nodeTypes: [{ type: 'MissingNode', hint: '' }]
}
store.showErrorOverlay()
missingNodesStore.setMissingNodeTypes([
{ type: 'MissingNode', hint: '' }
] as unknown as MissingNodeType[])
executionErrorStore.showErrorOverlay()
store.clearAllErrors()
executionErrorStore.clearAllErrors()
expect(store.lastExecutionError).toBeNull()
expect(store.lastPromptError).toBeNull()
expect(store.lastNodeErrors).toBeNull()
expect(store.missingNodesError).toBeNull()
expect(store.isErrorOverlayOpen).toBe(false)
expect(store.hasAnyError).toBe(false)
expect(executionErrorStore.lastExecutionError).toBeNull()
expect(executionErrorStore.lastPromptError).toBeNull()
expect(executionErrorStore.lastNodeErrors).toBeNull()
expect(missingNodesStore.missingNodesError).toBeNull()
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
expect(executionErrorStore.hasAnyError).toBe(false)
})
})

View File

@@ -1,122 +1,43 @@
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import { computed, ref } from 'vue'
import { st } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeErrorFlagSync } from '@/composables/graph/useNodeErrorFlagSync'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import type {
ExecutionErrorWsMessage,
NodeError,
PromptError
} from '@/schemas/apiSchema'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
getAncestorExecutionIds,
getParentExecutionIds
} from '@/types/nodeIdentification'
import { getAncestorExecutionIds } from '@/types/nodeIdentification'
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
import type { MissingNodeType } from '@/types/comfy'
import {
executionIdToNodeLocatorId,
forEachNode,
getNodeByExecutionId,
getExecutionIdByNode,
getActiveGraphNodeIds
getNodeByExecutionId
} from '@/utils/graphTraversalUtil'
import {
isValueStillOutOfRange,
SIMPLE_ERROR_TYPES
SIMPLE_ERROR_TYPES,
isValueStillOutOfRange
} from '@/utils/executionErrorUtil'
interface MissingNodesError {
message: string
nodeTypes: MissingNodeType[]
}
function setNodeHasErrors(node: LGraphNode, hasErrors: boolean): void {
if (node.has_errors === hasErrors) return
const oldValue = node.has_errors
node.has_errors = hasErrors
node.graph?.trigger('node:property:changed', {
type: 'node:property:changed',
nodeId: node.id,
property: 'has_errors',
oldValue,
newValue: hasErrors
})
}
/**
* Single-pass reconciliation of node error flags.
* Collects the set of nodes that should have errors, then walks all nodes
* once, setting each flag exactly once. This avoids the redundant
* true→false→true transition (and duplicate events) that a clear-then-apply
* approach would cause.
*/
function reconcileNodeErrorFlags(
rootGraph: LGraph,
nodeErrors: Record<string, NodeError> | null,
missingModelExecIds: Set<string>
): void {
// Collect nodes and slot info that should be flagged
// Includes both error-owning nodes and their ancestor containers
const flaggedNodes = new Set<LGraphNode>()
const errorSlots = new Map<LGraphNode, Set<string>>()
if (nodeErrors) {
for (const [executionId, nodeError] of Object.entries(nodeErrors)) {
const node = getNodeByExecutionId(rootGraph, executionId)
if (!node) continue
flaggedNodes.add(node)
const slotNames = new Set<string>()
for (const error of nodeError.errors) {
const name = error.extra_info?.input_name
if (name) slotNames.add(name)
}
if (slotNames.size > 0) errorSlots.set(node, slotNames)
for (const parentId of getParentExecutionIds(executionId)) {
const parentNode = getNodeByExecutionId(rootGraph, parentId)
if (parentNode) flaggedNodes.add(parentNode)
}
}
}
for (const execId of missingModelExecIds) {
const node = getNodeByExecutionId(rootGraph, execId)
if (node) flaggedNodes.add(node)
}
forEachNode(rootGraph, (node) => {
setNodeHasErrors(node, flaggedNodes.has(node))
if (node.inputs) {
const nodeSlotNames = errorSlots.get(node)
for (const slot of node.inputs) {
slot.hasErrors = !!nodeSlotNames?.has(slot.name)
}
}
})
}
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
/** Execution error state: node errors, runtime errors, prompt errors, and missing assets. */
export const useExecutionErrorStore = defineStore('executionError', () => {
const workflowStore = useWorkflowStore()
const canvasStore = useCanvasStore()
const missingModelStore = useMissingModelStore()
const missingNodesStore = useMissingNodesErrorStore()
const lastNodeErrors = ref<Record<NodeId, NodeError> | null>(null)
const lastExecutionError = ref<ExecutionErrorWsMessage | null>(null)
const lastPromptError = ref<PromptError | null>(null)
const missingNodesError = ref<MissingNodesError | null>(null)
const isErrorOverlayOpen = ref(false)
@@ -136,7 +57,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
lastExecutionError.value = null
lastPromptError.value = null
lastNodeErrors.value = null
missingNodesError.value = null
missingNodesStore.setMissingNodeTypes([])
isErrorOverlayOpen.value = false
}
@@ -238,14 +159,6 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
missingModelStore.removeMissingModelByWidget(executionId, widgetName)
}
/** Set missing node types and open the error overlay if the Errors tab is enabled. */
function surfaceMissingNodes(types: MissingNodeType[]) {
setMissingNodeTypes(types)
if (useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) {
showErrorOverlay()
}
}
/** Set missing models and open the error overlay if the Errors tab is enabled. */
function surfaceMissingModels(models: MissingModelCandidate[]) {
missingModelStore.setMissingModels(models)
@@ -257,51 +170,6 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
}
}
/** Remove specific node types from the missing nodes list (e.g. after replacement). */
function removeMissingNodesByType(typesToRemove: string[]) {
if (!missingNodesError.value) return
const removeSet = new Set(typesToRemove)
const remaining = missingNodesError.value.nodeTypes.filter((node) => {
const nodeType = typeof node === 'string' ? node : node.type
return !removeSet.has(nodeType)
})
setMissingNodeTypes(remaining)
}
function setMissingNodeTypes(types: MissingNodeType[]) {
if (!types.length) {
missingNodesError.value = null
return
}
const seen = new Set<string>()
const uniqueTypes = types.filter((node) => {
// For string entries (group nodes), deduplicate by the string itself.
// For object entries, prefer nodeId so multiple instances of the same
// type are kept as separate rows; fall back to type if nodeId is absent.
const isString = typeof node === 'string'
let key: string
if (isString) {
key = node
} else if (node.nodeId != null) {
key = String(node.nodeId)
} else {
key = node.type
}
if (seen.has(key)) return false
seen.add(key)
return true
})
missingNodesError.value = {
message: isCloud
? st(
'rightSidePanel.missingNodePacks.unsupportedTitle',
'Unsupported Node Packs'
)
: st('rightSidePanel.missingNodePacks.title', 'Missing Node Packs'),
nodeTypes: uniqueTypes
}
}
const lastExecutionErrorNodeLocatorId = computed(() => {
const err = lastExecutionError.value
if (!err) return null
@@ -323,14 +191,12 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
() => !!lastNodeErrors.value && Object.keys(lastNodeErrors.value).length > 0
)
const hasMissingNodes = computed(() => !!missingNodesError.value)
const hasAnyError = computed(
() =>
hasExecutionError.value ||
hasPromptError.value ||
hasNodeError.value ||
hasMissingNodes.value ||
missingNodesStore.hasMissingNodes ||
missingModelStore.hasMissingModels
)
@@ -361,14 +227,12 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
const executionErrorCount = computed(() => (lastExecutionError.value ? 1 : 0))
const missingNodeCount = computed(() => (missingNodesError.value ? 1 : 0))
const totalErrorCount = computed(
() =>
promptErrorCount.value +
nodeErrorCount.value +
executionErrorCount.value +
missingNodeCount.value +
missingNodesStore.missingNodeCount +
missingModelStore.missingModelCount
)
@@ -400,37 +264,6 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
return ids
})
/**
* Set of all execution ID prefixes derived from missing node execution IDs,
* including the missing nodes themselves.
*
* Example: missing node at "65:70:63" → Set { "65", "65:70", "65:70:63" }
*/
const missingAncestorExecutionIds = computed<Set<NodeExecutionId>>(() => {
const ids = new Set<NodeExecutionId>()
const error = missingNodesError.value
if (!error) return ids
for (const nodeType of error.nodeTypes) {
if (typeof nodeType === 'string') continue
if (nodeType.nodeId == null) continue
for (const id of getAncestorExecutionIds(String(nodeType.nodeId))) {
ids.add(id)
}
}
return ids
})
const activeMissingNodeGraphIds = computed<Set<string>>(() => {
if (!app.isGraphReady) return new Set()
return getActiveGraphNodeIds(
app.rootGraph,
canvasStore.currentGraph ?? app.rootGraph,
missingAncestorExecutionIds.value
)
})
/** Map of node errors indexed by locator ID. */
const nodeErrorsByLocatorId = computed<Record<NodeLocatorId, NodeError>>(
() => {
@@ -493,42 +326,13 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
return errorAncestorExecutionIds.value.has(execId)
}
/** True if the node has a missing node inside it at any nesting depth. */
function isContainerWithMissingNode(node: LGraphNode): boolean {
if (!app.isGraphReady) return false
const execId = getExecutionIdByNode(app.rootGraph, node)
if (!execId) return false
return missingAncestorExecutionIds.value.has(execId)
}
watch(
[lastNodeErrors, () => missingModelStore.missingModelNodeIds],
() => {
if (!app.isGraphReady) return
// Legacy (LGraphNode) only: suppress missing-model error flags when
// the Errors tab is hidden, since legacy nodes lack the per-widget
// red highlight that Vue nodes use to indicate *why* a node has errors.
// Vue nodes compute hasAnyError independently and are unaffected.
const showErrorsTab = useSettingStore().get(
'Comfy.RightSidePanel.ShowErrorsTab'
)
reconcileNodeErrorFlags(
app.rootGraph,
lastNodeErrors.value,
showErrorsTab
? missingModelStore.missingModelAncestorExecutionIds
: new Set()
)
},
{ flush: 'post' }
)
useNodeErrorFlagSync(lastNodeErrors, missingModelStore)
return {
// Raw state
lastNodeErrors,
lastExecutionError,
lastPromptError,
missingNodesError,
// Clearing
clearAllErrors,
@@ -543,30 +347,22 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
hasExecutionError,
hasPromptError,
hasNodeError,
hasMissingNodes,
hasAnyError,
allErrorExecutionIds,
totalErrorCount,
lastExecutionErrorNodeId,
activeGraphErrorNodeIds,
activeMissingNodeGraphIds,
// Clearing (targeted)
clearSimpleNodeErrors,
clearWidgetRelatedErrors,
// Missing node actions
setMissingNodeTypes,
surfaceMissingNodes,
removeMissingNodesByType,
// Missing model coordination (delegates to missingModelStore)
surfaceMissingModels,
// Lookup helpers
getNodeErrors,
slotHasError,
isContainerWithInternalError,
isContainerWithMissingNode
isContainerWithInternalError
}
})

View File

@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { app } from '@/scripts/app'
import { MAX_PROGRESS_JOBS, useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
// Create mock functions that will be shared
@@ -598,13 +599,13 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
})
})
describe('useExecutionErrorStore - setMissingNodeTypes', () => {
let store: ReturnType<typeof useExecutionErrorStore>
describe('useMissingNodesErrorStore - setMissingNodeTypes', () => {
let store: ReturnType<typeof useMissingNodesErrorStore>
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
store = useExecutionErrorStore()
store = useMissingNodesErrorStore()
})
it('clears missingNodesError when called with an empty array', () => {