Compare commits

...

4 Commits

Author SHA1 Message Date
Glary-Bot
cc07b775a5 fix(manager): expose failure count on focusable Failed tab
The failure-count tooltip was on the non-focusable <i> indicator,
so keyboard users could not get the hint when focusing the tab.
Move the accessible name and title to the parent <a> (the actual
focusable element), and mark the icon aria-hidden since it is
purely decorative now.
2026-05-03 08:31:44 +00:00
Glary-Bot
bf022a07e8 fix(manager): clear pending failure count on resetTaskState
Address CodeRabbit review feedback:

- Clear pendingFailureCount in resetTaskState() so an in-flight
  debounced failure toast becomes a no-op when a restart/reset
  happens before the debounce fires. useDebounceFn has no cancel
  API, so we rely on the early-return guard inside the callback.
- Strengthen the corresponding test to actually exercise the race
  (reset before timers flush), removing the pre-flush that made
  the test incapable of catching the regression.
- Switch the toast mock to vi.hoisted() per repo guidelines on
  keeping module mocks contained.
2026-04-20 04:19:27 +00:00
Glary-Bot
fafcc20c7d fix(manager): drive failure toast from task history, not promise rejection
Address review feedback: the previous toast logic fired on Promise
rejection from usePackInstall, but managerStore.installPack routes
through useCachedRequest which catches errors into null, and the
real install outcome arrives asynchronously via task-completion
events. This meant failures never surfaced a toast in practice.

Move the toast to comfyManagerStore and trigger it from a watch on
failedTasksIds length increases, with a short debounce to coalesce
a burst of failures into a single toast. The watcher ignores resets
(oldCount -> 0) so resetTaskState does not trigger a false alarm.

Replace the composable-level mock tests with store-level tests that
exercise the real contract (task history -> partitionTasks ->
failedTasksIds).
2026-04-20 03:34:36 +00:00
Glary-Bot
841990ec05 fix(manager): surface installation failures with toast and tab indicator
Installations could fail silently because the only failure feedback was
buried in a non-default Failed tab inside the progress toast. Users
believed the install succeeded until they restarted and the nodes were
missing.

- Show an error toast immediately when one or more pack installations
  reject in performInstallation, so failures surface in real time.
- Add an exclamation-circle indicator on the Failed tab in
  ManagerProgressToast whenever there are failed tasks, making the tab
  discoverable even when collapsed.
- Add unit tests covering the new toast behavior.

Fixes FE-218
2026-04-20 03:23:22 +00:00
4 changed files with 178 additions and 5 deletions

View File

@@ -384,7 +384,12 @@
"legacyManagerSearchTip": "Looking for ComfyUI-Manager? You can enable the legacy manager UI by starting ComfyUI with the --enable-manager-legacy-ui flag.",
"failed": "Failed",
"failedToInstall": "Failed to Install",
"failedTabIndicatorTooltip": "{count} installation failed | {count} installations failed",
"installError": "Install Error",
"installFailureToast": {
"summary": "Installation failed",
"detail": "{count} extension failed to install. Check the Failed tab for details. | {count} extensions failed to install. Check the Failed tab for details."
},
"importFailedGenericError": "Package failed to import. Check the console for more details.",
"noNodesFound": "No nodes found",
"noNodesFoundDescription": "The pack's nodes either could not be parsed, or the pack is a frontend extension only and doesn't have any nodes.",

View File

