Compare commits

..

4 Commits

Author SHA1 Message Date
GitHub Action
def709cd7d [automated] Apply ESLint and Oxfmt fixes 2026-03-24 02:13:53 +00:00
Matt Miller
e6a423c36e fix: unexport FILE_INPUT_FIELDS and fix lint
- Remove export from FILE_INPUT_FIELDS (flagged by knip as unused export)
- Extract EmptyFileInputNode interface to fix oxfmt line length

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 19:11:01 -07:00
Matt Miller
1b6c2f0add fix: address review findings for empty file input validation
- Broaden check to catch null, undefined, and whitespace-only values
- Skip linked inputs (array refs to upstream nodes) to avoid false positives
- Scope validation to target nodes during partial execution
- Use `as const` for FILE_INPUT_FIELDS
- Add test coverage for edge cases (null, undefined, whitespace, linked
  inputs, partial execution filtering, missing fields)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 18:41:04 -07:00
Matt Miller
68c6a9d7e2 fix: block workflow queue when file input nodes have empty selections
Prevents submitting workflows where LoadImage, LoadAudio, Load3D, or
LoadVideo nodes have no file selected. Shows a localized error dialog
listing the affected nodes instead of sending an invalid request.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 18:29:53 -07:00
58 changed files with 1246 additions and 1684 deletions

View File

@@ -28,15 +28,10 @@ export const TestIds = {
settingsTabAbout: 'settings-tab-about',
confirm: 'confirm-dialog',
errorOverlay: 'error-overlay',
errorOverlaySeeErrors: 'error-overlay-see-errors',
runtimeErrorPanel: 'runtime-error-panel',
missingNodeCard: 'missing-node-card',
errorCardFindOnGithub: 'error-card-find-on-github',
errorCardCopy: 'error-card-copy',
about: 'about-panel',
whatsNewSection: 'whats-new-section',
missingNodePacksGroup: 'error-group-missing-node',
missingModelsGroup: 'error-group-missing-model'
whatsNewSection: 'whats-new-section'
},
keybindings: {
presetMenu: 'keybinding-preset-menu'
@@ -81,10 +76,6 @@ export const TestIds = {
},
user: {
currentUserIndicator: 'current-user-indicator'
},
errors: {
imageLoadError: 'error-loading-image',
videoLoadError: 'error-loading-video'
}
} as const
@@ -110,4 +101,3 @@ 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 = errorOverlay.getByText(/Missing Node Packs/)
const missingNodesTitle = comfyPage.page.getByText(/Missing Node Packs/)
await expect(missingNodesTitle).toBeVisible()
})
@@ -42,13 +42,11 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
)
await expect(errorOverlay).toBeVisible()
const missingNodesTitle = errorOverlay.getByText(/Missing Node Packs/)
const missingNodesTitle = comfyPage.page.getByText(/Missing Node Packs/)
await expect(missingNodesTitle).toBeVisible()
// Click "See Errors" to open the errors tab and verify subgraph node content
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
await errorOverlay.getByRole('button', { name: 'See Errors' }).click()
await expect(errorOverlay).not.toBeVisible()
const missingNodeCard = comfyPage.page.getByTestId(
@@ -77,9 +75,7 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
await expect(errorOverlay).toBeVisible()
// Click "See Errors" to open the right side panel errors tab
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
await errorOverlay.getByRole('button', { name: 'See Errors' }).click()
await expect(errorOverlay).not.toBeVisible()
// Verify MissingNodeCard is rendered in the errors tab
@@ -169,19 +165,17 @@ test.describe('Error actions in Errors Tab', { tag: '@ui' }, () => {
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
await errorOverlay.getByRole('button', { name: 'See Errors' }).click()
await expect(errorOverlay).not.toBeVisible()
// Verify Find on GitHub button is present in the error card
const findOnGithubButton = comfyPage.page.getByTestId(
TestIds.dialogs.errorCardFindOnGithub
)
const findOnGithubButton = comfyPage.page.getByRole('button', {
name: 'Find on GitHub'
})
await expect(findOnGithubButton).toBeVisible()
// Verify Copy button is present in the error card
const copyButton = comfyPage.page.getByTestId(TestIds.dialogs.errorCardCopy)
const copyButton = comfyPage.page.getByRole('button', { name: 'Copy' })
await expect(copyButton).toBeVisible()
})
})
@@ -210,7 +204,7 @@ test.describe('Missing models in Error Tab', () => {
)
await expect(errorOverlay).toBeVisible()
const missingModelsTitle = errorOverlay.getByText(/Missing Models/)
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
await expect(missingModelsTitle).toBeVisible()
})
@@ -226,7 +220,7 @@ test.describe('Missing models in Error Tab', () => {
)
await expect(errorOverlay).toBeVisible()
const missingModelsTitle = errorOverlay.getByText(/Missing Models/)
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
await expect(missingModelsTitle).toBeVisible()
})
@@ -237,10 +231,13 @@ test.describe('Missing models in Error Tab', () => {
'missing/model_metadata_widget_mismatch'
)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).not.toBeVisible()
await expect(comfyPage.page.getByText(/Missing Models/)).not.toBeVisible()
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()
})
// Flaky test after parallelization

View File

@@ -764,13 +764,13 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
)
})
const generateUniqueFilename = (extension = '') =>
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
test.describe('Restore all open workflows on reload', () => {
let workflowA: string
let workflowB: string
const generateUniqueFilename = (extension = '') =>
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
@@ -829,82 +829,6 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
})
})
test.describe('Restore workflow tabs after browser restart', () => {
let workflowA: string
let workflowB: string
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
workflowA = generateUniqueFilename()
await comfyPage.menu.topbar.saveWorkflow(workflowA)
workflowB = generateUniqueFilename()
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.menu.topbar.saveWorkflow(workflowB)
// Wait for localStorage fallback pointers to be written
await comfyPage.page.waitForFunction(() => {
for (let i = 0; i < window.localStorage.length; i++) {
const key = window.localStorage.key(i)
if (key?.startsWith('Comfy.Workflow.LastOpenPaths:')) {
return true
}
}
return false
})
// Simulate browser restart: clear sessionStorage (lost on close)
// but keep localStorage (survives browser restart)
await comfyPage.page.evaluate(() => {
sessionStorage.clear()
})
await comfyPage.setup({ clearStorage: false })
})
test('Restores topbar workflow tabs after browser restart', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
// Wait for both restored tabs to render (localStorage fallback is async)
await expect(
comfyPage.page.locator('.workflow-tabs .workflow-label', {
hasText: workflowA
})
).toBeVisible()
const tabs = await comfyPage.menu.topbar.getTabNames()
const activeWorkflowName = await comfyPage.menu.topbar.getActiveTabName()
expect(tabs).toEqual(expect.arrayContaining([workflowA, workflowB]))
expect(tabs.indexOf(workflowA)).toBeLessThan(tabs.indexOf(workflowB))
expect(activeWorkflowName).toEqual(workflowB)
})
test('Restores sidebar workflows after browser restart', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Sidebar'
)
await comfyPage.menu.workflowsTab.open()
const openWorkflows =
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
const activeWorkflowName =
await comfyPage.menu.workflowsTab.getActiveWorkflowName()
expect(openWorkflows).toEqual(
expect.arrayContaining([workflowA, workflowB])
)
expect(openWorkflows.indexOf(workflowA)).toBeLessThan(
openWorkflows.indexOf(workflowB)
)
expect(activeWorkflowName).toEqual(workflowB)
})
})
test('Auto fit view after loading workflow', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.EnableWorkflowViewRestore',

View File

@@ -206,31 +206,6 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
await expect(nav).toBeVisible() // Nav should be visible at tablet size
})
test(
'select components in filter bar render correctly',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
// Wait for filter bar select components to render
const dialog = comfyPage.page.getByRole('dialog')
const sortBySelect = dialog.getByRole('combobox', { name: /Sort/ })
await expect(sortBySelect).toBeVisible()
// Screenshot the filter bar containing MultiSelect and SingleSelect
const filterBar = sortBySelect.locator(
'xpath=ancestor::div[contains(@class, "justify-between")]'
)
await expect(filterBar).toHaveScreenshot(
'template-filter-bar-select-components.png',
{
mask: [comfyPage.page.locator('.p-toast')]
}
)
}
)
test(
'template cards descriptions adjust height dynamically',
{ tag: '@screenshot' },

View File

@@ -47,46 +47,6 @@ test.describe('Vue Node Moving', () => {
}
)
test('should not move node when pointer moves less than drag threshold', async ({
comfyPage
}) => {
const headerPos = await getLoadCheckpointHeaderPos(comfyPage)
// Move only 2px — below the 3px drag threshold in useNodePointerInteractions
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(headerPos.x + 2, headerPos.y + 1, {
steps: 5
})
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
const afterPos = await getLoadCheckpointHeaderPos(comfyPage)
expect(afterPos.x).toBeCloseTo(headerPos.x, 0)
expect(afterPos.y).toBeCloseTo(headerPos.y, 0)
// The small movement should have selected the node, not dragged it
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
})
test('should move node when pointer moves beyond drag threshold', async ({
comfyPage
}) => {
const headerPos = await getLoadCheckpointHeaderPos(comfyPage)
// Move 50px — well beyond the 3px drag threshold
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(headerPos.x + 50, headerPos.y + 50, {
steps: 20
})
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
const afterPos = await getLoadCheckpointHeaderPos(comfyPage)
await expectPosChanged(headerPos, afterPos)
})
test(
'@mobile should allow moving nodes by dragging on touch devices',
{ tag: '@screenshot' },

View File

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

View File

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

View File

@@ -49,12 +49,7 @@
<Button variant="muted-textonly" size="unset" @click="dismiss">
{{ t('g.dismiss') }}
</Button>
<Button
variant="secondary"
size="lg"
data-testid="error-overlay-see-errors"
@click="seeErrors"
>
<Button variant="secondary" size="lg" @click="seeErrors">
{{
appMode ? t('linearMode.error.goto') : t('errorOverlay.seeErrors')
}}

View File

@@ -1,60 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import MultiSelect from './MultiSelect.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
multiSelectDropdown: 'Multi-select dropdown',
noResultsFound: 'No results found',
search: 'Search',
clearAll: 'Clear all',
itemsSelected: 'Items selected'
}
}
}
})
describe('MultiSelect', () => {
function createWrapper() {
return mount(MultiSelect, {
attachTo: document.body,
global: {
plugins: [i18n]
},
props: {
modelValue: [],
label: 'Category',
options: [
{ name: 'One', value: 'one' },
{ name: 'Two', value: 'two' }
]
}
})
}
it('keeps open-state border styling available while the dropdown is open', async () => {
const wrapper = createWrapper()
const trigger = wrapper.get('button[aria-haspopup="listbox"]')
expect(trigger.classes()).toContain(
'data-[state=open]:border-node-component-border'
)
expect(trigger.attributes('aria-expanded')).toBe('false')
await trigger.trigger('click')
await nextTick()
expect(trigger.attributes('aria-expanded')).toBe('true')
expect(trigger.attributes('data-state')).toBe('open')
wrapper.unmount()
})
})

