mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-24 22:58:08 +00:00
Compare commits
6 Commits
litegraph/
...
glary/fe-2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16d324a80f | ||
|
|
c8e103f3c6 | ||
|
|
cc07b775a5 | ||
|
|
bf022a07e8 | ||
|
|
fafcc20c7d | ||
|
|
841990ec05 |
@@ -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.",
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
Reference in New Issue
Block a user