[feat] Add import failure detection and error handling for package manager (#4600)

This commit is contained in:
Jin Yi
2025-07-31 13:24:28 +09:00
committed by GitHub
parent cfb3011398
commit a6c3fa72be
17 changed files with 784 additions and 253 deletions

View File

@@ -1,15 +1,66 @@
<template>
<div class="w-[552px] max-h-[246px] flex flex-col">
<div class="w-[552px] flex flex-col">
<ContentDivider :width="1" />
<div class="px-4 py-6 w-full h-full flex flex-col gap-2">
<!-- Description -->
<!-- <div>
<p class="text-sm leading-4 text-gray-100 m-0 mb-4">
<div v-if="showAfterWhatsNew">
<p
class="text-sm leading-4 text-neutral-800 dark-theme:text-white m-0 mb-4"
>
{{ $t('manager.conflicts.description') }}
<br /><br />
{{ $t('manager.conflicts.info') }}
</p>
</div> -->
</div>
<!-- Import Failed List Wrapper -->
<div
v-if="importFailedConflicts.length > 0"
class="w-full flex flex-col bg-neutral-200 dark-theme:bg-black min-h-8 rounded-lg"
>
<div
class="w-full h-8 flex items-center justify-between gap-2 pl-4"
@click="toggleImportFailedPanel"
>
<div class="flex-1 flex">
<span
class="text-xs font-bold text-yellow-600 dark-theme:text-yellow-400 mr-2"
>{{ importFailedConflicts.length }}</span
>
<span
class="text-xs font-bold text-neutral-600 dark-theme:text-white"
>{{ $t('manager.conflicts.importFailedExtensions') }}</span
>
</div>
<div>
<Button
:icon="
importFailedExpanded
? 'pi pi-chevron-down text-xs'
: 'pi pi-chevron-right text-xs'
"
text
class="text-neutral-600 dark-theme:text-neutral-300 !bg-transparent"
/>
</div>
</div>
<!-- Import failed list -->
<div
v-if="importFailedExpanded"
class="py-2 px-4 flex flex-col gap-2.5 max-h-[142px] overflow-y-auto scrollbar-hide"
>
<div
v-for="(packageName, i) in importFailedConflicts"
:key="i"
class="flex items-center justify-between h-6 px-4 flex-shrink-0 conflict-list-item"
>
<span class="text-xs text-neutral-600 dark-theme:text-neutral-300">
{{ packageName }}
</span>
<span class="pi pi-info-circle text-sm"></span>
</div>
</div>
</div>
<!-- Conflict List Wrapper -->
<div
class="w-full flex flex-col bg-neutral-200 dark-theme:bg-black min-h-8 rounded-lg"
@@ -111,48 +162,66 @@
</template>
<script setup lang="ts">
import { filter, flatMap, map, some } from 'lodash'
import Button from 'primevue/button'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
import type { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
import { useConflictDetection } from '@/composables/useConflictDetection'
import { getConflictMessage } from '@/utils/conflictMessageUtil'
interface Props {
conflicts?: ConflictDetectionResult[]
conflictedPackages?: ConflictDetectionResult[]
showAfterWhatsNew?: boolean
}
const props = withDefaults(defineProps<Props>(), {
conflicts: () => [],
conflictedPackages: () => []
const { showAfterWhatsNew } = withDefaults(defineProps<Props>(), {
showAfterWhatsNew: false
})
const { t } = useI18n()
const { conflictedPackages } = useConflictDetection()
const conflictsExpanded = ref<boolean>(false)
const extensionsExpanded = ref<boolean>(false)
const importFailedExpanded = ref<boolean>(false)
// Use conflictedPackages if provided, otherwise fallback to conflicts
const conflictData = computed(() =>
props.conflictedPackages.length > 0
? props.conflictedPackages
: props.conflicts
)
const conflictData = computed(() => conflictedPackages.value)
const allConflictDetails = computed(() =>
conflictData.value.flatMap((result) => result.conflicts)
)
const allConflictDetails = computed(() => {
const allConflicts = flatMap(conflictData.value, (result) => result.conflicts)
return filter(allConflicts, (conflict) => conflict.type !== 'import_failed')
})
const packagesWithImportFailed = computed(() => {
return filter(conflictData.value, (result) =>
some(result.conflicts, (conflict) => conflict.type === 'import_failed')
)
})
const importFailedConflicts = computed(() => {
return map(
packagesWithImportFailed.value,
(result) => result.package_name || result.package_id
)
})
const toggleImportFailedPanel = () => {
importFailedExpanded.value = !importFailedExpanded.value
conflictsExpanded.value = false
extensionsExpanded.value = false
}
const toggleConflictsPanel = () => {
conflictsExpanded.value = !conflictsExpanded.value
extensionsExpanded.value = false
importFailedExpanded.value = false
}
const toggleExtensionsPanel = () => {
extensionsExpanded.value = !extensionsExpanded.value
conflictsExpanded.value = false
importFailedExpanded.value = false
}
</script>
<style scoped>

View File

@@ -17,9 +17,10 @@
<script setup lang="ts">
import Message from 'primevue/message'
import { computed } from 'vue'
import { computed, inject } from 'vue'
import { components } from '@/types/comfyRegistryTypes'
import { ImportFailedKey } from '@/types/importFailedTypes'
type PackVersionStatus = components['schemas']['NodeVersionStatus']
type PackStatus = components['schemas']['NodeStatus']
@@ -37,6 +38,10 @@ const { statusType, hasCompatibilityIssues } = defineProps<{
hasCompatibilityIssues?: boolean
}>()
// Inject import failed context from parent
const importFailedContext = inject(ImportFailedKey)
const importFailed = importFailedContext?.importFailed
const statusPropsMap: Record<Status, StatusProps> = {
NodeStatusActive: {
label: 'active',
@@ -72,14 +77,14 @@ const statusPropsMap: Record<Status, StatusProps> = {
}
}
const statusLabel = computed(() =>
hasCompatibilityIssues
? 'conflicting'
: statusPropsMap[statusType]?.label || 'unknown'
)
const statusSeverity = computed(() =>
hasCompatibilityIssues
? 'error'
: statusPropsMap[statusType]?.severity || 'secondary'
)
const statusLabel = computed(() => {
if (importFailed?.value) return 'importFailed'
if (hasCompatibilityIssues) return 'conflicting'
return statusPropsMap[statusType]?.label || 'unknown'
})
const statusSeverity = computed(() => {
if (hasCompatibilityIssues || importFailed?.value) return 'error'
return statusPropsMap[statusType]?.severity || 'secondary'
})
</script>

View File

@@ -7,7 +7,7 @@
showDelay: 300
}"
class="flex items-center justify-center w-6 h-6 cursor-pointer"
@click="showConflictModal"
@click="showConflictModal(true)"
>
<i class="pi pi-exclamation-triangle text-yellow-500 text-xl"></i>
</div>
@@ -70,8 +70,10 @@ const canToggleDirectly = computed(() => {
)
})
const showConflictModal = () => {
if (packageConflict.value && !acknowledgmentState.value.modal_dismissed) {
const showConflictModal = (skipModalDismissed: boolean) => {
let modal_dismissed = acknowledgmentState.value.modal_dismissed
if (skipModalDismissed) modal_dismissed = false
if (packageConflict.value && !modal_dismissed) {
showNodeConflictDialog({
conflictedPackages: [packageConflict.value],
buttonText: !isEnabled.value

View File

@@ -18,7 +18,6 @@
import { inject, ref } from 'vue'
import PackActionButton from '@/components/dialog/content/manager/button/PackActionButton.vue'
import { useConflictAcknowledgment } from '@/composables/useConflictAcknowledgment'
import { t } from '@/i18n'
import { useDialogService } from '@/services/dialogService'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
@@ -42,7 +41,6 @@ const { nodePacks, variant, label, hasConflict, conflictInfo } = defineProps<{
const isInstalling = inject(IsInstallingKey, ref(false))
const managerStore = useComfyManagerStore()
const { acknowledgmentState, markConflictsAsSeen } = useConflictAcknowledgment()
const { showNodeConflictDialog } = useDialogService()
const createPayload = (
@@ -74,11 +72,7 @@ const installPack = (item: NodePack) =>
const installAllPacks = async () => {
if (!nodePacks?.length) return
if (
hasConflict &&
conflictInfo &&
!acknowledgmentState.value.modal_dismissed
) {
if (hasConflict && conflictInfo) {
const conflictedPackages: ConflictDetectionResult[] = nodePacks.map(
(pack) => ({
package_id: pack.id || '',
@@ -96,11 +90,6 @@ const installAllPacks = async () => {
// Proceed with installation
isInstalling.value = true
await performInstallation(nodePacks)
},
dialogComponentProps: {
onClose: () => {
markConflictsAsSeen()
}
}
})
return

View File

@@ -13,7 +13,7 @@
>
<div class="mb-6">
<MetadataRow
v-if="isPackInstalled(nodePack.id)"
v-if="!importFailed && isPackInstalled(nodePack.id)"
:label="t('manager.filter.enabled')"
class="flex"
style="align-items: center"
@@ -71,11 +71,13 @@ import InfoPanelHeader from '@/components/dialog/content/manager/infoPanel/InfoP
import InfoTabs from '@/components/dialog/content/manager/infoPanel/InfoTabs.vue'
import MetadataRow from '@/components/dialog/content/manager/infoPanel/MetadataRow.vue'
import { useConflictDetection } from '@/composables/useConflictDetection'
import { useImportFailedDetection } from '@/composables/useImportFailedDetection'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/stores/conflictDetectionStore'
import { IsInstallingKey } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
import type { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
import { ImportFailedKey } from '@/types/importFailedTypes'
interface InfoItem {
key: string
@@ -129,6 +131,15 @@ const hasCompatibilityIssues = computed(() => {
return conflictResult.value?.has_conflict
})
const packageId = computed(() => nodePack.id || '')
const { importFailed, showImportFailedDialog } =
useImportFailedDetection(packageId)
provide(ImportFailedKey, {
importFailed,
showImportFailedDialog
})
const infoItems = computed<InfoItem[]>(() => [
{
key: 'publisher',

View File

@@ -11,7 +11,10 @@
<span class="inline-block text-base">{{ nodePacks[0].name }}</span>
</slot>
</h2>
<div class="mt-2 mb-4 w-full max-w-xs flex justify-center">
<div
v-if="!importFailed"
class="mt-2 mb-4 w-full max-w-xs flex justify-center"
>
<slot name="install-button">
<PackUninstallButton
v-if="isAllInstalled"
@@ -36,7 +39,7 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { inject, ref, watch } from 'vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
@@ -44,6 +47,7 @@ import PackUninstallButton from '@/components/dialog/content/manager/button/Pack
import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { components } from '@/types/comfyRegistryTypes'
import { ImportFailedKey } from '@/types/importFailedTypes'
const { nodePacks, hasConflict } = defineProps<{
nodePacks: components['schemas']['Node'][]
@@ -52,6 +56,10 @@ const { nodePacks, hasConflict } = defineProps<{
const managerStore = useComfyManagerStore()
// Inject import failed context from parent
const importFailedContext = inject(ImportFailedKey)
const importFailed = importFailedContext?.importFailed
const isAllInstalled = ref(false)
watch(
[() => nodePacks, () => managerStore.installedPacks],

View File

@@ -5,7 +5,7 @@
<Tab v-if="hasCompatibilityIssues" value="warning" class="p-2 mr-6">
<div class="flex items-center gap-1">
<span></span>
{{ $t('g.warning') }}
{{ importFailed ? $t('g.error') : $t('g.warning') }}
</div>
</Tab>
<Tab value="description" class="p-2 mr-6">
@@ -43,13 +43,14 @@ import TabList from 'primevue/tablist'
import TabPanel from 'primevue/tabpanel'
import TabPanels from 'primevue/tabpanels'
import Tabs from 'primevue/tabs'
import { computed, ref, watchEffect } from 'vue'
import { computed, inject, ref, watchEffect } from 'vue'
import DescriptionTabPanel from '@/components/dialog/content/manager/infoPanel/tabs/DescriptionTabPanel.vue'
import NodesTabPanel from '@/components/dialog/content/manager/infoPanel/tabs/NodesTabPanel.vue'
import WarningTabPanel from '@/components/dialog/content/manager/infoPanel/tabs/WarningTabPanel.vue'
import { components } from '@/types/comfyRegistryTypes'
import type { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
import { ImportFailedKey } from '@/types/importFailedTypes'
const { nodePack, hasCompatibilityIssues, conflictResult } = defineProps<{
nodePack: components['schemas']['Node']
@@ -57,6 +58,10 @@ const { nodePack, hasCompatibilityIssues, conflictResult } = defineProps<{
conflictResult?: ConflictDetectionResult | null
}>()
// Inject import failed context from parent
const importFailedContext = inject(ImportFailedKey)
const importFailed = importFailedContext?.importFailed
const nodeNames = computed(() => {
// @ts-expect-error comfy_nodes is an Algolia-specific field
const { comfy_nodes } = nodePack

View File

@@ -1,24 +1,44 @@
<template>
<div class="flex flex-col gap-3">
<button
v-if="importFailedInfo"
class="cursor-pointer outline-none border-none inline-flex items-center justify-end bg-transparent gap-1"
@click="showImportFailedDialog"
>
<i class="pi pi-code text-base"></i>
<span class="dark-theme:text-white text-sm">{{
t('serverStart.openLogs')
}}</span>
</button>
<div
v-for="(conflict, index) in conflictResult?.conflicts || []"
:key="index"
class="p-3 bg-yellow-800/20 rounded-md"
>
<div class="text-sm break-words">
{{ getConflictMessage(conflict, $t) }}
<div class="flex justify-between items-center">
<div class="text-sm break-words flex-1">
{{ getConflictMessage(conflict, $t) }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useImportFailedDetection } from '@/composables/useImportFailedDetection'
import { t } from '@/i18n'
import { components } from '@/types/comfyRegistryTypes'
import type { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
import { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
import { getConflictMessage } from '@/utils/conflictMessageUtil'
const { conflictResult } = defineProps<{
const { nodePack, conflictResult } = defineProps<{
nodePack: components['schemas']['Node']
conflictResult?: ConflictDetectionResult | null
conflictResult: ConflictDetectionResult | null
}>()
const packageId = computed(() => nodePack?.id || '')
const { importFailedInfo, showImportFailedDialog } =
useImportFailedDetection(packageId)
</script>

View File

@@ -7,18 +7,31 @@
<span>{{ formattedDownloads }}</span>
</div>
<div class="flex justify-end items-center gap-2">
<template v-if="!isInstalled">
<PackInstallButton
:node-packs="[nodePack]"
:has-conflict="uninstalledPackConflict.hasConflict"
:conflict-info="uninstalledPackConflict.conflicts"
/>
<template v-if="importFailed">
<div
class="flex justify-center items-center gap-2 cursor-pointer"
@click="showImportFailedDialog"
>
<i class="pi pi-exclamation-triangle text-red-500 text-sm"></i>
<span class="text-red-500 text-xs pt-0.5">{{
t('manager.failedToInstall')
}}</span>
</div>
</template>
<template v-else>
<PackEnableToggle
:node-pack="nodePack"
:has-conflict="installedPackHasConflict"
/>
<template v-if="!isInstalled">
<PackInstallButton
:node-packs="[nodePack]"
:has-conflict="uninstalledPackConflict.hasConflict"
:conflict-info="uninstalledPackConflict.conflicts"
/>
</template>
<template v-else>
<PackEnableToggle
:node-pack="nodePack"
:has-conflict="installedPackHasConflict"
/>
</template>
</template>
</div>
</div>
@@ -31,6 +44,7 @@ import { useI18n } from 'vue-i18n'
import PackEnableToggle from '@/components/dialog/content/manager/button/PackEnableToggle.vue'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import { useConflictDetection } from '@/composables/useConflictDetection'
import { useImportFailedDetection } from '@/composables/useImportFailedDetection'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/stores/conflictDetectionStore'
import type { components } from '@/types/comfyRegistryTypes'
@@ -42,7 +56,7 @@ const { nodePack } = defineProps<{
const { isPackInstalled } = useComfyManagerStore()
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
const { n } = useI18n()
const { n, t } = useI18n()
const formattedDownloads = computed(() =>
nodePack.downloads ? n(nodePack.downloads) : ''
@@ -51,14 +65,17 @@ const formattedDownloads = computed(() =>
const { getConflictsForPackageByID } = useConflictDetectionStore()
const { checkNodeCompatibility } = useConflictDetection()
const { importFailed, showImportFailedDialog } = useImportFailedDetection(
nodePack.id
)
const conflicts = computed(
() => getConflictsForPackageByID(nodePack.id!) || null
)
const installedPackHasConflict = computed(() => {
if (!nodePack.id) return false
// Try exact match first
let conflicts = getConflictsForPackageByID(nodePack.id)
if (conflicts) return true
return false
return !!conflicts.value
})
const uninstalledPackConflict = computed(() => {

View File

@@ -124,11 +124,8 @@ const handleWhatsNewDismissed = async () => {
* Show the node conflict dialog with current conflict data
*/
const showConflictModal = () => {
const conflictData = {
conflictedPackages: conflictDetection.conflictedPackages.value
}
showNodeConflictDialog({
...conflictData,
showAfterWhatsNew: true,
dialogComponentProps: {
onClose: () => {
markConflictsAsSeen()

View File

@@ -0,0 +1,85 @@
import { type ComputedRef, computed, unref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDialogService } from '@/services/dialogService'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/stores/conflictDetectionStore'
import type { ConflictDetail } from '@/types/conflictDetectionTypes'
/**
* Extracting import failed conflicts from conflict list
*/
function extractImportFailedConflicts(conflicts?: ConflictDetail[] | null) {
if (!conflicts) return null
const importFailedConflicts = conflicts.filter(
(item): item is ConflictDetail => item.type === 'import_failed'
)
return importFailedConflicts.length > 0 ? importFailedConflicts : null
}
/**
* Creating import failed dialog
*/
function createImportFailedDialog() {
const { t } = useI18n()
const { showErrorDialog } = useDialogService()
return (importFailedInfo: ConflictDetail[] | null) => {
if (importFailedInfo) {
const errorMessage =
importFailedInfo
.map((conflict) => conflict.required_value)
.filter(Boolean)
.join('\n') || t('manager.importFailedGenericError')
const error = new Error(errorMessage)
showErrorDialog(error, {
title: t('manager.failedToInstall'),
reportType: 'importFailedError'
})
}
}
}
/**
* Composable for detecting and handling import failed conflicts
* @param packageId - Package ID string or computed ref
* @returns Object with import failed detection and dialog handler
*/
export function useImportFailedDetection(
packageId?: string | ComputedRef<string> | null
) {
const { isPackInstalled } = useComfyManagerStore()
const { getConflictsForPackageByID } = useConflictDetectionStore()
const isInstalled = computed(() =>
packageId ? isPackInstalled(unref(packageId)) : false
)
const conflicts = computed(() => {
const currentPackageId = unref(packageId)
if (!currentPackageId || !isInstalled.value) return null
return getConflictsForPackageByID(currentPackageId) || null
})
const importFailedInfo = computed(() => {
return extractImportFailedConflicts(conflicts.value?.conflicts)
})
const importFailed = computed(() => {
return importFailedInfo.value !== null
})
const showImportFailedDialog = createImportFailedDialog()
return {
importFailedInfo,
importFailed,
showImportFailedDialog: () =>
showImportFailedDialog(importFailedInfo.value),
isInstalled
}
}

View File

@@ -143,6 +143,9 @@
"legacyManagerUI": "Use Legacy UI",
"legacyManagerUIDescription": "To use the legacy Manager UI, start ComfyUI with --enable-manager-legacy-ui",
"failed": "Failed",
"failedToInstall": "Failed to Install",
"installError": "Install Error",
"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.",
"installationQueue": "Installation Queue",
@@ -191,7 +194,8 @@
"deleted": "Deleted",
"banned": "Banned",
"unknown": "Unknown",
"conflicting": "Conflicting"
"conflicting": "Conflicting",
"importFailed": "Install Error"
},
"sort": {
"downloads": "Most Popular",
@@ -210,6 +214,7 @@
"info": "If you continue with the update, the conflicting extensions will be disabled automatically. You can review and manage them anytime in the ComfyUI Manager.",
"extensionAtRisk": "Extension at Risk",
"conflicts": "Conflicts",
"importFailedExtensions": "Import Failed Extensions",
"conflictInfoTitle": "Why is this happening?",
"installAnyway": "Install Anyway",
"enableAnyway": "Enable Anyway",
@@ -226,7 +231,8 @@
"accelerator": "GPU/Accelerator not supported (available: {current}, required: {required})",
"generic": "Compatibility issue (current: {current}, required: {required})",
"banned": "This package is banned for security reasons",
"pending": "Security verification pending - compatibility cannot be verified"
"pending": "Security verification pending - compatibility cannot be verified",
"import_failed": "Import Failed"
},
"warningTooltip": "This package may have compatibility issues with your current environment"
}

View File

@@ -432,14 +432,19 @@ export const useDialogService = () => {
}
function showNodeConflictDialog(
options: InstanceType<typeof NodeConflictDialogContent>['$props'] & {
options: {
showAfterWhatsNew?: boolean
dialogComponentProps?: DialogComponentProps
buttonText?: string
onButtonClick?: () => void
} = {}
) {
const { dialogComponentProps, buttonText, onButtonClick, ...props } =
options
const {
dialogComponentProps,
buttonText,
onButtonClick,
showAfterWhatsNew
} = options
return dialogStore.showDialog({
key: 'global-node-conflict',
@@ -461,7 +466,9 @@ export const useDialogService = () => {
},
...dialogComponentProps
},
props,
props: {
showAfterWhatsNew
},
footerProps: {
buttonText,
onButtonClick

View File

@@ -0,0 +1,9 @@
import type { ComputedRef, InjectionKey } from 'vue'
export interface ImportFailedContext {
importFailed: ComputedRef<boolean>
showImportFailedDialog: () => void
}
export const ImportFailedKey: InjectionKey<ImportFailedContext> =
Symbol('ImportFailed')

View File

@@ -27,8 +27,12 @@ export function getConflictMessage(
})
}
// For banned and pending, use simple message
if (conflict.type === 'banned' || conflict.type === 'pending') {
// For banned, pending, and import_failed, use simple message
if (
conflict.type === 'banned' ||
conflict.type === 'pending' ||
conflict.type === 'import_failed'
) {
return t(messageKey)
}

View File

@@ -1,43 +1,77 @@
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { createPinia, setActivePinia } from 'pinia'
import Button from 'primevue/button'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import NodeConflictDialogContent from '@/components/dialog/content/manager/NodeConflictDialogContent.vue'
import type { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
import { getConflictMessage } from '@/utils/conflictMessageUtil'
// Mock getConflictMessage utility
vi.mock('@/utils/conflictMessageUtil', () => ({
getConflictMessage: vi.fn((conflict, t) => {
return `${conflict.type}: ${conflict.current_value} vs ${conflict.required_value}`
})
}))
// Mock dependencies
vi.mock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
t: vi.fn((key: string) => {
const translations: Record<string, string> = {
'manager.conflicts.description': 'Some extensions are not compatible',
'manager.conflicts.info': 'Additional info about conflicts',
'manager.conflicts.conflicts': 'Conflicts',
'manager.conflicts.extensionAtRisk': 'Extensions at Risk'
'manager.conflicts.extensionAtRisk': 'Extensions at Risk',
'manager.conflicts.importFailedExtensions': 'Import Failed Extensions'
}
return translations[key] || key
})
}))
}))
// Mock data for conflict detection
const mockConflictData = ref<ConflictDetectionResult[]>([])
// Mock useConflictDetection composable
vi.mock('@/composables/useConflictDetection', () => ({
useConflictDetection: () => ({
conflictedPackages: computed(() => mockConflictData.value)
})
}))
describe('NodeConflictDialogContent', () => {
let pinia: ReturnType<typeof createPinia>
beforeEach(() => {
vi.clearAllMocks()
pinia = createPinia()
setActivePinia(pinia)
// Reset mock data
mockConflictData.value = []
})
const createWrapper = (props = {}) => {
return mount(NodeConflictDialogContent, {
props,
global: {
plugins: [createPinia()],
plugins: [pinia],
components: {
Button
},
stubs: {
ContentDivider: true
},
mocks: {
$t: vi.fn((key: string) => {
const translations: Record<string, string> = {
'manager.conflicts.description':
'Some extensions are not compatible',
'manager.conflicts.info': 'Additional info about conflicts',
'manager.conflicts.conflicts': 'Conflicts',
'manager.conflicts.extensionAtRisk': 'Extensions at Risk'
'manager.conflicts.extensionAtRisk': 'Extensions at Risk',
'manager.conflicts.importFailedExtensions':
'Import Failed Extensions'
}
return translations[key] || key
})
@@ -77,265 +111,317 @@ describe('NodeConflictDialogContent', () => {
required_value: 'not_banned'
}
]
},
{
package_id: 'Package3',
package_name: 'Test Package 3',
has_conflict: true,
is_compatible: false,
conflicts: [
{
type: 'import_failed',
current_value: 'installed',
required_value: 'ModuleNotFoundError: No module named "example"'
}
]
}
]
describe('rendering', () => {
it('should render without conflicts', () => {
const wrapper = createWrapper({
conflicts: [],
conflictedPackages: []
})
// Set empty conflict data
mockConflictData.value = []
const wrapper = createWrapper()
expect(wrapper.text()).toContain('0')
expect(wrapper.text()).toContain('Conflicts')
expect(wrapper.text()).toContain('Extensions at Risk')
expect(wrapper.find('[class*="Import Failed Extensions"]').exists()).toBe(
false
)
})
it('should render with conflict data from conflicts prop', () => {
const wrapper = createWrapper({
conflicts: mockConflictResults,
conflictedPackages: []
})
it('should render with conflict data from composable', () => {
// Set conflict data
mockConflictData.value = mockConflictResults
expect(wrapper.text()).toContain('3') // 2 from Package1 + 1 from Package2
expect(wrapper.text()).toContain('Conflicts')
expect(wrapper.text()).toContain('2')
expect(wrapper.text()).toContain('Extensions at Risk')
})
it('should render with conflict data from conflictedPackages prop', () => {
const wrapper = createWrapper({
conflicts: [],
conflictedPackages: mockConflictResults
})
const wrapper = createWrapper()
// Should show 3 total conflicts (2 from Package1 + 1 from Package2, excluding import_failed)
expect(wrapper.text()).toContain('3')
expect(wrapper.text()).toContain('Conflicts')
expect(wrapper.text()).toContain('2')
// Should show 3 extensions at risk (all packages)
expect(wrapper.text()).toContain('Extensions at Risk')
// Should show import failed section
expect(wrapper.text()).toContain('Import Failed Extensions')
expect(wrapper.text()).toContain('1') // 1 import failed package
})
it('should prioritize conflictedPackages over conflicts prop', () => {
const singleConflict: ConflictDetectionResult[] = [
{
package_id: 'SinglePackage',
package_name: 'Single Package',
has_conflict: true,
is_compatible: false,
conflicts: [
{
type: 'os',
current_value: 'macOS',
required_value: 'Windows'
}
]
}
]
it('should show description when showAfterWhatsNew is true', () => {
const wrapper = createWrapper({
conflicts: mockConflictResults, // 3 conflicts
conflictedPackages: singleConflict // 1 conflict
showAfterWhatsNew: true
})
// Should use conflictedPackages (1 conflict) instead of conflicts (3 conflicts)
expect(wrapper.text()).toContain('1')
expect(wrapper.text()).toContain('Conflicts')
expect(wrapper.text()).toContain('Extensions at Risk')
expect(wrapper.text()).toContain('Some extensions are not compatible')
expect(wrapper.text()).toContain('Additional info about conflicts')
})
it('should not show description when showAfterWhatsNew is false', () => {
const wrapper = createWrapper({
showAfterWhatsNew: false
})
expect(wrapper.text()).not.toContain('Some extensions are not compatible')
expect(wrapper.text()).not.toContain('Additional info about conflicts')
})
it('should separate import_failed conflicts into separate section', () => {
mockConflictData.value = mockConflictResults
const wrapper = createWrapper()
// Import Failed Extensions section should show 1 package
const importFailedSection = wrapper.findAll(
'.w-full.flex.flex-col.bg-neutral-200'
)[0]
expect(importFailedSection.text()).toContain('1')
expect(importFailedSection.text()).toContain('Import Failed Extensions')
// Conflicts section should show 3 conflicts (excluding import_failed)
const conflictsSection = wrapper.findAll(
'.w-full.flex.flex-col.bg-neutral-200'
)[1]
expect(conflictsSection.text()).toContain('3')
expect(conflictsSection.text()).toContain('Conflicts')
})
})
describe('panel interactions', () => {
it('should toggle conflicts panel', async () => {
const wrapper = createWrapper({
conflictedPackages: mockConflictResults
})
// Initially collapsed
expect(wrapper.find('.conflict-list-item').exists()).toBe(false)
// Click to expand conflicts panel
const conflictsHeader = wrapper.find('.w-full.h-8.flex.items-center')
await conflictsHeader.trigger('click')
// Should be expanded now
expect(wrapper.find('.conflict-list-item').exists()).toBe(true)
// Should show chevron-down icon when expanded
const chevronButton = wrapper.findComponent(Button)
expect(chevronButton.props('icon')).toContain('pi-chevron-down')
beforeEach(() => {
mockConflictData.value = mockConflictResults
})
it('should toggle extensions panel', async () => {
const wrapper = createWrapper({
conflictedPackages: mockConflictResults
})
it('should toggle import failed panel', async () => {
const wrapper = createWrapper()
// Find extensions panel header (second one)
const extensionsHeader = wrapper.findAll(
'.w-full.h-8.flex.items-center'
)[1]
// Find import failed panel header (first one)
const importFailedHeader = wrapper.find('.w-full.h-8.flex.items-center')
// Initially collapsed
expect(
wrapper.find('[class*="py-2 px-4 flex flex-col gap-2.5"]').exists()
).toBe(false)
// Click to expand extensions panel
await extensionsHeader.trigger('click')
// Click to expand import failed panel
await importFailedHeader.trigger('click')
// Should be expanded now
expect(
wrapper.find('[class*="py-2 px-4 flex flex-col gap-2.5"]').exists()
).toBe(true)
// Should be expanded now and show package name
const expandedContent = wrapper.find(
'[class*="py-2 px-4 flex flex-col gap-2.5"]'
)
expect(expandedContent.exists()).toBe(true)
expect(expandedContent.text()).toContain('Test Package 3')
// Should show chevron-down icon when expanded
const chevronButton = wrapper.findComponent(Button)
expect(chevronButton.props('icon')).toContain('pi-chevron-down')
})
it('should collapse other panel when opening one', async () => {
const wrapper = createWrapper({
conflictedPackages: mockConflictResults
})
it('should toggle conflicts panel', async () => {
const wrapper = createWrapper()
const conflictsHeader = wrapper.find('.w-full.h-8.flex.items-center')
const extensionsHeader = wrapper.findAll(
// Find conflicts panel header (second one)
const conflictsHeader = wrapper.findAll(
'.w-full.h-8.flex.items-center'
)[1]
// Open conflicts panel first
// Click to expand conflicts panel
await conflictsHeader.trigger('click')
// Verify conflicts panel is open
// Should be expanded now
const conflictItems = wrapper.findAll('.conflict-list-item')
expect(conflictItems.length).toBeGreaterThan(0)
})
it('should toggle extensions panel', async () => {
const wrapper = createWrapper()
// Find extensions panel header (third one)
const extensionsHeader = wrapper.findAll(
'.w-full.h-8.flex.items-center'
)[2]
// Click to expand extensions panel
await extensionsHeader.trigger('click')
// Should be expanded now and show all package names
const expandedContent = wrapper.findAll(
'[class*="py-2 px-4 flex flex-col gap-2.5"]'
)[0]
expect(expandedContent.exists()).toBe(true)
expect(expandedContent.text()).toContain('Test Package 1')
expect(expandedContent.text()).toContain('Test Package 2')
expect(expandedContent.text()).toContain('Test Package 3')
})
it('should collapse other panels when opening one', async () => {
const wrapper = createWrapper()
const importFailedHeader = wrapper.findAll(
'.w-full.h-8.flex.items-center'
)[0]
const conflictsHeader = wrapper.findAll(
'.w-full.h-8.flex.items-center'
)[1]
const extensionsHeader = wrapper.findAll(
'.w-full.h-8.flex.items-center'
)[2]
// Open import failed panel first
await importFailedHeader.trigger('click')
// Verify import failed panel is open
expect((wrapper.vm as any).importFailedExpanded).toBe(true)
expect((wrapper.vm as any).conflictsExpanded).toBe(false)
expect((wrapper.vm as any).extensionsExpanded).toBe(false)
// Open conflicts panel
await conflictsHeader.trigger('click')
// Verify conflicts panel is open and others are closed
expect((wrapper.vm as any).importFailedExpanded).toBe(false)
expect((wrapper.vm as any).conflictsExpanded).toBe(true)
expect((wrapper.vm as any).extensionsExpanded).toBe(false)
// Open extensions panel
await extensionsHeader.trigger('click')
// Verify extensions panel is open and conflicts panel is closed
// Verify extensions panel is open and others are closed
expect((wrapper.vm as any).importFailedExpanded).toBe(false)
expect((wrapper.vm as any).conflictsExpanded).toBe(false)
expect((wrapper.vm as any).extensionsExpanded).toBe(true)
})
})
describe('conflict display', () => {
it('should display individual conflict details', async () => {
const wrapper = createWrapper({
conflictedPackages: mockConflictResults
})
beforeEach(() => {
mockConflictData.value = mockConflictResults
})
// Expand conflicts panel
const conflictsHeader = wrapper.find('.w-full.h-8.flex.items-center')
it('should display individual conflict details excluding import_failed', async () => {
const wrapper = createWrapper()
// Expand conflicts panel (second header)
const conflictsHeader = wrapper.findAll(
'.w-full.h-8.flex.items-center'
)[1]
await conflictsHeader.trigger('click')
// Should display conflict messages
// Should display conflict messages (excluding import_failed)
const conflictItems = wrapper.findAll('.conflict-list-item')
expect(conflictItems).toHaveLength(3) // 2 from Package1 + 1 from Package2
})
it('should display package names in extensions list', async () => {
const wrapper = createWrapper({
conflictedPackages: mockConflictResults
})
it('should display import failed packages separately', async () => {
const wrapper = createWrapper()
// Expand extensions panel
// Expand import failed panel (first header)
const importFailedHeader = wrapper.findAll(
'.w-full.h-8.flex.items-center'
)[0]
await importFailedHeader.trigger('click')
// Should display only import failed package
const importFailedItems = wrapper.findAll('.conflict-list-item')
expect(importFailedItems).toHaveLength(1)
expect(importFailedItems[0].text()).toContain('Test Package 3')
})
it('should display all package names in extensions list', async () => {
const wrapper = createWrapper()
// Expand extensions panel (third header)
const extensionsHeader = wrapper.findAll(
'.w-full.h-8.flex.items-center'
)[1]
)[2]
await extensionsHeader.trigger('click')
// Should display package names
// Should display all package names
expect(wrapper.text()).toContain('Test Package 1')
expect(wrapper.text()).toContain('Test Package 2')
})
})
describe('conflict message generation', () => {
it('should generate appropriate conflict messages', () => {
// Mock translation function for testing
const mockT = vi.fn((key: string, params?: Record<string, any>) => {
const translations: Record<string, string> = {
'manager.conflicts.conflictMessages.os': `OS conflict: ${params?.current} vs ${params?.required}`,
'manager.conflicts.conflictMessages.accelerator': `Accelerator conflict: ${params?.current} vs ${params?.required}`,
'manager.conflicts.conflictMessages.banned': 'This package is banned'
}
return translations[key] || key
})
// Test the getConflictMessage utility function
const osConflict = mockConflictResults[0].conflicts[0]
const acceleratorConflict = mockConflictResults[0].conflicts[1]
const bannedConflict = mockConflictResults[1].conflicts[0]
const osMessage = getConflictMessage(osConflict, mockT)
const acceleratorMessage = getConflictMessage(acceleratorConflict, mockT)
const bannedMessage = getConflictMessage(bannedConflict, mockT)
expect(osMessage).toContain('OS conflict')
expect(acceleratorMessage).toContain('Accelerator conflict')
expect(bannedMessage).toContain('banned')
expect(wrapper.text()).toContain('Test Package 3')
})
})
describe('empty states', () => {
it('should handle empty conflicts gracefully', () => {
const wrapper = createWrapper({
conflicts: [],
conflictedPackages: []
})
expect(wrapper.text()).toContain('0')
expect(wrapper.text()).toContain('Conflicts')
expect(wrapper.text()).toContain('Extensions at Risk')
})
it('should handle undefined props gracefully', () => {
mockConflictData.value = []
const wrapper = createWrapper()
expect(wrapper.text()).toContain('0')
expect(wrapper.text()).toContain('Conflicts')
expect(wrapper.text()).toContain('Extensions at Risk')
// Import failed section should not be visible when there are no import failures
expect(wrapper.text()).not.toContain('Import Failed Extensions')
})
it('should handle conflicts without import_failed', () => {
// Only set packages without import_failed conflicts
mockConflictData.value = [mockConflictResults[0], mockConflictResults[1]]
const wrapper = createWrapper()
expect(wrapper.text()).toContain('3') // conflicts count
expect(wrapper.text()).toContain('2') // extensions count
// Import failed section should not be visible
expect(wrapper.text()).not.toContain('Import Failed Extensions')
})
})
describe('scrolling behavior', () => {
it('should apply scrollbar styles to conflict lists', async () => {
const wrapper = createWrapper({
conflictedPackages: mockConflictResults
})
it('should apply scrollbar styles to all expandable lists', async () => {
mockConflictData.value = mockConflictResults
const wrapper = createWrapper()
// Expand conflicts panel
const conflictsHeader = wrapper.find('.w-full.h-8.flex.items-center')
await conflictsHeader.trigger('click')
// Test all three panels
const headers = wrapper.findAll('.w-full.h-8.flex.items-center')
// Check for scrollable container with proper classes
const scrollableContainer = wrapper.find(
'[class*="max-h-"][class*="overflow-y-auto"][class*="scrollbar-hide"]'
)
expect(scrollableContainer.exists()).toBe(true)
for (let i = 0; i < headers.length; i++) {
await headers[i].trigger('click')
// Check for scrollable container with proper classes
const scrollableContainer = wrapper.find(
'[class*="max-h-"][class*="overflow-y-auto"][class*="scrollbar-hide"]'
)
expect(scrollableContainer.exists()).toBe(true)
// Close the panel for next iteration
await headers[i].trigger('click')
}
})
})
describe('accessibility', () => {
it('should have proper button roles and labels', () => {
const wrapper = createWrapper({
conflictedPackages: mockConflictResults
})
mockConflictData.value = mockConflictResults
const wrapper = createWrapper()
const buttons = wrapper.findAllComponents(Button)
expect(buttons.length).toBeGreaterThan(0)
expect(buttons.length).toBe(3) // 3 chevron buttons
// Check chevron buttons have icons
buttons.forEach((button) => {
expect(button.props('icon')).toBeDefined()
expect(button.props('icon')).toMatch(/pi-chevron-(right|down)/)
})
})
it('should have clickable panel headers', () => {
const wrapper = createWrapper({
conflictedPackages: mockConflictResults
})
mockConflictData.value = mockConflictResults
const wrapper = createWrapper()
const headers = wrapper.findAll('.w-full.h-8.flex.items-center')
expect(headers).toHaveLength(2) // conflicts and extensions headers
expect(headers).toHaveLength(3) // import failed, conflicts and extensions headers
headers.forEach((header) => {
expect(header.element.tagName).toBe('DIV')
@@ -343,14 +429,27 @@ describe('NodeConflictDialogContent', () => {
})
})
describe('props handling', () => {
it('should emit dismiss event when needed', () => {
const wrapper = createWrapper({
conflictedPackages: mockConflictResults
})
describe('lodash optimization', () => {
it('should efficiently filter conflicts using lodash', () => {
mockConflictData.value = mockConflictResults
const wrapper = createWrapper()
// Component now uses emit pattern instead of callback props
expect(wrapper.emitted('dismiss')).toBeUndefined()
// Verify that import_failed conflicts are filtered out from main conflicts
const vm = wrapper.vm as any
expect(vm.allConflictDetails).toHaveLength(3) // Should not include import_failed
expect(
vm.allConflictDetails.every((c: any) => c.type !== 'import_failed')
).toBe(true)
})
it('should efficiently extract import failed packages using lodash', () => {
mockConflictData.value = mockConflictResults
const wrapper = createWrapper()
// Verify that only import_failed packages are extracted
const vm = wrapper.vm as any
expect(vm.importFailedConflicts).toHaveLength(1)
expect(vm.importFailedConflicts[0]).toBe('Test Package 3')
})
})
})

View File

@@ -0,0 +1,198 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import { useImportFailedDetection } from '@/composables/useImportFailedDetection'
import * as dialogService from '@/services/dialogService'
import * as comfyManagerStore from '@/stores/comfyManagerStore'
import * as conflictDetectionStore from '@/stores/conflictDetectionStore'
// Mock the stores and services
vi.mock('@/stores/comfyManagerStore')
vi.mock('@/stores/conflictDetectionStore')
vi.mock('@/services/dialogService')
vi.mock('vue-i18n', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-i18n')>()
return {
...actual,
useI18n: () => ({
t: vi.fn((key: string) => key)
})
}
})
describe('useImportFailedDetection', () => {
let mockComfyManagerStore: any
let mockConflictDetectionStore: any
let mockDialogService: any
beforeEach(() => {
setActivePinia(createPinia())
mockComfyManagerStore = {
isPackInstalled: vi.fn()
}
mockConflictDetectionStore = {
getConflictsForPackageByID: vi.fn()
}
mockDialogService = {
showErrorDialog: vi.fn()
}
vi.mocked(comfyManagerStore.useComfyManagerStore).mockReturnValue(
mockComfyManagerStore
)
vi.mocked(conflictDetectionStore.useConflictDetectionStore).mockReturnValue(
mockConflictDetectionStore
)
vi.mocked(dialogService.useDialogService).mockReturnValue(mockDialogService)
})
it('should return false for importFailed when package is not installed', () => {
mockComfyManagerStore.isPackInstalled.mockReturnValue(false)
const { importFailed } = useImportFailedDetection('test-package')
expect(importFailed.value).toBe(false)
})
it('should return false for importFailed when no conflicts exist', () => {
mockComfyManagerStore.isPackInstalled.mockReturnValue(true)
mockConflictDetectionStore.getConflictsForPackageByID.mockReturnValue(null)
const { importFailed } = useImportFailedDetection('test-package')
expect(importFailed.value).toBe(false)
})
it('should return false for importFailed when conflicts exist but no import_failed type', () => {
mockComfyManagerStore.isPackInstalled.mockReturnValue(true)
mockConflictDetectionStore.getConflictsForPackageByID.mockReturnValue({
package_id: 'test-package',
conflicts: [
{ type: 'dependency', message: 'Dependency conflict' },
{ type: 'version', message: 'Version conflict' }
]
})
const { importFailed } = useImportFailedDetection('test-package')
expect(importFailed.value).toBe(false)
})
it('should return true for importFailed when import_failed conflicts exist', () => {
mockComfyManagerStore.isPackInstalled.mockReturnValue(true)
mockConflictDetectionStore.getConflictsForPackageByID.mockReturnValue({
package_id: 'test-package',
conflicts: [
{
type: 'import_failed',
message: 'Import failed',
required_value: 'Error details'
},
{ type: 'dependency', message: 'Dependency conflict' }
]
})
const { importFailed } = useImportFailedDetection('test-package')
expect(importFailed.value).toBe(true)
})
it('should work with computed ref packageId', () => {
const packageId = ref('test-package')
mockComfyManagerStore.isPackInstalled.mockReturnValue(true)
mockConflictDetectionStore.getConflictsForPackageByID.mockReturnValue({
package_id: 'test-package',
conflicts: [
{
type: 'import_failed',
message: 'Import failed',
required_value: 'Error details'
}
]
})
const { importFailed } = useImportFailedDetection(
computed(() => packageId.value)
)
expect(importFailed.value).toBe(true)
// Change packageId
packageId.value = 'another-package'
mockConflictDetectionStore.getConflictsForPackageByID.mockReturnValue(null)
expect(importFailed.value).toBe(false)
})
it('should return correct importFailedInfo', () => {
const importFailedConflicts = [
{
type: 'import_failed',
message: 'Import failed 1',
required_value: 'Error 1'
},
{
type: 'import_failed',
message: 'Import failed 2',
required_value: 'Error 2'
}
]
mockComfyManagerStore.isPackInstalled.mockReturnValue(true)
mockConflictDetectionStore.getConflictsForPackageByID.mockReturnValue({
package_id: 'test-package',
conflicts: [
...importFailedConflicts,
{ type: 'dependency', message: 'Dependency conflict' }
]
})
const { importFailedInfo } = useImportFailedDetection('test-package')
expect(importFailedInfo.value).toEqual(importFailedConflicts)
})
it('should show error dialog when showImportFailedDialog is called', () => {
const importFailedConflicts = [
{
type: 'import_failed',
message: 'Import failed',
required_value: 'Error details'
}
]
mockComfyManagerStore.isPackInstalled.mockReturnValue(true)
mockConflictDetectionStore.getConflictsForPackageByID.mockReturnValue({
package_id: 'test-package',
conflicts: importFailedConflicts
})
const { showImportFailedDialog } = useImportFailedDetection('test-package')
showImportFailedDialog()
expect(mockDialogService.showErrorDialog).toHaveBeenCalledWith(
expect.any(Error),
{
title: 'manager.failedToInstall',
reportType: 'importFailedError'
}
)
})
it('should handle null packageId', () => {
const { importFailed, isInstalled } = useImportFailedDetection(null)
expect(importFailed.value).toBe(false)
expect(isInstalled.value).toBe(false)
})
it('should handle undefined packageId', () => {
const { importFailed, isInstalled } = useImportFailedDetection(undefined)
expect(importFailed.value).toBe(false)
expect(isInstalled.value).toBe(false)
})
})