@@ -18,11 +18,25 @@ const { isRestarting, isRestartCompleted, applyChanges } = useApplyChanges()
const isExpanded = ref(false)
const activeTabIndex = ref(0)
const FAILED_TAB_KEY = 'failed'
const failedCount = computed(() => comfyManagerStore.failedTasksIds.length)
const hasFailures = computed(() => failedCount.value > 0)
const failedTabIndicatorLabel = computed(() =>
t(
'manager.failedTabIndicatorTooltip',
{ count: failedCount.value },
failedCount.value
)
)
const tabs = computed(() => [
{ label: t('manager.installationQueue') },
{
key: FAILED_TAB_KEY,
label: t('manager.failed', {
count: comfyManagerStore.failedTasksIds.length
count: failedCount.value
})
}
])
@@ -169,7 +183,31 @@ onBeforeUnmount(() => {
menuitem: { class: 'font-medium' },
action: { class: 'px-4 py-2' }
}"
/>
>
<template #item="{ item, props, label }">
<a
v-bind="props.action"
class="flex items-center gap-2"
:aria-label="
item.key === FAILED_TAB_KEY && hasFailures
? `${label} ${failedTabIndicatorLabel}`
: undefined
"
:title="
item.key === FAILED_TAB_KEY && hasFailures
? failedTabIndicatorLabel
: undefined
"
>
<span>{{ label }}</span>
<i
v-if="item.key === FAILED_TAB_KEY && hasFailures"
class="pi pi-exclamation-circle text-danger"
aria-hidden="true"
/>
</a>
</template>
</TabMenu>
</div>
<div

View File

@@ -1,6 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
@@ -55,6 +55,23 @@ vi.mock('vue-i18n', () => ({
}))
}))
const { toastAddMock } = vi.hoisted(() => ({
toastAddMock: vi.fn()
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn(() => ({
add: toastAddMock
}))
}))
vi.mock('@/i18n', () => ({
t: (key: string, params?: Record<string, unknown>) => {
if (params && 'count' in params) return `${key}:${String(params.count)}`
return key
}
}))
interface EnabledDisabledTestCase {
desc: string
installed: Record<string, ManagerPackInstalled>
@@ -79,6 +96,8 @@ describe('useComfyManagerStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
toastAddMock.mockClear()
vi.useFakeTimers()
mockManagerService = {
isLoading: ref(false),
error: ref(null),
@@ -102,6 +121,10 @@ describe('useComfyManagerStore', () => {
vi.mocked(useComfyManagerService).mockReturnValue(mockManagerService)
})
afterEach(() => {
vi.useRealTimers()
})
const testCases: EnabledDisabledTestCase[] = [
{
desc: 'Two enabled versions',
@@ -501,4 +524,83 @@ describe('useComfyManagerStore', () => {
expect(store.isPackInstalled('disabled-pack')).toBe(true)
})
})
describe('installation failure toast', () => {
const setTaskHistory = (
store: ReturnType<typeof useComfyManagerStore>,
history: Record<string, ManagerComponents['schemas']['TaskHistoryItem']>
) => {
store.taskHistory = history
}
const errorTask = (
id: string
): ManagerComponents['schemas']['TaskHistoryItem'] => ({
ui_id: id,
client_id: 'test',
kind: 'install',
result: 'failed',
status: { status_str: 'error', completed: false, messages: ['boom'] },
timestamp: new Date().toISOString()
})
const successTask = (
id: string
): ManagerComponents['schemas']['TaskHistoryItem'] => ({
ui_id: id,
client_id: 'test',
kind: 'install',
result: 'success',
status: { status_str: 'success', completed: true, messages: [] },
timestamp: new Date().toISOString()
})
it('shows an error toast when a task fails', async () => {
const store = useComfyManagerStore()
setTaskHistory(store, { a: errorTask('a') })
await nextTick()
await vi.runAllTimersAsync()
expect(toastAddMock).toHaveBeenCalledTimes(1)
const message = toastAddMock.mock.calls[0][0]
expect(message.severity).toBe('error')
expect(message.summary).toBe('manager.installFailureToast.summary')
expect(message.detail).toBe('manager.installFailureToast.detail:1')
})
it('does not show a toast when all tasks succeed', async () => {
const store = useComfyManagerStore()
setTaskHistory(store, { a: successTask('a'), b: successTask('b') })
await nextTick()
await vi.runAllTimersAsync()
expect(toastAddMock).not.toHaveBeenCalled()
})
it('coalesces multiple failures that land in quick succession', async () => {
const store = useComfyManagerStore()
setTaskHistory(store, { a: errorTask('a') })
await nextTick()
setTaskHistory(store, { a: errorTask('a'), b: errorTask('b') })
await nextTick()
await vi.runAllTimersAsync()
expect(toastAddMock).toHaveBeenCalledTimes(1)
expect(toastAddMock.mock.calls[0][0].detail).toBe(
'manager.installFailureToast.detail:2'
)
})
it('does not show a stale toast when resetTaskState runs before the debounce fires', async () => {
const store = useComfyManagerStore()
setTaskHistory(store, { a: errorTask('a') })
await nextTick()
store.resetTaskState()
await nextTick()
await vi.runAllTimersAsync()
expect(toastAddMock).not.toHaveBeenCalled()
})
})
})

View File

@@ -1,4 +1,4 @@
import { useEventListener, whenever } from '@vueuse/core'
import { useDebounceFn, useEventListener, whenever } from '@vueuse/core'
import { defineStore } from 'pinia'
import { v4 as uuidv4 } from 'uuid'
import { ref, watch } from 'vue'
@@ -6,6 +6,7 @@ import { ref, watch } from 'vue'
import { t } from '@/i18n'
import { useCachedRequest } from '@/composables/useCachedRequest'
import { useServerLogs } from '@/composables/useServerLogs'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
@@ -112,6 +113,30 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
{ deep: true }
)
const pendingFailureCount = ref(0)
const flushFailureToast = useDebounceFn(() => {
if (pendingFailureCount.value === 0) return
const count = pendingFailureCount.value
pendingFailureCount.value = 0
useToastStore().add({
severity: 'error',
summary: t('manager.installFailureToast.summary'),
detail: t('manager.installFailureToast.detail', { count }, count),
life: 8000
})
}, 300)
watch(
() => failedTasksIds.value.length,
(newCount, oldCount) => {
const delta = newCount - (oldCount ?? 0)
if (delta <= 0) return
pendingFailureCount.value += delta
void flushFailureToast()
}
)
const getPackId = (pack: ManagerPackInstalled) => pack.cnr_id || pack.aux_id
const isInstalledPackId = (packName: string | undefined): boolean =>
@@ -336,7 +361,10 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
}
const resetTaskState = () => {
// Clear all task-related reactive state for fresh start after restart
// Clear all task-related reactive state for fresh start after restart.
// Also clear pendingFailureCount so any in-flight debounced failure
// toast (which reads this value before firing) becomes a no-op.
pendingFailureCount.value = 0
taskLogs.value = []
taskHistory.value = {}
succeededTasksIds.value = []