mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-21 21:07:33 +00:00
Compare commits
3 Commits
main
...
refactor/e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2edd60c796 | ||
|
|
9f3cacfd78 | ||
|
|
3858432fe6 |
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />' }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
39
src/components/rightSidePanel/errors/useErrorActions.ts
Normal file
39
src/components/rightSidePanel/errors/useErrorActions.ts
Normal 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 }
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
111
src/composables/graph/useNodeErrorFlagSync.ts
Normal file
111
src/composables/graph/useNodeErrorFlagSync.ts
Normal 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
|
||||
}
|
||||
@@ -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', () => {
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
130
src/platform/nodeReplacement/missingNodesErrorStore.ts
Normal file
130
src/platform/nodeReplacement/missingNodesErrorStore.ts
Normal 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
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)))
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user