Compare commits

...

6 Commits

Author SHA1 Message Date
bymyself
16d324a80f test: add ManagerProgressToast failure indicator coverage
Add tests for the failure indicator feature in ManagerProgressToast:
- Verifies no indicator when there are no failures
- Verifies aria-label with failure count when failures exist
- Tests tooltip shows correct failure count

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-11 14:12:48 -07:00
bymyself
c8e103f3c6 fix(manager): filter failure toast to install tasks only, prevent replay
- Track already-notified failed install IDs to prevent duplicate toasts
- Only fire toast for tasks where kind === 'install' (not update/uninstall/etc.)
- Add tests for non-install failures and replay prevention

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11462#discussion_r3186527643
2026-05-11 13:43:17 -07:00
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
5 changed files with 420 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

@@ -0,0 +1,152 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, onMounted } from 'vue'
import type { UseScrollReturn } from '@vueuse/core'
const mockFailedTasksIds = vi.hoisted(() => ({ value: [] as string[] }))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: vi.fn(
(key: string, params?: Record<string, unknown>, count?: number) => {
if (count !== undefined) return `${key}:${count}`
if (params && 'count' in params) return `${key}:${params.count}`
return key
}
)
}),
createI18n: vi.fn(() => ({
global: { t: vi.fn((key: string) => key) }
}))
}))
vi.mock('@vueuse/core', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as object),
useScroll: () => ({ y: { value: 0 } }) as UseScrollReturn,
whenever: vi.fn()
}
})
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: () => ({
taskLogs: [{ taskId: '1', taskName: 'Test', logs: ['log'] }],
get failedTasksIds() {
return mockFailedTasksIds.value
},
succeededTasksIds: [],
succeededTasksLogs: [],
failedTasksLogs: [],
taskHistory: {},
taskQueue: {
history: {},
running_queue: [],
pending_queue: [],
installed_packs: {}
},
isProcessingTasks: false,
resetTaskState: vi.fn()
})
}))
vi.mock('@/workbench/extensions/manager/composables/useApplyChanges', () => ({
useApplyChanges: () => ({
isRestarting: { value: false },
isRestartCompleted: { value: false },
applyChanges: vi.fn()
})
}))
import ManagerProgressToast from './ManagerProgressToast.vue'
// HoneyToast stub that emits update:expanded on mount to expand the component
const HoneyToastStub = defineComponent({
name: 'HoneyToastStub',
emits: ['update:expanded'],
setup(_, { emit, slots }) {
onMounted(() => {
emit('update:expanded', true)
})
return () =>
h('div', { 'data-testid': 'honey-toast' }, [
slots.default?.(),
slots.footer?.({ toggle: () => {} })
])
}
})
const renderComponent = async () => {
const result = render(ManagerProgressToast, {
global: {
plugins: [PrimeVue, createTestingPinia({ stubActions: false })],
stubs: {
HoneyToast: HoneyToastStub,
DotSpinner: true,
Panel: {
template: '<div><slot name="header" /><slot /></div>'
},
TabMenu: {
template: `
<div data-testid="tab-menu">
<template v-for="(item, index) in model" :key="index">
<slot name="item" :item="item" :props="{ action: {} }" :label="item.label" />
</template>
</div>
`,
props: ['model', 'activeIndex']
}
}
}
})
// Wait for the emit to propagate
await new Promise((resolve) => setTimeout(resolve, 0))
return result
}
describe('ManagerProgressToast', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFailedTasksIds.value = []
})
describe('failure indicator', () => {
it('does not show failure indicator when there are no failures', async () => {
mockFailedTasksIds.value = []
await renderComponent()
// When there are no failures, the failed tab should NOT have an
// aria-label with the indicator tooltip
const failedTabWithIndicator = screen.queryByLabelText(
/failedTabIndicatorTooltip/
)
expect(failedTabWithIndicator).toBeNull()
})
it('shows failure indicator aria-label when there are failures', async () => {
mockFailedTasksIds.value = ['task-1', 'task-2']
await renderComponent()
// When there are failures, the failed tab should have an aria-label
// containing the indicator tooltip with the count
const failedTabWithIndicator = screen.getByLabelText(
/failedTabIndicatorTooltip:2/
)
expect(failedTabWithIndicator).toBeInTheDocument()
})
it('shows title tooltip with failure count on Failed tab', async () => {
mockFailedTasksIds.value = ['task-1', 'task-2', 'task-3']
await renderComponent()
// The aria-label should contain the count (3 in this case)
const failedTabWithIndicator = screen.getByLabelText(
/failedTabIndicatorTooltip:3/
)
expect(failedTabWithIndicator).toBeInTheDocument()
})
})
})

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,152 @@ 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()
})
it('does not show toast for non-install failures (update, uninstall, etc.)', async () => {
const store = useComfyManagerStore()
const updateFailure: ManagerComponents['schemas']['TaskHistoryItem'] = {
ui_id: 'update-1',
client_id: 'test',
kind: 'update',
result: 'failed',
status: { status_str: 'error', completed: false, messages: ['boom'] },
timestamp: new Date().toISOString()
}
const uninstallFailure: ManagerComponents['schemas']['TaskHistoryItem'] =
{
ui_id: 'uninstall-1',
client_id: 'test',
kind: 'uninstall',
result: 'failed',
status: { status_str: 'error', completed: false, messages: ['boom'] },
timestamp: new Date().toISOString()
}
setTaskHistory(store, {
a: updateFailure,
b: uninstallFailure
})
await nextTick()
await vi.runAllTimersAsync()
expect(toastAddMock).not.toHaveBeenCalled()
})
it('does not re-notify already-notified failures when history is replayed', async () => {
const store = useComfyManagerStore()
// First failure triggers notification
setTaskHistory(store, { a: errorTask('a') })
await nextTick()
await vi.runAllTimersAsync()
expect(toastAddMock).toHaveBeenCalledTimes(1)
toastAddMock.mockClear()
// Simulate server state replay: resetTaskState clears local state,
// then history is re-populated with the same failed task
store.resetTaskState()
await nextTick()
setTaskHistory(store, { a: errorTask('a') })
await nextTick()
await vi.runAllTimersAsync()
// Should notify again after reset (fresh session)
expect(toastAddMock).toHaveBeenCalledTimes(1)
})
it('does not re-notify same failure ID within same session', async () => {
const store = useComfyManagerStore()
setTaskHistory(store, { a: errorTask('a') })
await nextTick()
await vi.runAllTimersAsync()
expect(toastAddMock).toHaveBeenCalledTimes(1)
toastAddMock.mockClear()
// Re-assign same history (simulating server push with same data)
setTaskHistory(store, { a: errorTask('a') })
await nextTick()
await vi.runAllTimersAsync()
// Should NOT re-notify - already notified about this ID
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,49 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
{ deep: true }
)
// Track install failures we've already notified about to prevent replay
const notifiedFailedInstallIds = ref<Set<string>>(new Set())
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 taskHistory for newly completed install failures.
// Only fires toast for tasks where kind === 'install' and status is error/skip.
// Tracks notified IDs to prevent replay when server state reintroduces history.
watch(
taskHistory,
(history) => {
let newFailures = 0
for (const task of Object.values(history)) {
// Only notify for install tasks, not update/uninstall/enable/disable/etc.
if (task.kind !== 'install') continue
// Only notify for failures (error or skip status)
if (task.status?.status_str === 'success') continue
// Skip if we've already notified about this task
if (notifiedFailedInstallIds.value.has(task.ui_id)) continue
notifiedFailedInstallIds.value.add(task.ui_id)
newFailures++
}
if (newFailures > 0) {
pendingFailureCount.value += newFailures
void flushFailureToast()
}
},
{ deep: true }
)
const getPackId = (pack: ManagerPackInstalled) => pack.cnr_id || pack.aux_id
const isInstalledPackId = (packName: string | undefined): boolean =>
@@ -336,7 +380,12 @@ 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.
// Clear notifiedFailedInstallIds so we start fresh tracking.
pendingFailureCount.value = 0
notifiedFailedInstallIds.value.clear()
taskLogs.value = []
taskHistory.value = {}
succeededTasksIds.value = []