View File

@@ -1,215 +1,207 @@
<template>
<ComboboxRoot
<!--
Note: Unlike SingleSelect, we don't need an explicit options prop because:
1. Our value template only shows a static label (not dynamic based on selection)
2. We display a count badge instead of actual selected labels
3. All PrimeVue props (including options) are passed via v-bind="$attrs"
option-label="name" is required because our option template directly accesses option.name
max-selected-labels="0" is required to show count badge instead of selected item labels
-->
<MultiSelect
v-model="selectedItems"
multiple
by="value"
:disabled
ignore-filter
:reset-search-term-on-select="false"
v-bind="{ ...$attrs, options: filteredOptions }"
option-label="name"
unstyled
:max-selected-labels="0"
:pt="{
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
class: cn(
'relative inline-flex cursor-pointer select-none',
size === 'md' ? 'h-8' : 'h-10',
'rounded-lg bg-secondary-background text-base-foreground',
'transition-all duration-200 ease-in-out',
'hover:bg-secondary-background-hover',
'border-[2.5px] border-solid',
selectedCount > 0 ? 'border-base-foreground' : 'border-transparent',
'focus-within:border-base-foreground',
props.disabled &&
'cursor-default opacity-30 hover:bg-secondary-background'
)
}),
labelContainer: {
class: cn(
'flex flex-1 items-center overflow-hidden py-2 whitespace-nowrap',
size === 'md' ? 'pl-3' : 'pl-4'
)
},
label: {
class: 'p-0'
},
dropdown: {
class: 'flex shrink-0 cursor-pointer items-center justify-center px-3'
},
header: () => ({
class:
showSearchBox || showSelectedCount || showClearButton
? 'block'
: 'hidden'
}),
// Overlay & list visuals unchanged
overlay: {
class: cn(
'mt-2 rounded-lg p-2',
'bg-base-background',
'text-base-foreground',
'border border-solid border-border-default'
)
},
listContainer: () => ({
style: { maxHeight: `min(${listMaxHeight}, 50vh)` },
class: 'scrollbar-custom'
}),
list: {
class: 'flex flex-col gap-0 p-0 m-0 list-none border-none text-sm'
},
// Option row hover and focus tone
option: ({ context }: MultiSelectPassThroughMethodOptions) => ({
class: cn(
'flex h-10 cursor-pointer items-center gap-2 rounded-lg px-2',
'hover:bg-secondary-background-hover',
// Add focus/highlight state for keyboard navigation
context?.focused &&
'bg-secondary-background-selected hover:bg-secondary-background-selected'
)
}),
// Hide built-in checkboxes entirely via PT (no :deep)
pcHeaderCheckbox: {
root: { class: 'hidden' },
style: { display: 'none' }
},
pcOptionCheckbox: {
root: { class: 'hidden' },
style: { display: 'none' }
},
emptyMessage: {
class: 'px-3 pb-4 text-sm text-muted-foreground'
}
}"
:aria-label="label || t('g.multiSelectDropdown')"
role="combobox"
:aria-expanded="false"
aria-haspopup="listbox"
:tabindex="0"
>
<ComboboxAnchor as-child>
<ComboboxTrigger
v-bind="$attrs"
:aria-label="label || t('g.multiSelectDropdown')"
:class="
cn(
'relative inline-flex cursor-pointer items-center select-none',
size === 'md' ? 'h-8' : 'h-10',
'rounded-lg bg-secondary-background text-base-foreground',
'transition-all duration-200 ease-in-out',
'hover:bg-secondary-background-hover',
'border-[2.5px] border-solid border-transparent',
selectedCount > 0
? 'border-base-foreground'
: 'focus-visible:border-node-component-border data-[state=open]:border-node-component-border',
disabled &&
'cursor-default opacity-30 hover:bg-secondary-background'
)
"
>
<template
v-if="showSearchBox || showSelectedCount || showClearButton"
#header
>
<div class="flex flex-col px-2 pt-2 pb-0">
<SearchInput
v-if="showSearchBox"
v-model="searchQuery"
:class="showSelectedCount || showClearButton ? 'mb-2' : ''"
:placeholder="searchPlaceholder"
size="sm"
/>
<div
:class="
cn(
'flex flex-1 items-center overflow-hidden py-2 whitespace-nowrap',
size === 'md' ? 'pl-3' : 'pl-4'
)
"
v-if="showSelectedCount || showClearButton"
class="mt-2 flex items-center justify-between"
>
<span :class="size === 'md' ? 'text-xs' : 'text-sm'">
{{ label }}
</span>
<span
v-if="selectedCount > 0"
class="pointer-events-none absolute -top-2 -right-2 z-10 flex size-5 items-center justify-center rounded-full bg-base-foreground text-xs font-semibold text-base-background"
v-if="showSelectedCount"
class="px-1 text-sm text-base-foreground"
>
{{ selectedCount }}
{{
selectedCount > 0
? $t('g.itemsSelected', { selectedCount })
: $t('g.itemSelected', { selectedCount })
}}
</span>
<Button
v-if="showClearButton"
variant="textonly"
size="md"
@click.stop="selectedItems = []"
>
{{ $t('g.clearAll') }}
</Button>
</div>
<div
class="flex shrink-0 cursor-pointer items-center justify-center px-3"
>
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
</div>
</ComboboxTrigger>
</ComboboxAnchor>
<div class="my-4 h-px bg-border-default"></div>
</div>
</template>
<ComboboxPortal>
<ComboboxContent
position="popper"
:side-offset="8"
align="start"
<!-- Trigger value (keep text scale identical) -->
<template #value>
<span :class="size === 'md' ? 'text-xs' : 'text-sm'">
{{ label }}
</span>
<span
v-if="selectedCount > 0"
class="pointer-events-none absolute -top-2 -right-2 z-10 flex size-5 items-center justify-center rounded-full bg-base-foreground text-xs font-semibold text-base-background"
>
{{ selectedCount }}
</span>
</template>
<!-- Chevron size identical to current -->
<template #dropdownicon>
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
</template>
<!-- Custom option row: square checkbox + label (unchanged layout/colors) -->
<template #option="slotProps">
<div
role="button"
class="flex cursor-pointer items-center gap-2"
:style="popoverStyle"
:class="
cn(
'z-3000 overflow-hidden',
'rounded-lg p-2',
'bg-base-background text-base-foreground',
'border border-solid border-border-default',
'shadow-md',
'data-[state=closed]:animate-out data-[state=open]:animate-in',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2'
)
"
@focus-outside="preventFocusDismiss"
>
<div
v-if="showSearchBox || showSelectedCount || showClearButton"
class="flex flex-col px-2 pt-2 pb-0"
>
<div
v-if="showSearchBox"
:class="
cn(
'flex items-center gap-2 rounded-lg border border-solid border-border-default px-3 py-1.5',
(showSelectedCount || showClearButton) && 'mb-2'
)
"
>
<i
class="icon-[lucide--search] shrink-0 text-sm text-muted-foreground"
/>
<ComboboxInput
v-model="searchQuery"
:placeholder="searchPlaceholder ?? t('g.search')"
class="w-full border-none bg-transparent text-sm outline-none"
/>
</div>
<div
v-if="showSelectedCount || showClearButton"
class="mt-2 flex items-center justify-between"
>
<span
v-if="showSelectedCount"
class="px-1 text-sm text-base-foreground"
>
{{ $t('g.itemsSelected', { count: selectedCount }) }}
</span>
<Button
v-if="showClearButton"
variant="textonly"
size="md"
@click.stop="selectedItems = []"
>
{{ $t('g.clearAll') }}
</Button>
</div>
<div class="my-4 h-px bg-border-default" />
</div>
<ComboboxViewport
class="flex size-4 shrink-0 items-center justify-center rounded-sm p-0.5 transition-all duration-200"
:class="
cn(
'flex flex-col gap-0 p-0 text-sm',
'scrollbar-custom overflow-y-auto',
'min-w-(--reka-combobox-trigger-width)'
)
slotProps.selected
? 'bg-primary-background'
: 'bg-secondary-background'
"
:style="{ maxHeight: `min(${listMaxHeight}, 50vh)` }"
>
<ComboboxItem
v-for="opt in filteredOptions"
:key="opt.value"
:value="opt"
:class="
cn(
'group flex h-10 shrink-0 cursor-pointer items-center gap-2 rounded-lg px-2 outline-none',
'hover:bg-secondary-background-hover',
'data-highlighted:bg-secondary-background-selected data-highlighted:hover:bg-secondary-background-selected'
)
"
>
<div
class="flex size-4 shrink-0 items-center justify-center rounded-sm transition-all duration-200 group-data-[state=checked]:bg-primary-background group-data-[state=unchecked]:bg-secondary-background [&>span]:flex"
>
<ComboboxItemIndicator>
<i
class="icon-[lucide--check] text-xs font-bold text-base-foreground"
/>
</ComboboxItemIndicator>
</div>
<span>{{ opt.name }}</span>
</ComboboxItem>
<ComboboxEmpty class="px-3 pb-4 text-sm text-muted-foreground">
{{ $t('g.noResultsFound') }}
</ComboboxEmpty>
</ComboboxViewport>
</ComboboxContent>
</ComboboxPortal>
</ComboboxRoot>
<i
v-if="slotProps.selected"
class="text-bold icon-[lucide--check] text-xs text-base-foreground"
/>
</div>
<span>
{{ slotProps.option.name }}
</span>
</div>
</template>
</MultiSelect>
</template>
<script setup lang="ts">
import { useFuse } from '@vueuse/integrations/useFuse'
import type { UseFuseOptions } from '@vueuse/integrations/useFuse'
import type { FocusOutsideEvent } from 'reka-ui'
import {
ComboboxAnchor,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxItemIndicator,
ComboboxPortal,
ComboboxRoot,
ComboboxTrigger,
ComboboxViewport
} from 'reka-ui'
import { computed } from 'vue'
import type { MultiSelectPassThroughMethodOptions } from 'primevue/multiselect'
import MultiSelect from 'primevue/multiselect'
import { computed, useAttrs } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import Button from '@/components/ui/button/Button.vue'
import { usePopoverSizing } from '@/composables/usePopoverSizing'
import { cn } from '@/utils/tailwindUtil'
import type { SelectOption } from './types'
type Option = SelectOption
defineOptions({
inheritAttrs: false
})
const {
label,
options = [],
size = 'lg',
disabled = false,
showSearchBox = false,
showSelectedCount = false,
showClearButton = false,
searchPlaceholder,
listMaxHeight = '28rem',
popoverMinWidth,
popoverMaxWidth
} = defineProps<{
interface Props {
/** Input label shown on the trigger button */
label?: string
/** Available options */
options?: SelectOption[]
/** Trigger size: 'lg' (40px, Interface) or 'md' (32px, Node) */
size?: 'lg' | 'md'
/** Disable the select */
disabled?: boolean
/** Show search box in the panel header */
showSearchBox?: boolean
/** Show selected count text in the panel header */
@@ -224,9 +216,22 @@ const {
popoverMinWidth?: string
/** Maximum width of the popover (default: auto) */
popoverMaxWidth?: string
}>()
// Note: options prop is intentionally omitted.
// It's passed via $attrs to maximize PrimeVue API compatibility
}
const {
label,
size = 'lg',
showSearchBox = false,
showSelectedCount = false,
showClearButton = false,
searchPlaceholder = 'Search...',
listMaxHeight = '28rem',
popoverMinWidth,
popoverMaxWidth
} = defineProps<Props>()
const selectedItems = defineModel<SelectOption[]>({
const selectedItems = defineModel<Option[]>({
required: true
})
const searchQuery = defineModel<string>('searchQuery', { default: '' })
@@ -234,16 +239,15 @@ const searchQuery = defineModel<string>('searchQuery', { default: '' })
const { t } = useI18n()
const selectedCount = computed(() => selectedItems.value.length)
function preventFocusDismiss(event: FocusOutsideEvent) {
event.preventDefault()
}
const popoverStyle = usePopoverSizing({
minWidth: popoverMinWidth,
maxWidth: popoverMaxWidth
})
const attrs = useAttrs()
const originalOptions = computed(() => (attrs.options as Option[]) || [])
const fuseOptions: UseFuseOptions<SelectOption> = {
// Use VueUse's useFuse for better reactivity and performance
const fuseOptions: UseFuseOptions<Option> = {
fuseOptions: {
keys: ['name', 'value'],
threshold: 0.3,
@@ -252,20 +256,23 @@ const fuseOptions: UseFuseOptions<SelectOption> = {
matchAllWhenSearchEmpty: true
}
const { results } = useFuse(searchQuery, () => options, fuseOptions)
const { results } = useFuse(searchQuery, originalOptions, fuseOptions)
// Filter options based on search, but always include selected items
const filteredOptions = computed(() => {
if (!searchQuery.value || searchQuery.value.trim() === '') {
return options
return originalOptions.value
}
// results.value already contains the search results from useFuse
const searchResults = results.value.map(
(result: { item: SelectOption }) => result.item
(result: { item: Option }) => result.item
)
// Include selected items that aren't in search results
const selectedButNotInResults = selectedItems.value.filter(
(item) =>
!searchResults.some((result: SelectOption) => result.value === item.value)
!searchResults.some((result: Option) => result.value === item.value)
)
return [...selectedButNotInResults, ...searchResults]

View File

@@ -1,12 +1,21 @@
<template>
<SelectRoot v-model="selectedItem" :disabled>
<SelectTrigger
v-bind="$attrs"
:aria-label="label || t('g.singleSelectDropdown')"
:aria-busy="loading || undefined"
:aria-invalid="invalid || undefined"
:class="
cn(
<!--
Note: We explicitly pass options here (not just via $attrs) because:
1. Our custom value template needs options to look up labels from values
2. PrimeVue's value slot only provides 'value' and 'placeholder', not the selected item's label
3. We need to maintain the icon slot functionality in the value template
option-label="name" is required because our option template directly accesses option.name
-->
<Select
v-model="selectedItem"
v-bind="$attrs"
:options="options"
option-label="name"
option-value="value"
unstyled
:pt="{
root: ({ props }: SelectPassThroughMethodOptions<SelectOption>) => ({
class: cn(
'relative inline-flex cursor-pointer items-center select-none',
size === 'md' ? 'h-8' : 'h-10',
'rounded-lg',
@@ -14,107 +23,121 @@
'transition-all duration-200 ease-in-out',
'hover:bg-secondary-background-hover',
'border-[2.5px] border-solid',
invalid ? 'border-destructive-background' : 'border-transparent',
'focus:border-node-component-border focus:outline-none',
'disabled:cursor-default disabled:opacity-30 disabled:hover:bg-secondary-background'
invalid
? 'border-destructive-background'
: 'border-transparent focus-within:border-node-component-border',
props.disabled &&
'cursor-default opacity-30 hover:bg-secondary-background'
)
"
>
}),
label: {
class: cn(
'flex flex-1 items-center py-2 whitespace-nowrap outline-hidden',
size === 'md' ? 'pl-3' : 'pl-4'
)
},
dropdown: {
class:
// Right chevron touch area
'flex shrink-0 items-center justify-center px-3 py-2'
},
overlay: {
class: cn(
'mt-2 rounded-lg p-2',
'bg-base-background text-base-foreground',
'border border-solid border-border-default'
)
},
listContainer: () => ({
style: `max-height: min(${listMaxHeight}, 50vh)`,
class: 'scrollbar-custom'
}),
list: {
class:
// Same list tone/size as MultiSelect
'flex flex-col gap-0 p-0 m-0 list-none border-none text-sm'
},
option: ({ context }: SelectPassThroughMethodOptions<SelectOption>) => ({
class: cn(
// Row layout
'flex items-center justify-between gap-3 rounded-sm px-2 py-3',
'hover:bg-secondary-background-hover',
// Add focus state for keyboard navigation
context.focused && 'bg-secondary-background-hover',
// Selected state + check icon
context.selected &&
'bg-secondary-background-selected hover:bg-secondary-background-selected'
)
}),
optionLabel: {
class: 'truncate'
},
optionGroupLabel: {
class: 'px-3 py-2 text-xs uppercase tracking-wide text-muted-foreground'
},
emptyMessage: {
class: 'px-3 py-2 text-sm text-muted-foreground'
}
}"
:aria-label="label || t('g.singleSelectDropdown')"
:aria-busy="loading || undefined"
:aria-invalid="invalid || undefined"
role="combobox"
:aria-expanded="false"
aria-haspopup="listbox"
:tabindex="0"
>
<!-- Trigger value -->
<template #value="slotProps">
<div
:class="
cn(
'flex flex-1 items-center gap-2 overflow-hidden py-2',
size === 'md' ? 'pl-3 text-xs' : 'pl-4 text-sm'
)
cn('flex items-center gap-2', size === 'md' ? 'text-xs' : 'text-sm')
"
>
<i
v-if="loading"
class="icon-[lucide--loader-circle] shrink-0 animate-spin text-muted-foreground"
class="icon-[lucide--loader-circle] animate-spin text-muted-foreground"
/>
<slot v-else name="icon" />
<SelectValue :placeholder="label" class="truncate" />
</div>
<div
class="flex shrink-0 cursor-pointer items-center justify-center px-3"
>
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
</div>
</SelectTrigger>
<SelectPortal>
<SelectContent
position="popper"
:side-offset="8"
align="start"
:style="optionStyle"
:class="
cn(
'z-3000 overflow-hidden',
'rounded-lg p-2',
'bg-base-background text-base-foreground',
'border border-solid border-border-default',
'shadow-md',
'min-w-(--reka-select-trigger-width)',
'data-[state=closed]:animate-out data-[state=open]:animate-in',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2'
)
"
>
<SelectViewport
:style="{ maxHeight: `min(${listMaxHeight}, 50vh)` }"
class="scrollbar-custom w-full"
<span
v-if="slotProps.value !== null && slotProps.value !== undefined"
class="text-base-foreground"
>
<SelectItem
v-for="opt in options"
:key="opt.value"
:value="opt.value"
:class="
cn(
'relative flex w-full cursor-pointer items-center justify-between select-none',
'gap-3 rounded-sm px-2 py-3 text-sm outline-none',
'hover:bg-secondary-background-hover',
'focus:bg-secondary-background-hover',
'data-[state=checked]:bg-secondary-background-selected',
'data-[state=checked]:hover:bg-secondary-background-selected'
)
"
>
<SelectItemText class="truncate">
{{ opt.name }}
</SelectItemText>
<SelectItemIndicator
class="flex shrink-0 items-center justify-center"
>
<i
class="icon-[lucide--check] text-base-foreground"
aria-hidden="true"
/>
</SelectItemIndicator>
</SelectItem>
</SelectViewport>
</SelectContent>
</SelectPortal>
</SelectRoot>
{{ getLabel(slotProps.value) }}
</span>
<span v-else class="text-base-foreground">
{{ label }}
</span>
</div>
</template>
<!-- Trigger caret (hidden when loading) -->
<template #dropdownicon>
<i
v-if="!loading"
class="icon-[lucide--chevron-down] text-muted-foreground"
/>
</template>
<!-- Option row -->
<template #option="{ option, selected }">
<div
class="flex w-full items-center justify-between gap-3"
:style="optionStyle"
>
<span class="truncate">{{ option.name }}</span>
<i v-if="selected" class="icon-[lucide--check] text-base-foreground" />
</div>
</template>
</Select>
</template>
<script setup lang="ts">
import {
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectPortal,
SelectRoot,
SelectTrigger,
SelectValue,
SelectViewport
} from 'reka-ui'
import type { SelectPassThroughMethodOptions } from 'primevue/select'
import Select from 'primevue/select'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { usePopoverSizing } from '@/composables/usePopoverSizing'
import { cn } from '@/utils/tailwindUtil'
import type { SelectOption } from './types'
@@ -129,12 +152,16 @@ const {
size = 'lg',
invalid = false,
loading = false,
disabled = false,
listMaxHeight = '28rem',
popoverMinWidth,
popoverMaxWidth
} = defineProps<{
label?: string
/**
* Required for displaying the selected item's label.
* Cannot rely on $attrs alone because we need to access options
* in getLabel() to map values to their display names.
*/
options?: SelectOption[]
/** Trigger size: 'lg' (40px, Interface) or 'md' (32px, Node) */
size?: 'lg' | 'md'
@@ -142,8 +169,6 @@ const {
invalid?: boolean
/** Show loading spinner instead of chevron */
loading?: boolean
/** Disable the select */
disabled?: boolean
/** Maximum height of the dropdown panel (default: 28rem) */
listMaxHeight?: string
/** Minimum width of the popover (default: auto) */
@@ -156,8 +181,26 @@ const selectedItem = defineModel<string | undefined>({ required: true })
const { t } = useI18n()
const optionStyle = usePopoverSizing({
minWidth: popoverMinWidth,
maxWidth: popoverMaxWidth
/**
* Maps a value to its display label.
* Necessary because PrimeVue's value slot doesn't provide the selected item's label,
* only the raw value. We need this to show the correct text when an item is selected.
*/
const getLabel = (val: string | null | undefined) => {
if (val == null) return label ?? ''
if (!options) return label ?? ''
const found = options.find((o) => o.value === val)
return found ? found.name : (label ?? '')
}
// Extract complex style logic from template
const optionStyle = computed(() => {
if (!popoverMinWidth && !popoverMaxWidth) return undefined
const styles: string[] = []
if (popoverMinWidth) styles.push(`min-width: ${popoverMinWidth}`)
if (popoverMaxWidth) styles.push(`max-width: ${popoverMaxWidth}`)
return styles.join('; ')
})
</script>

View File

@@ -9,15 +9,12 @@ 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'
@@ -41,21 +38,12 @@ 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 } = storeToRefs(executionErrorStore)
const activeMissingNodeGraphIds = computed<Set<string>>(() => {
if (!app.isGraphReady) return new Set()
return getActiveGraphNodeIds(
app.rootGraph,
canvasStore.currentGraph ?? app.rootGraph,
missingNodesErrorStore.missingAncestorExecutionIds
)
})
const { hasAnyError, allErrorExecutionIds, activeMissingNodeGraphIds } =
storeToRefs(executionErrorStore)
const { activeMissingModelGraphIds } = storeToRefs(missingModelStore)

View File

@@ -237,11 +237,6 @@ 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

@@ -90,7 +90,6 @@
variant="secondary"
size="sm"
class="h-8 w-2/3 justify-center gap-1 rounded-lg text-xs"
data-testid="error-card-find-on-github"
@click="handleCheckGithub(error)"
>
{{ t('g.findOnGithub') }}
@@ -100,7 +99,6 @@
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') }}
@@ -127,10 +125,12 @@
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,8 +154,10 @@ 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) {
@@ -176,6 +178,23 @@ function handleCopyError(idx: number) {
}
function handleCheckGithub(error: ErrorItem) {
findOnGitHub(error.message)
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')
}
</script>

View File

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

View File

@@ -209,9 +209,12 @@ describe('TabErrors.vue', () => {
}
})
const copyButton = wrapper.find('[data-testid="error-card-copy"]')
expect(copyButton.exists()).toBe(true)
await copyButton.trigger('click')
// 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')
expect(mockCopy).toHaveBeenCalledWith('Test message\n\nTest details')
})
@@ -242,9 +245,5 @@ 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,7 +53,6 @@
<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)"
@@ -210,9 +209,12 @@
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'
@@ -236,7 +238,6 @@ 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'
@@ -245,7 +246,7 @@ import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacemen
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const { focusNode, enterSubgraph } = useFocusNode()
const { openGitHubIssues, contactSupport } = useErrorActions()
const { staticUrls } = useExternalLink()
const settingStore = useSettingStore()
const rightSidePanelStore = useRightSidePanelStore()
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
@@ -371,13 +372,13 @@ watch(
if (!graphNodeId) return
const prefix = `${graphNodeId}:`
for (const group of allErrorGroups.value) {
if (group.type !== 'execution') continue
const hasMatch = group.cards.some(
(card) =>
card.graphNodeId === graphNodeId ||
(card.nodeId?.startsWith(prefix) ?? false)
)
const hasMatch =
group.type === 'execution' &&
group.cards.some(
(card) =>
card.graphNodeId === graphNodeId ||
(card.nodeId?.startsWith(prefix) ?? false)
)
setSectionCollapsed(group.title, !hasMatch)
}
rightSidePanelStore.focusedErrorNodeId = null
@@ -417,4 +418,20 @@ 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

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

View File

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

View File

@@ -5,7 +5,6 @@ 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'
@@ -196,8 +195,12 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
cardIndex: ci,
searchableNodeId: card.nodeId ?? '',
searchableNodeTitle: card.nodeTitle ?? '',
searchableMessage: card.errors.map((e) => e.message).join(' '),
searchableDetails: card.errors.map((e) => e.details ?? '').join(' ')
searchableMessage: card.errors
.map((e: ErrorItem) => e.message)
.join(' '),
searchableDetails: card.errors
.map((e: ErrorItem) => e.details ?? '')
.join(' ')
})
}
}
@@ -237,7 +240,6 @@ export function useErrorGroups(
t: (key: string) => string
) {
const executionErrorStore = useExecutionErrorStore()
const missingNodesStore = useMissingNodesErrorStore()
const missingModelStore = useMissingModelStore()
const canvasStore = useCanvasStore()
const { inferPackFromNodeName } = useComfyRegistryStore()
@@ -283,7 +285,7 @@ export function useErrorGroups(
const missingNodeCache = computed(() => {
const map = new Map<string, LGraphNode>()
const nodeTypes = missingNodesStore.missingNodesError?.nodeTypes ?? []
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
for (const nodeType of nodeTypes) {
if (typeof nodeType === 'string') continue
if (nodeType.nodeId == null) continue
@@ -405,7 +407,7 @@ export function useErrorGroups(
const asyncResolvedIds = ref<Map<string, string | null>>(new Map())
const pendingTypes = computed(() =>
(missingNodesStore.missingNodesError?.nodeTypes ?? []).filter(
(executionErrorStore.missingNodesError?.nodeTypes ?? []).filter(
(n): n is Exclude<MissingNodeType, string> =>
typeof n !== 'string' && !n.cnrId
)
@@ -446,8 +448,6 @@ 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,18 +459,8 @@ 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 = missingNodesStore.missingNodesError?.nodeTypes ?? []
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
const map = new Map<
string | null,
{ nodeTypes: MissingNodeType[]; isResolving: boolean }
@@ -532,7 +522,7 @@ export function useErrorGroups(
})
const swapNodeGroups = computed<SwapNodeGroup[]>(() => {
const nodeTypes = missingNodesStore.missingNodesError?.nodeTypes ?? []
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
const map = new Map<string, SwapNodeGroup>()
for (const nodeType of nodeTypes) {
@@ -556,7 +546,7 @@ export function useErrorGroups(
/** Builds an ErrorGroup from missingNodesError. Returns [] when none present. */
function buildMissingNodeGroups(): ErrorGroup[] {
const error = missingNodesStore.missingNodesError
const error = executionErrorStore.missingNodesError
if (!error) return []
const groups: ErrorGroup[] = []

View File

@@ -2,8 +2,6 @@ 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'
@@ -42,33 +40,24 @@ export function useErrorReport(cardSource: MaybeRefOrGetter<ErrorCardData>) {
if (runtimeErrors.length === 0) return
if (!systemStatsStore.systemStats) {
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
}
try {
await systemStatsStore.refetchSystemStats()
} catch {
return
}
}
if (!systemStatsStore.systemStats || cancelled) return
if (cancelled || !systemStatsStore.systemStats) return
let logs: string
try {
logs = await api.getLogs()
} catch {
logs = 'Failed to retrieve server logs'
}
const logs = await api
.getLogs()
.catch(() => 'Failed to retrieve server logs')
if (cancelled) return
const workflow = (() => {
try {
return app.rootGraph.serialize()
} catch (e) {
console.warn('Failed to serialize workflow for error report:', e)
return null
}
})()
if (!workflow) return
const workflow = app.rootGraph.serialize()
for (const { error, idx } of runtimeErrors) {
try {
@@ -83,8 +72,8 @@ export function useErrorReport(cardSource: MaybeRefOrGetter<ErrorCardData>) {
workflow
})
enrichedDetails[idx] = report
} catch (e) {
console.warn('Failed to generate error report:', e)
} catch {
// Fallback: keep original error.details
}
}
})

View File

@@ -315,45 +315,6 @@ 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,22 +35,10 @@ 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) {
@@ -94,15 +82,6 @@ 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?.()) {
@@ -112,15 +91,6 @@ 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)
@@ -132,17 +102,7 @@ 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

@@ -1,111 +0,0 @@
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

@@ -1,65 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
exceedsClickThreshold,
useClickDragGuard
} from '@/composables/useClickDragGuard'
describe('exceedsClickThreshold', () => {
it('returns false when distance is within threshold', () => {
expect(exceedsClickThreshold({ x: 0, y: 0 }, { x: 2, y: 2 }, 5)).toBe(false)
})
it('returns true when distance exceeds threshold', () => {
expect(exceedsClickThreshold({ x: 0, y: 0 }, { x: 3, y: 5 }, 5)).toBe(true)
})
it('returns false when distance exactly equals threshold', () => {
expect(exceedsClickThreshold({ x: 0, y: 0 }, { x: 3, y: 4 }, 5)).toBe(false)
})
it('handles negative deltas', () => {
expect(exceedsClickThreshold({ x: 10, y: 10 }, { x: 4, y: 2 }, 5)).toBe(
true
)
})
})
describe('useClickDragGuard', () => {
it('reports no drag when pointer has not moved', () => {
const guard = useClickDragGuard(5)
guard.recordStart({ clientX: 100, clientY: 200 })
expect(guard.wasDragged({ clientX: 100, clientY: 200 })).toBe(false)
})
it('reports no drag when movement is within threshold', () => {
const guard = useClickDragGuard(5)
guard.recordStart({ clientX: 100, clientY: 200 })
expect(guard.wasDragged({ clientX: 103, clientY: 204 })).toBe(false)
})
it('reports drag when movement exceeds threshold', () => {
const guard = useClickDragGuard(5)
guard.recordStart({ clientX: 100, clientY: 200 })
expect(guard.wasDragged({ clientX: 106, clientY: 200 })).toBe(true)
})
it('returns false when no start has been recorded', () => {
const guard = useClickDragGuard(5)
expect(guard.wasDragged({ clientX: 100, clientY: 200 })).toBe(false)
})
it('returns false after reset', () => {
const guard = useClickDragGuard(5)
guard.recordStart({ clientX: 100, clientY: 200 })
guard.reset()
expect(guard.wasDragged({ clientX: 200, clientY: 300 })).toBe(false)
})
it('respects custom threshold', () => {
const guard = useClickDragGuard(3)
guard.recordStart({ clientX: 0, clientY: 0 })
expect(guard.wasDragged({ clientX: 3, clientY: 0 })).toBe(false)
expect(guard.wasDragged({ clientX: 4, clientY: 0 })).toBe(true)
})
})

View File

@@ -1,41 +0,0 @@
interface PointerPosition {
readonly x: number
readonly y: number
}
function squaredDistance(a: PointerPosition, b: PointerPosition): number {
const dx = a.x - b.x
const dy = a.y - b.y
return dx * dx + dy * dy
}
export function exceedsClickThreshold(
start: PointerPosition,
end: PointerPosition,
threshold: number
): boolean {
return squaredDistance(start, end) > threshold * threshold
}
export function useClickDragGuard(threshold: number = 5) {
let start: PointerPosition | null = null
function recordStart(e: { clientX: number; clientY: number }) {
start = { x: e.clientX, y: e.clientY }
}
function wasDragged(e: { clientX: number; clientY: number }): boolean {
if (!start) return false
return exceedsClickThreshold(
start,
{ x: e.clientX, y: e.clientY },
threshold
)
}
function reset() {
start = null
}
return { recordStart, wasDragged, reset }
}

View File

@@ -107,27 +107,6 @@ export const ESSENTIALS_CATEGORY_CANONICAL: ReadonlyMap<
EssentialsCategory
> = new Map(ESSENTIALS_CATEGORIES.map((c) => [c.toLowerCase(), c]))
/**
* Precomputed rank map: category → display order index.
* Used for sorting essentials folders in their canonical order.
*/
export const ESSENTIALS_CATEGORY_RANK: ReadonlyMap<string, number> = new Map(
ESSENTIALS_CATEGORIES.map((c, i) => [c, i])
)
/**
* Precomputed rank maps: category → (node name → display order index).
* Used for sorting nodes within each essentials folder.
*/
export const ESSENTIALS_NODE_RANK: Partial<
Record<EssentialsCategory, ReadonlyMap<string, number>>
> = Object.fromEntries(
Object.entries(ESSENTIALS_NODES).map(([category, nodes]) => [
category,
new Map(nodes.map((name, i) => [name, i]))
])
)
/**
* "Novel" toolkit nodes for telemetry — basics excluded.
* Derived from ESSENTIALS_NODES minus the 'basics' category.

View File

@@ -1,7 +1,5 @@
import * as THREE from 'three'
import { exceedsClickThreshold } from '@/composables/useClickDragGuard'
import { AnimationManager } from './AnimationManager'
import { CameraManager } from './CameraManager'
import { ControlsManager } from './ControlsManager'
@@ -70,7 +68,9 @@ class Load3d {
targetAspectRatio: number = 1
isViewerMode: boolean = false
private rightMouseStart: { x: number; y: number } = { x: 0, y: 0 }
// Context menu tracking
private rightMouseDownX: number = 0
private rightMouseDownY: number = 0
private rightMouseMoved: boolean = false
private readonly dragThreshold: number = 5
private contextMenuAbortController: AbortController | null = null
@@ -197,20 +197,18 @@ class Load3d {
const mousedownHandler = (e: MouseEvent) => {
if (e.button === 2) {
this.rightMouseStart = { x: e.clientX, y: e.clientY }
this.rightMouseDownX = e.clientX
this.rightMouseDownY = e.clientY
this.rightMouseMoved = false
}
}
const mousemoveHandler = (e: MouseEvent) => {
if (e.buttons === 2) {
if (
exceedsClickThreshold(
this.rightMouseStart,
{ x: e.clientX, y: e.clientY },
this.dragThreshold
)
) {
const dx = Math.abs(e.clientX - this.rightMouseDownX)
const dy = Math.abs(e.clientY - this.rightMouseDownY)
if (dx > this.dragThreshold || dy > this.dragThreshold) {
this.rightMouseMoved = true
}
}
@@ -219,13 +217,12 @@ class Load3d {
const contextmenuHandler = (e: MouseEvent) => {
if (this.isViewerMode) return
const dx = Math.abs(e.clientX - this.rightMouseDownX)
const dy = Math.abs(e.clientY - this.rightMouseDownY)
const wasDragging =
this.rightMouseMoved ||
exceedsClickThreshold(
this.rightMouseStart,
{ x: e.clientX, y: e.clientY },
this.dragThreshold
)
dx > this.dragThreshold ||
dy > this.dragThreshold
this.rightMouseMoved = false

View File

@@ -278,7 +278,8 @@
"clearAll": "Clear all",
"copyURL": "Copy URL",
"releaseTitle": "{package} {version} Release",
"itemsSelected": "No items selected | {count} item selected | {count} items selected",
"itemSelected": "{selectedCount} item selected",
"itemsSelected": "{selectedCount} items selected",
"multiSelectDropdown": "Multi-select dropdown",
"singleSelectDropdown": "Single-select dropdown",
"progressCountOf": "of",
@@ -1888,7 +1889,9 @@
"extensionFileHint": "This may be due to the following script",
"promptExecutionError": "Prompt execution failed",
"accessRestrictedTitle": "Access Restricted",
"accessRestrictedMessage": "Your account is not authorized for this feature."
"accessRestrictedMessage": "Your account is not authorized for this feature.",
"emptyFileInputTitle": "Missing File Inputs",
"emptyFileInputMessage": "The following nodes require a file to be selected: {nodeList}. Please upload or select files before running."
},
"apiNodesSignInDialog": {
"title": "Sign In Required to Use API Nodes",

View File

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

View File

@@ -1,215 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MissingNodeType } from '@/types/comfy'
vi.mock('@/i18n', () => ({
st: vi.fn((_key: string, fallback: string) => fallback)
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
const mockShowErrorsTab = vi.hoisted(() => ({ value: false }))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn(() => mockShowErrorsTab.value)
}))
}))
import { useMissingNodesErrorStore } from './missingNodesErrorStore'
describe('missingNodesErrorStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
describe('setMissingNodeTypes', () => {
it('sets missingNodesError with provided types', () => {
const store = useMissingNodesErrorStore()
const types: MissingNodeType[] = [
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
]
store.setMissingNodeTypes(types)
expect(store.missingNodesError).not.toBeNull()
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
expect(store.hasMissingNodes).toBe(true)
})
it('clears missingNodesError when given empty array', () => {
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
])
expect(store.missingNodesError).not.toBeNull()
store.setMissingNodeTypes([])
expect(store.missingNodesError).toBeNull()
expect(store.hasMissingNodes).toBe(false)
})
it('deduplicates string entries by value', () => {
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
'NodeA',
'NodeA',
'NodeB'
] as MissingNodeType[])
expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
})
it('deduplicates object entries by nodeId when present', () => {
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeA', nodeId: '2', isReplaceable: false }
])
expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
})
it('deduplicates object entries by type when nodeId is absent', () => {
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', isReplaceable: false },
{ type: 'NodeA', isReplaceable: true }
] as MissingNodeType[])
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
})
it('keeps distinct nodeIds even when type is the same', () => {
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeA', nodeId: '2', isReplaceable: false },
{ type: 'NodeA', nodeId: '3', isReplaceable: false }
])
expect(store.missingNodesError?.nodeTypes).toHaveLength(3)
})
})
describe('surfaceMissingNodes', () => {
beforeEach(() => {
mockShowErrorsTab.value = false
})
it('stores missing node types and returns false when setting disabled', () => {
const store = useMissingNodesErrorStore()
const types: MissingNodeType[] = [
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
]
const shouldShowOverlay = store.surfaceMissingNodes(types)
expect(store.missingNodesError).not.toBeNull()
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
expect(store.hasMissingNodes).toBe(true)
expect(shouldShowOverlay).toBe(false)
})
it('returns true when ShowErrorsTab setting is enabled', () => {
mockShowErrorsTab.value = true
const store = useMissingNodesErrorStore()
const shouldShowOverlay = store.surfaceMissingNodes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
])
expect(shouldShowOverlay).toBe(true)
})
it('returns false when ShowErrorsTab setting is disabled', () => {
mockShowErrorsTab.value = false
const store = useMissingNodesErrorStore()
const shouldShowOverlay = store.surfaceMissingNodes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
])
expect(shouldShowOverlay).toBe(false)
})
it('returns false for empty types even when setting is enabled', () => {
mockShowErrorsTab.value = true
const store = useMissingNodesErrorStore()
const shouldShowOverlay = store.surfaceMissingNodes([])
expect(shouldShowOverlay).toBe(false)
})
it('deduplicates node types', () => {
const store = useMissingNodesErrorStore()
store.surfaceMissingNodes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeB', nodeId: '2', isReplaceable: false }
])
expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
})
})
describe('removeMissingNodesByType', () => {
it('removes matching types from the missing nodes list', () => {
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeB', nodeId: '2', isReplaceable: false },
{ type: 'NodeC', nodeId: '3', isReplaceable: false }
])
store.removeMissingNodesByType(['NodeA', 'NodeC'])
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
const remaining = store.missingNodesError?.nodeTypes[0]
expect(typeof remaining !== 'string' && remaining?.type).toBe('NodeB')
})
it('clears missingNodesError when all types are removed', () => {
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
])
store.removeMissingNodesByType(['NodeA'])
expect(store.missingNodesError).toBeNull()
expect(store.hasMissingNodes).toBe(false)
})
it('does nothing when missingNodesError is null', () => {
const store = useMissingNodesErrorStore()
expect(store.missingNodesError).toBeNull()
store.removeMissingNodesByType(['NodeA'])
expect(store.missingNodesError).toBeNull()
})
it('does nothing when removing non-existent types', () => {
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
])
store.removeMissingNodesByType(['NonExistent'])
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
})
it('handles removing from string entries', () => {
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
'StringNodeA',
'StringNodeB'
] as MissingNodeType[])
store.removeMissingNodesByType(['StringNodeA'])
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
})
})
})

View File

@@ -1,125 +0,0 @@
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. Returns true if the Errors tab is enabled and types were set. */
function surfaceMissingNodes(types: MissingNodeType[]): boolean {
setMissingNodeTypes(types)
return (
types.length > 0 &&
useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')
)
}
/** 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('@/platform/nodeReplacement/missingNodesErrorStore', () => ({
useMissingNodesErrorStore: vi.fn(() => ({
vi.mock('@/stores/executionErrorStore', () => ({
useExecutionErrorStore: 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 { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
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 missing nodes error store.
* replaced types from the execution error store.
*/
function replaceGroup(group: ReplacementGroup): void {
const replaced = replaceNodesInPlace(group.nodeTypes)
if (replaced.length > 0) {
useMissingNodesErrorStore().removeMissingNodesByType(replaced)
useExecutionErrorStore().removeMissingNodesByType(replaced)
}
}
/**
* Replaces every available node across all swap groups and removes
* the succeeded types from the missing nodes error store.
* the succeeded types from the execution error store.
*/
function replaceAllGroups(groups: ReplacementGroup[]): void {
const allNodeTypes = groups.flatMap((g) => g.nodeTypes)
const replaced = replaceNodesInPlace(allNodeTypes)
if (replaced.length > 0) {
useMissingNodesErrorStore().removeMissingNodesByType(replaced)
useExecutionErrorStore().removeMissingNodesByType(replaced)
}
}

View File

@@ -1,5 +1,5 @@
<template>
<BaseModalLayout content-title="" data-testid="settings-dialog" size="sm">
<BaseModalLayout content-title="" data-testid="settings-dialog" size="md">
<template #leftPanelHeaderTitle>
<i class="icon-[lucide--settings]" />
<h2 class="text-neutral text-base">{{ $t('g.settings') }}</h2>
@@ -12,7 +12,6 @@
size="md"
:placeholder="$t('g.searchSettings') + '...'"
:debounce-time="128"
autofocus
@search="handleSearch"
/>
</div>

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 { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
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(
useMissingNodesErrorStore().surfaceMissingNodes
useExecutionErrorStore().surfaceMissingNodes
).not.toHaveBeenCalled()
})
@@ -170,9 +170,9 @@ describe('useWorkflowService', () => {
useWorkflowService().showPendingWarnings(workflow)
expect(
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenCalledWith(missingNodeTypes)
expect(useExecutionErrorStore().surfaceMissingNodes).toHaveBeenCalledWith(
missingNodeTypes
)
expect(workflow.pendingWarnings).toBeNull()
})
@@ -185,9 +185,9 @@ describe('useWorkflowService', () => {
useWorkflowService().showPendingWarnings(workflow)
expect(
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenCalledWith(['CustomNode1'])
expect(useExecutionErrorStore().surfaceMissingNodes).toHaveBeenCalledWith(
['CustomNode1']
)
expect(workflow.pendingWarnings).toBeNull()
})
@@ -201,7 +201,7 @@ describe('useWorkflowService', () => {
service.showPendingWarnings(workflow)
expect(
useMissingNodesErrorStore().surfaceMissingNodes
useExecutionErrorStore().surfaceMissingNodes
).toHaveBeenCalledTimes(1)
})
})
@@ -226,7 +226,7 @@ describe('useWorkflowService', () => {
)
expect(
useMissingNodesErrorStore().surfaceMissingNodes
useExecutionErrorStore().surfaceMissingNodes
).not.toHaveBeenCalled()
await useWorkflowService().openWorkflow(workflow)
@@ -238,9 +238,9 @@ describe('useWorkflowService', () => {
workflow,
expect.objectContaining({ deferWarnings: true })
)
expect(
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenCalledWith(['CustomNode1'])
expect(useExecutionErrorStore().surfaceMissingNodes).toHaveBeenCalledWith(
['CustomNode1']
)
expect(workflow.pendingWarnings).toBeNull()
})
@@ -258,20 +258,20 @@ describe('useWorkflowService', () => {
await service.openWorkflow(workflow1)
expect(
useMissingNodesErrorStore().surfaceMissingNodes
useExecutionErrorStore().surfaceMissingNodes
).toHaveBeenCalledTimes(1)
expect(
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenCalledWith(['MissingNodeA'])
expect(useExecutionErrorStore().surfaceMissingNodes).toHaveBeenCalledWith(
['MissingNodeA']
)
expect(workflow1.pendingWarnings).toBeNull()
expect(workflow2.pendingWarnings).not.toBeNull()
await service.openWorkflow(workflow2)
expect(
useMissingNodesErrorStore().surfaceMissingNodes
useExecutionErrorStore().surfaceMissingNodes
).toHaveBeenCalledTimes(2)
expect(
useMissingNodesErrorStore().surfaceMissingNodes
useExecutionErrorStore().surfaceMissingNodes
).toHaveBeenLastCalledWith(['MissingNodeB'])
expect(workflow2.pendingWarnings).toBeNull()
})
@@ -286,12 +286,12 @@ describe('useWorkflowService', () => {
await service.openWorkflow(workflow, { force: true })
expect(
useMissingNodesErrorStore().surfaceMissingNodes
useExecutionErrorStore().surfaceMissingNodes
).toHaveBeenCalledTimes(1)
await service.openWorkflow(workflow, { force: true })
expect(
useMissingNodesErrorStore().surfaceMissingNodes
useExecutionErrorStore().surfaceMissingNodes
).toHaveBeenCalledTimes(1)
})
})

View File

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

View File

@@ -250,125 +250,61 @@ function readSessionPointer<T extends { workspaceId: string }>(
/**
* Reads the active path pointer from sessionStorage.
* Falls back to workspace-based search when clientId changes after reload,
* then to localStorage when sessionStorage is empty (browser restart).
* Falls back to workspace-based search when clientId changes after reload.
*/
export function readActivePath(
clientId: string,
targetWorkspaceId?: string
): ActivePathPointer | null {
return (
readSessionPointer<ActivePathPointer>(
StorageKeys.activePath(clientId),
StorageKeys.prefixes.activePath,
targetWorkspaceId
) ??
(targetWorkspaceId
? readLocalPointer<ActivePathPointer>(
StorageKeys.lastActivePath(targetWorkspaceId),
isValidActivePathPointer
)
: null)
return readSessionPointer<ActivePathPointer>(
StorageKeys.activePath(clientId),
StorageKeys.prefixes.activePath,
targetWorkspaceId
)
}
/**
* Writes the active path pointer to both sessionStorage (tab-scoped)
* and localStorage (survives browser restart).
* Writes the active path pointer to sessionStorage.
*/
export function writeActivePath(
clientId: string,
pointer: ActivePathPointer
): void {
const json = JSON.stringify(pointer)
writeStorage(sessionStorage, StorageKeys.activePath(clientId), json)
writeStorage(
localStorage,
StorageKeys.lastActivePath(pointer.workspaceId),
json
)
try {
const key = StorageKeys.activePath(clientId)
sessionStorage.setItem(key, JSON.stringify(pointer))
} catch {
// Best effort - ignore errors
}
}
/**
* Reads the open paths pointer from sessionStorage.
* Falls back to workspace-based search when clientId changes after reload,
* then to localStorage when sessionStorage is empty (browser restart).
* Falls back to workspace-based search when clientId changes after reload.
*/
export function readOpenPaths(
clientId: string,
targetWorkspaceId?: string
): OpenPathsPointer | null {
return (
readSessionPointer<OpenPathsPointer>(
StorageKeys.openPaths(clientId),
StorageKeys.prefixes.openPaths,
targetWorkspaceId
) ??
(targetWorkspaceId
? readLocalPointer<OpenPathsPointer>(
StorageKeys.lastOpenPaths(targetWorkspaceId),
isValidOpenPathsPointer
)
: null)
return readSessionPointer<OpenPathsPointer>(
StorageKeys.openPaths(clientId),
StorageKeys.prefixes.openPaths,
targetWorkspaceId
)
}
/**
* Writes the open paths pointer to both sessionStorage (tab-scoped)
* and localStorage (survives browser restart).
* Writes the open paths pointer to sessionStorage.
*/
export function writeOpenPaths(
clientId: string,
pointer: OpenPathsPointer
): void {
const json = JSON.stringify(pointer)
writeStorage(sessionStorage, StorageKeys.openPaths(clientId), json)
writeStorage(
localStorage,
StorageKeys.lastOpenPaths(pointer.workspaceId),
json
)
}
function hasWorkspaceId(obj: Record<string, unknown>): boolean {
return typeof obj.workspaceId === 'string'
}
function isValidActivePathPointer(value: unknown): value is ActivePathPointer {
if (typeof value !== 'object' || value === null) return false
const obj = value as Record<string, unknown>
return hasWorkspaceId(obj) && typeof obj.path === 'string'
}
function isValidOpenPathsPointer(value: unknown): value is OpenPathsPointer {
if (typeof value !== 'object' || value === null) return false
const obj = value as Record<string, unknown>
return (
hasWorkspaceId(obj) &&
Array.isArray(obj.paths) &&
typeof obj.activeIndex === 'number'
)
}
function readLocalPointer<T>(
key: string,
validate: (value: unknown) => value is T
): T | null {
try {
const json = localStorage.getItem(key)
if (!json) return null
const parsed = JSON.parse(json)
return validate(parsed) ? parsed : null
const key = StorageKeys.openPaths(clientId)
sessionStorage.setItem(key, JSON.stringify(pointer))
} catch {
return null
}
}
function writeStorage(storage: Storage, key: string, value: string): void {
try {
storage.setItem(key, value)
} catch {
// Best effort — silently degrade when storage is full or unavailable
// Best effort - ignore errors
}
}
@@ -381,9 +317,7 @@ export function clearAllV2Storage(): void {
const prefixes = [
StorageKeys.prefixes.draftIndex,
StorageKeys.prefixes.draftPayload,
StorageKeys.prefixes.lastActivePath,
StorageKeys.prefixes.lastOpenPaths
StorageKeys.prefixes.draftPayload
]
try {

View File

@@ -72,19 +72,6 @@ export const StorageKeys = {
return `Comfy.Workflow.OpenPaths:${clientId}`
},
/**
* localStorage copies of tab pointers for cross-session restore.
* sessionStorage is per-tab (correct for in-session use) but lost
* on browser restart; these keys preserve the last-written state.
*/
lastActivePath(workspaceId: string): string {
return `Comfy.Workflow.LastActivePath:${workspaceId}`
},
lastOpenPaths(workspaceId: string): string {
return `Comfy.Workflow.LastOpenPaths:${workspaceId}`
},
/**
* Prefix patterns for cleanup operations.
*/
@@ -92,8 +79,6 @@ export const StorageKeys = {
draftIndex: 'Comfy.Workflow.DraftIndex.v2:',
draftPayload: 'Comfy.Workflow.Draft.v2:',
activePath: 'Comfy.Workflow.ActivePath:',
openPaths: 'Comfy.Workflow.OpenPaths:',
lastActivePath: 'Comfy.Workflow.LastActivePath:',
lastOpenPaths: 'Comfy.Workflow.LastOpenPaths:'
openPaths: 'Comfy.Workflow.OpenPaths:'
}
} as const

View File

@@ -4,7 +4,6 @@ import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ImageLightbox from '@/components/common/ImageLightbox.vue'
import { useClickDragGuard } from '@/composables/useClickDragGuard'
import { cn } from '@/utils/tailwindUtil'
defineOptions({ inheritAttrs: false })
@@ -31,17 +30,26 @@ const {
const dropZoneRef = ref<HTMLElement | null>(null)
const canAcceptDrop = ref(false)
const clickGuard = useClickDragGuard(5)
const pointerStart = ref<{ x: number; y: number } | null>(null)
const lightboxOpen = ref(false)
function onPointerDown(e: PointerEvent) {
clickGuard.recordStart(e)
pointerStart.value = { x: e.clientX, y: e.clientY }
}
function onIndicatorClick(e: MouseEvent) {
const dragged = e.detail !== 0 && clickGuard.wasDragged(e)
clickGuard.reset()
if (dragged) return
if (e.detail !== 0) {
const start = pointerStart.value
if (start) {
const dx = e.clientX - start.x
const dy = e.clientY - start.y
if (dx * dx + dy * dy > 25) {
pointerStart.value = null
return
}
}
}
pointerStart.value = null
dropIndicator?.onClick?.(e)
}

View File

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

View File

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

View File

@@ -304,7 +304,6 @@ 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'
@@ -356,7 +355,6 @@ 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
)
@@ -370,7 +368,7 @@ const hasAnyError = computed((): boolean => {
missingModelStore.hasMissingModelOnNode(nodeLocatorId.value) ||
(lgraphNode.value &&
(executionErrorStore.isContainerWithInternalError(lgraphNode.value) ||
missingNodesErrorStore.isContainerWithMissingNode(lgraphNode.value) ||
executionErrorStore.isContainerWithMissingNode(lgraphNode.value) ||
missingModelStore.isContainerWithMissingModel(lgraphNode.value)))
)
})

View File

@@ -28,7 +28,7 @@
'-z-10 bg-node-component-header-surface'
)
"
:style="headerColorStyle"
:style="{ backgroundColor: headerColor }"
@click.stop="$emit('enterSubgraph')"
>
<div class="ml-auto flex h-full w-1/2 items-center justify-center gap-2">
@@ -73,7 +73,6 @@
'-z-10 bg-node-component-header-surface'
)
"
:style="headerColorStyle"
@click.stop="$emit('toggleAdvanced')"
>
<div class="ml-auto flex h-full w-1/2 items-center justify-center gap-2">
@@ -125,7 +124,7 @@
'-z-10 bg-node-component-header-surface'
)
"
:style="headerColorStyle"
:style="{ backgroundColor: headerColor }"
@click.stop="$emit('enterSubgraph')"
>
<div class="flex size-full items-center justify-center gap-2">
@@ -146,7 +145,6 @@
'-z-10 bg-node-component-header-surface'
)
"
:style="headerColorStyle"
@click.stop="$emit('toggleAdvanced')"
>
<div class="flex size-full items-center justify-center gap-2">
@@ -235,10 +233,6 @@ const getTabStyles = (isBackground = false) => {
)
}
const headerColorStyle = computed(() =>
props.headerColor ? { backgroundColor: props.headerColor } : undefined
)
// Case 1 context: Split widths
const errorTabWidth = 'w-[calc(50%+4px)]'
const enterTabFullWidth = 'w-[calc(100%+8px)]'

View File

@@ -1,8 +1,7 @@
import { onScopeDispose, toValue } from 'vue'
import { onScopeDispose, ref, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { isMiddlePointerInput } from '@/base/pointerUtils'
import { useClickDragGuard } from '@/composables/useClickDragGuard'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
@@ -29,7 +28,9 @@ export function useNodePointerInteractions(
let hasDraggingStarted = false
const dragGuard = useClickDragGuard(3)
const startPosition = ref({ x: 0, y: 0 })
const DRAG_THRESHOLD = 3 // pixels
function onPointerdown(event: PointerEvent) {
if (forwardMiddlePointerIfNeeded(event)) return
@@ -56,7 +57,7 @@ export function useNodePointerInteractions(
return
}
dragGuard.recordStart(event)
startPosition.value = { x: event.clientX, y: event.clientY }
safeDragStart(event, nodeId)
}
@@ -84,7 +85,11 @@ export function useNodePointerInteractions(
}
// Check if we should start dragging (pointer moved beyond threshold)
if (lmbDown && !layoutStore.isDraggingVueNodes.value) {
if (dragGuard.wasDragged(event)) {
const dx = event.clientX - startPosition.value.x
const dy = event.clientY - startPosition.value.y
const distance = Math.sqrt(dx * dx + dy * dy)
if (distance > DRAG_THRESHOLD) {
layoutStore.isDraggingVueNodes.value = true
handleNodeSelect(event, nodeId)
}

View File

@@ -65,7 +65,6 @@ 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'
@@ -84,7 +83,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 '@/platform/nodeReplacement/cnrIdUtil'
import { getCnrIdFromProperties } from '@/workbench/extensions/manager/utils/missingNodeErrorUtil'
import { rescanAndSurfaceMissingNodes } from '@/platform/nodeReplacement/missingNodeScan'
import {
scanAllModelCandidates,
@@ -147,6 +146,45 @@ import {
export const ANIM_PREVIEW_WIDGET = '$$comfy_animation_preview'
const FILE_INPUT_FIELDS = {
LoadImage: 'image',
LoadAudio: 'audio',
Load3D: 'model_file',
LoadVideo: 'video'
} as const
function isEmptyFileValue(value: unknown): boolean {
if (Array.isArray(value)) return false // linked input from another node
if (typeof value === 'string') return value.trim() === ''
return value == null
}
interface EmptyFileInputNode {
nodeId: string
classType: string
title: string
}
export function findEmptyFileInputNodes(
output: ComfyApiWorkflow,
nodeIds?: Set<string>
): EmptyFileInputNode[] {
const result: EmptyFileInputNode[] = []
for (const [nodeId, node] of Object.entries(output)) {
if (nodeIds && !nodeIds.has(nodeId)) continue
const field =
FILE_INPUT_FIELDS[node.class_type as keyof typeof FILE_INPUT_FIELDS]
if (field && isEmptyFileValue(node.inputs[field])) {
result.push({
nodeId,
classType: node.class_type,
title: node._meta?.title ?? node.class_type
})
}
}
return result
}
export function sanitizeNodeName(string: string) {
let entityMap = {
'&': '',
@@ -1106,9 +1144,7 @@ export class ComfyApp {
}
private showMissingNodesError(missingNodeTypes: MissingNodeType[]) {
if (useMissingNodesErrorStore().surfaceMissingNodes(missingNodeTypes)) {
useExecutionErrorStore().showErrorOverlay()
}
useExecutionErrorStore().surfaceMissingNodes(missingNodeTypes)
}
async loadGraphData(
@@ -1618,6 +1654,25 @@ export class ComfyApp {
const queuedWorkflow = useWorkspaceStore().workflow
.activeWorkflow as ComfyWorkflow
const p = await this.graphToPrompt(this.rootGraph)
const targetNodeIds = isPartialExecution
? new Set(queueNodeIds!)
: undefined
const emptyFileInputNodes = findEmptyFileInputNodes(
p.output,
targetNodeIds
)
if (emptyFileInputNodes.length) {
const nodeList = emptyFileInputNodes
.map((n) => `#${n.nodeId} ${n.title}`)
.join(', ')
useDialogService().showErrorDialog(
new Error(t('errorDialog.emptyFileInputMessage', { nodeList })),
{ title: t('errorDialog.emptyFileInputTitle') }
)
break
}
const queuedNodes = collectAllNodes(this.rootGraph)
try {
api.authToken = comfyOrgAuthToken

View File

@@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest'
import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
import { findEmptyFileInputNodes } from './app'
function makeNode(
classType: string,
inputs: Record<string, unknown>,
title?: string
) {
return {
class_type: classType,
inputs,
_meta: { title: title ?? classType }
}
}
describe('findEmptyFileInputNodes', () => {
it('detects LoadImage with empty image field', () => {
const output: ComfyApiWorkflow = {
'1': makeNode('LoadImage', { image: '' }),
'2': makeNode('KSampler', { seed: 42 })
}
expect(findEmptyFileInputNodes(output)).toEqual([
{ nodeId: '1', classType: 'LoadImage', title: 'LoadImage' }
])
})
it('detects multiple empty file input nodes', () => {
const output: ComfyApiWorkflow = {
'1': makeNode('LoadImage', { image: '' }, 'My Image'),
'2': makeNode('LoadAudio', { audio: '' }),
'3': makeNode('LoadVideo', { video: 'file.mp4' })
}
const result = findEmptyFileInputNodes(output)
expect(result).toHaveLength(2)
expect(result[0]).toEqual({
nodeId: '1',
classType: 'LoadImage',
title: 'My Image'
})
expect(result[1]).toEqual({
nodeId: '2',
classType: 'LoadAudio',
title: 'LoadAudio'
})
})
it('returns empty array when all file inputs are populated', () => {
const output: ComfyApiWorkflow = {
'1': makeNode('LoadImage', { image: 'photo.png' }),
'2': makeNode('Load3D', { model_file: 'model.glb' })
}
expect(findEmptyFileInputNodes(output)).toEqual([])
})
it('returns empty array when no file input nodes exist', () => {
const output: ComfyApiWorkflow = {
'1': makeNode('KSampler', { seed: 42 }),
'2': makeNode('CLIPTextEncode', { text: 'hello' })
}
expect(findEmptyFileInputNodes(output)).toEqual([])
})
it('detects Load3D with empty model_file', () => {
const output: ComfyApiWorkflow = {
'5': makeNode('Load3D', { model_file: '' })
}
expect(findEmptyFileInputNodes(output)).toEqual([
{ nodeId: '5', classType: 'Load3D', title: 'Load3D' }
])
})
it('detects null file input values', () => {
const output: ComfyApiWorkflow = {
'1': makeNode('LoadImage', { image: null })
}
expect(findEmptyFileInputNodes(output)).toHaveLength(1)
})
it('detects undefined file input values', () => {
const output: ComfyApiWorkflow = {
'1': makeNode('LoadImage', { image: undefined })
}
expect(findEmptyFileInputNodes(output)).toHaveLength(1)
})
it('detects whitespace-only file input values', () => {
const output: ComfyApiWorkflow = {
'1': makeNode('LoadImage', { image: ' ' })
}
expect(findEmptyFileInputNodes(output)).toHaveLength(1)
})
it('skips linked inputs (array references to other nodes)', () => {
const output: ComfyApiWorkflow = {
'1': makeNode('LoadImage', { image: ['5', 0] })
}
expect(findEmptyFileInputNodes(output)).toEqual([])
})
it('filters to only specified node IDs when provided', () => {
const output: ComfyApiWorkflow = {
'1': makeNode('LoadImage', { image: '' }),
'2': makeNode('LoadAudio', { audio: '' }),
'3': makeNode('KSampler', { seed: 42 })
}
const result = findEmptyFileInputNodes(output, new Set(['2', '3']))
expect(result).toEqual([
{ nodeId: '2', classType: 'LoadAudio', title: 'LoadAudio' }
])
})
it('detects missing file input field entirely', () => {
const output: ComfyApiWorkflow = {
'1': makeNode('LoadImage', {})
}
expect(findEmptyFileInputNodes(output)).toHaveLength(1)
})
})

View File

@@ -1,9 +1,8 @@
import { resolveBlueprintEssentialsCategory } from '@/constants/essentialsDisplayNames'
import type { EssentialsCategory } from '@/constants/essentialsNodes'
import {
ESSENTIALS_CATEGORY_CANONICAL,
ESSENTIALS_CATEGORY_RANK,
ESSENTIALS_NODE_RANK
ESSENTIALS_CATEGORIES,
ESSENTIALS_NODES
} from '@/constants/essentialsNodes'
import { t } from '@/i18n'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
@@ -20,34 +19,6 @@ import type { TreeNode } from '@/types/treeExplorerTypes'
import { sortedTree, unwrapTreeRoot } from '@/utils/treeUtil'
const DEFAULT_ICON = 'pi pi-sort'
const UNKNOWN_RANK = Number.MAX_SAFE_INTEGER
function resolveEssentialsCategory(
nodeDef: ComfyNodeDefImpl
): EssentialsCategory | undefined {
if (!nodeDef.isCoreNode) return undefined
if (nodeDef.essentials_category) {
return (
ESSENTIALS_CATEGORY_CANONICAL.get(
nodeDef.essentials_category.toLowerCase()
) ?? (nodeDef.essentials_category as EssentialsCategory)
)
}
return resolveBlueprintEssentialsCategory(nodeDef.name)
}
function sortByKnownOrder<T>(
items: T[],
getKey: (item: T) => string | undefined,
rankMap: ReadonlyMap<string, number>
): void {
items.sort(
(a, b) =>
(rankMap.get(getKey(a) ?? '') ?? UNKNOWN_RANK) -
(rankMap.get(getKey(b) ?? '') ?? UNKNOWN_RANK)
)
}
function categoryPathExtractor(nodeDef: ComfyNodeDefImpl): string[] {
const category = nodeDef.category || ''
@@ -176,39 +147,44 @@ class NodeOrganizationService {
}
private organizeEssentials(nodes: ComfyNodeDefImpl[]): NodeSection[] {
const categoryByNode = new Map<ComfyNodeDefImpl, EssentialsCategory>()
const essentialNodes = nodes.filter((node) => {
const category = resolveEssentialsCategory(node)
if (!category) return false
categoryByNode.set(node, category)
return true
})
const essentialNodes = nodes.filter(
(nodeDef) =>
!!nodeDef.essentials_category ||
!!resolveBlueprintEssentialsCategory(nodeDef.name)
)
const tree = buildNodeDefTree(essentialNodes, {
pathExtractor: (node) => [categoryByNode.get(node)!, node.name]
pathExtractor: (nodeDef) => {
const folder =
nodeDef.essentials_category ||
resolveBlueprintEssentialsCategory(nodeDef.name) ||
''
return folder ? [folder, nodeDef.name] : [nodeDef.name]
}
})
this.sortEssentialsTree(tree)
this.sortEssentialsFolders(tree)
return [{ tree }]
}
private sortEssentialsTree(tree: TreeNode): void {
private sortEssentialsFolders(tree: TreeNode): void {
if (!tree.children) return
sortByKnownOrder(
tree.children,
(node) => node.label,
ESSENTIALS_CATEGORY_RANK
)
const catLen = ESSENTIALS_CATEGORIES.length
tree.children.sort((a, b) => {
const ai = ESSENTIALS_CATEGORIES.indexOf(a.label as EssentialsCategory)
const bi = ESSENTIALS_CATEGORIES.indexOf(b.label as EssentialsCategory)
return (ai === -1 ? catLen : ai) - (bi === -1 ? catLen : bi)
})
for (const folder of tree.children) {
if (!folder.children) continue
const rankMap = ESSENTIALS_NODE_RANK[folder.label as EssentialsCategory]
if (!rankMap) continue
sortByKnownOrder(
folder.children,
(node) => node.data?.name ?? node.label,
rankMap
)
const order = ESSENTIALS_NODES[folder.label as EssentialsCategory]
if (!order) continue
const orderLen = order.length
folder.children.sort((a, b) => {
const ai = order.indexOf(a.data?.name ?? a.label ?? '')
const bi = order.indexOf(b.data?.name ?? b.label ?? '')
return (ai === -1 ? orderLen : ai) - (bi === -1 ? orderLen : bi)
})
}
}

View File

@@ -34,7 +34,193 @@ vi.mock(
)
import { useExecutionErrorStore } from './executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
describe('executionErrorStore — missing node operations', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
describe('setMissingNodeTypes', () => {
it('sets missingNodesError with provided types', () => {
const store = useExecutionErrorStore()
const types: MissingNodeType[] = [
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
]
store.setMissingNodeTypes(types)
expect(store.missingNodesError).not.toBeNull()
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
expect(store.hasMissingNodes).toBe(true)
})
it('clears missingNodesError when given empty array', () => {
const store = useExecutionErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
])
expect(store.missingNodesError).not.toBeNull()
store.setMissingNodeTypes([])
expect(store.missingNodesError).toBeNull()
expect(store.hasMissingNodes).toBe(false)
})
it('deduplicates string entries by value', () => {
const store = useExecutionErrorStore()
store.setMissingNodeTypes([
'NodeA',
'NodeA',
'NodeB'
] as MissingNodeType[])
expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
})
it('deduplicates object entries by nodeId when present', () => {
const store = useExecutionErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeA', nodeId: '2', isReplaceable: false }
])
// Same nodeId='1' deduplicated, nodeId='2' kept
expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
})
it('deduplicates object entries by type when nodeId is absent', () => {
const store = useExecutionErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', isReplaceable: false },
{ type: 'NodeA', isReplaceable: true }
] as MissingNodeType[])
// Same type, no nodeId → deduplicated
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
})
it('keeps distinct nodeIds even when type is the same', () => {
const store = useExecutionErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeA', nodeId: '2', isReplaceable: false },
{ type: 'NodeA', nodeId: '3', isReplaceable: false }
])
expect(store.missingNodesError?.nodeTypes).toHaveLength(3)
})
})
describe('surfaceMissingNodes', () => {
beforeEach(() => {
mockShowErrorsTab.value = false
})
it('stores missing node types when called', () => {
const store = useExecutionErrorStore()
const types: MissingNodeType[] = [
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
]
store.surfaceMissingNodes(types)
expect(store.missingNodesError).not.toBeNull()
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
expect(store.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 }
])
expect(store.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 }
])
expect(store.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 }
])
expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
})
})
describe('removeMissingNodesByType', () => {
it('removes matching types from the missing nodes list', () => {
const store = useExecutionErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeB', nodeId: '2', isReplaceable: false },
{ type: 'NodeC', nodeId: '3', isReplaceable: false }
])
store.removeMissingNodesByType(['NodeA', 'NodeC'])
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
const remaining = store.missingNodesError?.nodeTypes[0]
expect(typeof remaining !== 'string' && remaining?.type).toBe('NodeB')
})
it('clears missingNodesError when all types are removed', () => {
const store = useExecutionErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
])
store.removeMissingNodesByType(['NodeA'])
expect(store.missingNodesError).toBeNull()
expect(store.hasMissingNodes).toBe(false)
})
it('does nothing when missingNodesError is null', () => {
const store = useExecutionErrorStore()
expect(store.missingNodesError).toBeNull()
// Should not throw
store.removeMissingNodesByType(['NodeA'])
expect(store.missingNodesError).toBeNull()
})
it('does nothing when removing non-existent types', () => {
const store = useExecutionErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
])
store.removeMissingNodesByType(['NonExistent'])
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
})
it('handles removing from string entries', () => {
const store = useExecutionErrorStore()
store.setMissingNodeTypes([
'StringNodeA',
'StringNodeB'
] as MissingNodeType[])
store.removeMissingNodesByType(['StringNodeA'])
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
})
})
})
describe('executionErrorStore — node error operations', () => {
beforeEach(() => {
@@ -351,18 +537,16 @@ describe('executionErrorStore — node error operations', () => {
})
describe('clearAllErrors', () => {
let executionErrorStore: ReturnType<typeof useExecutionErrorStore>
let missingNodesStore: ReturnType<typeof useMissingNodesErrorStore>
let store: ReturnType<typeof useExecutionErrorStore>
beforeEach(() => {
const pinia = createPinia()
setActivePinia(pinia)
executionErrorStore = useExecutionErrorStore()
missingNodesStore = useMissingNodesErrorStore()
store = useExecutionErrorStore()
})
it('resets all error categories and closes error overlay', () => {
executionErrorStore.lastExecutionError = {
store.lastExecutionError = {
prompt_id: 'test',
timestamp: 0,
node_id: '1',
@@ -372,12 +556,8 @@ describe('clearAllErrors', () => {
exception_type: 'RuntimeError',
traceback: []
}
executionErrorStore.lastPromptError = {
type: 'execution',
message: 'fail',
details: ''
}
executionErrorStore.lastNodeErrors = {
store.lastPromptError = { type: 'execution', message: 'fail', details: '' }
store.lastNodeErrors = {
'1': {
errors: [
{
@@ -391,18 +571,19 @@ describe('clearAllErrors', () => {
class_type: 'Test'
}
}
missingNodesStore.setMissingNodeTypes([
{ type: 'MissingNode', hint: '' }
] as unknown as MissingNodeType[])
executionErrorStore.showErrorOverlay()
store.missingNodesError = {
message: 'Missing nodes',
nodeTypes: [{ type: 'MissingNode', hint: '' }]
}
store.showErrorOverlay()
executionErrorStore.clearAllErrors()
store.clearAllErrors()
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)
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)
})
})

View File

@@ -1,43 +1,122 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
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 { st } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
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 { getAncestorExecutionIds } from '@/types/nodeIdentification'
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 type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
import type { MissingNodeType } from '@/types/comfy'
import {
executionIdToNodeLocatorId,
forEachNode,
getNodeByExecutionId,
getExecutionIdByNode,
getNodeByExecutionId
getActiveGraphNodeIds
} from '@/utils/graphTraversalUtil'
import {
SIMPLE_ERROR_TYPES,
isValueStillOutOfRange
isValueStillOutOfRange,
SIMPLE_ERROR_TYPES
} from '@/utils/executionErrorUtil'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
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)
}
}
})
}
/** 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)
@@ -57,7 +136,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
lastExecutionError.value = null
lastPromptError.value = null
lastNodeErrors.value = null
missingNodesStore.setMissingNodeTypes([])
missingNodesError.value = null
isErrorOverlayOpen.value = false
}
@@ -159,6 +238,14 @@ 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)
@@ -170,6 +257,51 @@ 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
@@ -191,12 +323,14 @@ 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 ||
missingNodesStore.hasMissingNodes ||
hasMissingNodes.value ||
missingModelStore.hasMissingModels
)
@@ -227,12 +361,14 @@ 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 +
missingNodesStore.missingNodeCount +
missingNodeCount.value +
missingModelStore.missingModelCount
)
@@ -264,6 +400,37 @@ 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>>(
() => {
@@ -326,13 +493,42 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
return errorAncestorExecutionIds.value.has(execId)
}
useNodeErrorFlagSync(lastNodeErrors, missingModelStore)
/** 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' }
)
return {
// Raw state
lastNodeErrors,
lastExecutionError,
lastPromptError,
missingNodesError,
// Clearing
clearAllErrors,
@@ -347,22 +543,30 @@ 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
isContainerWithInternalError,
isContainerWithMissingNode
}
})

View File

@@ -3,7 +3,6 @@ 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
@@ -599,13 +598,13 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
})
})
describe('useMissingNodesErrorStore - setMissingNodeTypes', () => {
let store: ReturnType<typeof useMissingNodesErrorStore>
describe('useExecutionErrorStore - setMissingNodeTypes', () => {
let store: ReturnType<typeof useExecutionErrorStore>
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
store = useMissingNodesErrorStore()
store = useExecutionErrorStore()
})
it('clears missingNodesError when called with an empty array', () => {

View File

@@ -135,7 +135,6 @@ whenever(() => !isExpanded.value, resetUserScrolling)
function closeToast() {
comfyManagerStore.resetTaskState()
isRestartCompleted.value = false
isExpanded.value = false
}

View File

@@ -3,7 +3,7 @@
variant="primary"
:size
:disabled="isLoading || isInstalling"
@click.stop="installAllPacks"
@click="installAllPacks"
>
<i
v-if="hasConflict && !isInstalling && !isLoading"

View File

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