[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
parent b67ace5195
commit b9c1962eea
17 changed files with 785 additions and 254 deletions

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)
})
})