Files
ComfyUI_frontend/tests-ui/tests/renderer/extensions/vueNodes/components/ImagePreview.spec.ts
Christian Byrne c145fd9df1 Show node output images on Vue Nodes (#5456)
* add image outputs on Vue nodes

* add unit tests and update cursor pointer

* use testing pinia

* properly mock i18n in component test

* get node via current graph

* use subgraph ID from node creation

* add better error handling for downloadFile util

* refactor: simplify image preview component architecture

- Replace awkward composable pattern with standard Vue component state
- Fix reactivity issues where images didn't update on new outputs
- Add proper subgraph-aware node resolution using NodeLocatorId
- Enhance accessibility with keyboard navigation and ARIA labels
- Add comprehensive error handling and loading states
- Include PrimeVue Skeleton for better loading UX
- Remove unused composable and test files

The image preview now properly updates when new outputs are generated
and follows standard Vue reactivity patterns.

* resolve merge conflict with main

- Keep both subgraphId field and hasErrors field from main
- No conflicts in other files (LGraphNode.vue and main.json merged cleanly)

* Fix LGraphNode test by adding proper Pinia testing setup

Added createTestingPinia and i18n configuration following the pattern
from working ImagePreview tests. Resolves test failures due to missing
Pinia store dependencies. All 6 tests now pass successfully.
2025-09-13 23:34:39 -07:00

278 lines
8.4 KiB
TypeScript

import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import ImagePreview from '@/renderer/extensions/vueNodes/components/ImagePreview.vue'
// Mock downloadFile to avoid DOM errors
vi.mock('@/base/common/downloadUtil', () => ({
downloadFile: vi.fn()
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
editOrMaskImage: 'Edit or mask image',
downloadImage: 'Download image',
removeImage: 'Remove image',
viewImageOfTotal: 'View image {index} of {total}',
imagePreview:
'Image preview - Use arrow keys to navigate between images',
errorLoadingImage: 'Error loading image',
failedToDownloadImage: 'Failed to download image',
calculatingDimensions: 'Calculating dimensions',
imageFailedToLoad: 'Image failed to load',
loading: 'Loading'
}
}
}
})
describe('ImagePreview', () => {
const defaultProps = {
imageUrls: [
'/api/view?filename=test1.png&type=output',
'/api/view?filename=test2.png&type=output'
]
}
const mountImagePreview = (props = {}) => {
return mount(ImagePreview, {
props: { ...defaultProps, ...props },
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn
}),
i18n
],
stubs: {
'i-lucide:venetian-mask': true,
'i-lucide:download': true,
'i-lucide:x': true,
'i-lucide:image-off': true,
Skeleton: true
}
}
})
}
it('renders image preview when imageUrls provided', () => {
const wrapper = mountImagePreview()
expect(wrapper.find('.image-preview').exists()).toBe(true)
expect(wrapper.find('img').exists()).toBe(true)
expect(wrapper.find('img').attributes('src')).toBe(
defaultProps.imageUrls[0]
)
})
it('does not render when no imageUrls provided', () => {
const wrapper = mountImagePreview({ imageUrls: [] })
expect(wrapper.find('.image-preview').exists()).toBe(false)
})
it('displays calculating dimensions text initially', () => {
const wrapper = mountImagePreview()
expect(wrapper.text()).toContain('Calculating dimensions')
})
it('shows navigation dots for multiple images', () => {
const wrapper = mountImagePreview()
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
expect(navigationDots).toHaveLength(2)
})
it('does not show navigation dots for single image', () => {
const wrapper = mountImagePreview({
imageUrls: [defaultProps.imageUrls[0]]
})
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
expect(navigationDots).toHaveLength(0)
})
it('shows action buttons on hover', async () => {
const wrapper = mountImagePreview()
// Initially buttons should not be visible
expect(wrapper.find('.actions').exists()).toBe(false)
// Trigger hover
await wrapper.trigger('mouseenter')
await nextTick()
// Action buttons should now be visible
expect(wrapper.find('.actions').exists()).toBe(true)
expect(wrapper.findAll('.action-btn')).toHaveLength(2) // download, remove (no mask for multiple images)
})
it('hides action buttons when not hovering', async () => {
const wrapper = mountImagePreview()
// Trigger hover
await wrapper.trigger('mouseenter')
await nextTick()
expect(wrapper.find('.actions').exists()).toBe(true)
// Trigger mouse leave
await wrapper.trigger('mouseleave')
await nextTick()
expect(wrapper.find('.actions').exists()).toBe(false)
})
it('shows mask/edit button only for single images', async () => {
// Multiple images - should not show mask button
const multipleImagesWrapper = mountImagePreview()
await multipleImagesWrapper.trigger('mouseenter')
await nextTick()
const maskButtonMultiple = multipleImagesWrapper.find(
'[aria-label="Edit or mask image"]'
)
expect(maskButtonMultiple.exists()).toBe(false)
// Single image - should show mask button
const singleImageWrapper = mountImagePreview({
imageUrls: [defaultProps.imageUrls[0]]
})
await singleImageWrapper.trigger('mouseenter')
await nextTick()
const maskButtonSingle = singleImageWrapper.find(
'[aria-label="Edit or mask image"]'
)
expect(maskButtonSingle.exists()).toBe(true)
})
it('handles action button clicks', async () => {
const wrapper = mountImagePreview({
imageUrls: [defaultProps.imageUrls[0]]
})
await wrapper.trigger('mouseenter')
await nextTick()
// Test Edit/Mask button - just verify it can be clicked without errors
const editButton = wrapper.find('[aria-label="Edit or mask image"]')
expect(editButton.exists()).toBe(true)
await editButton.trigger('click')
// Test Remove button - just verify it can be clicked without errors
const removeButton = wrapper.find('[aria-label="Remove image"]')
expect(removeButton.exists()).toBe(true)
await removeButton.trigger('click')
})
it('handles download button click', async () => {
const wrapper = mountImagePreview({
imageUrls: [defaultProps.imageUrls[0]]
})
await wrapper.trigger('mouseenter')
await nextTick()
// Test Download button
const downloadButton = wrapper.find('[aria-label="Download image"]')
expect(downloadButton.exists()).toBe(true)
await downloadButton.trigger('click')
// Verify the mocked downloadFile was called
expect(downloadFile).toHaveBeenCalledWith(defaultProps.imageUrls[0])
})
it('switches images when navigation dots are clicked', async () => {
const wrapper = mountImagePreview()
// Initially shows first image
expect(wrapper.find('img').attributes('src')).toBe(
defaultProps.imageUrls[0]
)
// Click second navigation dot
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
await navigationDots[1].trigger('click')
await nextTick()
// After clicking, component shows loading state (Skeleton), not img
expect(wrapper.find('skeleton-stub').exists()).toBe(true)
expect(wrapper.find('img').exists()).toBe(false)
// Simulate image load event to clear loading state
const component = wrapper.vm as any
component.isLoading = false
await nextTick()
// Now should show second image
const imgElement = wrapper.find('img')
expect(imgElement.exists()).toBe(true)
expect(imgElement.attributes('src')).toBe(defaultProps.imageUrls[1])
})
it('applies correct classes to navigation dots based on current image', async () => {
const wrapper = mountImagePreview()
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
// First dot should be active (has bg-white class)
expect(navigationDots[0].classes()).toContain('bg-white')
expect(navigationDots[1].classes()).toContain('bg-white/50')
// Switch to second image
await navigationDots[1].trigger('click')
await nextTick()
// Second dot should now be active
expect(navigationDots[0].classes()).toContain('bg-white/50')
expect(navigationDots[1].classes()).toContain('bg-white')
})
it('loads image without errors', async () => {
const wrapper = mountImagePreview()
const img = wrapper.find('img')
expect(img.exists()).toBe(true)
// Just verify the image element is properly set up
expect(img.attributes('src')).toBe(defaultProps.imageUrls[0])
})
it('has proper accessibility attributes', () => {
const wrapper = mountImagePreview()
const img = wrapper.find('img')
expect(img.attributes('alt')).toBe('Node output 1')
})
it('updates alt text when switching images', async () => {
const wrapper = mountImagePreview()
// Initially first image
expect(wrapper.find('img').attributes('alt')).toBe('Node output 1')
// Switch to second image
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
await navigationDots[1].trigger('click')
await nextTick()
// Simulate image load event to clear loading state
const component = wrapper.vm as any
component.isLoading = false
await nextTick()
// Alt text should update
const imgElement = wrapper.find('img')
expect(imgElement.exists()).toBe(true)
expect(imgElement.attributes('alt')).toBe('Node output 2')
})
})