Compare commits

...

3 Commits

Author SHA1 Message Date
Kelly Yang
3787e1fae9 test: migrate ImagePreview tests and fix flaky browser specs
- Use @testing-library/vue with user.keyboard; void fireEvent for no-floating-promises
- Add data-testid image-preview-root; src/types/vue-component.d.ts for *.vue modules
- E2E: doubleClick on canvas empty space; Escape before Workflow actions in enterBuilder
2026-04-14 21:02:32 -07:00
Kelly Yang
d8305398ae fix: mask editor context menu, clear-mask race, and preview UX
Open mask editor from image context menu with the target node instead of
relying on canvas selection (fixes Vue nodes E2E). Share clearing state
across useMaskEditor consumers, guard store reset when the dialog is open,
initialize canvas contexts before clearMask, and dedupe useCanvasTools via
createSharedComposable. Show Clear mask only when nodeId is present.
2026-04-14 21:02:32 -07:00
Kelly Yang
75295960a3 feat: clear mask from image preview 2026-04-14 21:02:32 -07:00
10 changed files with 421 additions and 214 deletions

View File

@@ -94,6 +94,8 @@ export class AppModeHelper {
/** Enter builder mode via the "Workflow actions" dropdown. */
async enterBuilder() {
await this.page.keyboard.press('Escape')
await this.comfyPage.nextFrame()
await this.page
.getByRole('button', { name: 'Workflow actions' })
.first()

View File

@@ -65,7 +65,10 @@ export class CanvasHelper {
}
async doubleClick(): Promise<void> {
await this.page.mouse.dblclick(10, 10, { delay: 5 })
await this.canvas.dblclick({
position: DefaultGraphPositions.emptySpaceClick,
delay: 5
})
await this.nextFrame()
}

View File

@@ -106,7 +106,6 @@
<button :class="textButtonClass" @click="onInvert">
{{ t('maskEditor.invert') }}
</button>
<button :class="textButtonClass" @click="onClear">
{{ t('maskEditor.clear') }}
</button>

View File

@@ -15,8 +15,14 @@ vi.mock('vue-i18n', async (importOriginal) => {
}
})
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({ execute: vi.fn() })
const mockOpenMaskEditor = vi.fn()
vi.mock('@/composables/maskeditor/useMaskEditor', () => ({
useMaskEditor: () => ({
openMaskEditor: mockOpenMaskEditor,
clearMask: vi.fn(),
isClearingMask: { value: false }
})
}))
function mockClipboard(clipboard: Partial<Clipboard> | undefined) {
@@ -101,6 +107,17 @@ describe('useImageMenuOptions', () => {
expect(copyIdx).toBeLessThan(pasteIdx)
expect(pasteIdx).toBeLessThan(saveIdx)
})
it('calls openMaskEditor with the node when Open in Mask Editor is chosen', () => {
const node = createImageNode()
const { getImageMenuOptions } = useImageMenuOptions()
const options = getImageMenuOptions(node)
const maskOption = options.find((o) => o.label === 'Open in Mask Editor')
maskOption!.action!()
expect(mockOpenMaskEditor).toHaveBeenCalledWith(node)
})
})
describe('pasteImage action', () => {

View File

@@ -1,8 +1,8 @@
import { useI18n } from 'vue-i18n'
import { downloadFile, openFileInNewTab } from '@/base/common/downloadUtil'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useCommandStore } from '@/stores/commandStore'
import type { MenuOption } from './useMoreOptionsMenu'
@@ -41,10 +41,10 @@ async function pasteClipboardImageToNode(node: LGraphNode): Promise<void> {
*/
export function useImageMenuOptions() {
const { t } = useI18n()
const maskEditor = useMaskEditor()
const openMaskEditor = () => {
const commandStore = useCommandStore()
void commandStore.execute('Comfy.MaskEditor.OpenMaskEditor')
const openMaskEditorForNode = (node: LGraphNode) => {
maskEditor.openMaskEditor(node)
}
const openImage = (node: LGraphNode) => {
@@ -118,7 +118,7 @@ export function useImageMenuOptions() {
options.push(
{
label: t('contextMenu.Open in Mask Editor'),
action: () => openMaskEditor()
action: () => openMaskEditorForNode(node)
},
{
label: t('contextMenu.Open Image'),

View File

@@ -1,9 +1,31 @@
import { createSharedComposable } from '@vueuse/core'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useDialogStore } from '@/stores/dialogStore'
import TopBarHeader from '@/components/maskeditor/dialog/TopBarHeader.vue'
import MaskEditorContent from '@/components/maskeditor/MaskEditorContent.vue'
import { useMaskEditorDataStore } from '@/stores/maskEditorDataStore'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { useMaskEditorLoader } from '@/composables/maskeditor/useMaskEditorLoader'
import { useMaskEditorSaver } from '@/composables/maskeditor/useMaskEditorSaver'
import { useCanvasTools } from '@/composables/maskeditor/useCanvasTools'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useI18n } from 'vue-i18n'
import { ref } from 'vue'
const useSharedCanvasTools = createSharedComposable(useCanvasTools)
const sharedIsClearingMask = ref(false)
export function useMaskEditor() {
const isClearingMask = sharedIsClearingMask
const toastStore = useToastStore()
const { t } = useI18n()
const dataStore = useMaskEditorDataStore()
const editorStore = useMaskEditorStore()
const loader = useMaskEditorLoader()
const saver = useMaskEditorSaver()
const canvasTools = useSharedCanvasTools()
const openMaskEditor = (node: LGraphNode) => {
if (!node) {
console.error('[MaskEditor] No node provided')
@@ -42,7 +64,93 @@ export function useMaskEditor() {
})
}
const clearMask = async (node: LGraphNode) => {
if (!node) {
return
}
if (isClearingMask.value) {
return
}
const dialogStore = useDialogStore()
if (dialogStore.isDialogOpen('global-mask-editor')) {
console.warn(
'[MaskEditor] Cannot clear mask while the mask editor is open'
)
toastStore.add({
severity: 'warn',
summary: t('maskEditor.cannotClearWhenOpenSummary'),
detail: t('maskEditor.cannotClearWhenOpenDetail'),
life: 3000
})
return
}
isClearingMask.value = true
try {
await loader.loadFromNode(node)
if (!dataStore.inputData) throw new Error('Failed to load image data')
if (!editorStore.maskCanvas) {
const { image } = dataStore.inputData.baseLayer
const width = image.naturalWidth || image.width || 1
const height = image.naturalHeight || image.height || 1
const imgCanvas = document.createElement('canvas')
imgCanvas.width = width
imgCanvas.height = height
const imgCtx = imgCanvas.getContext('2d')
if (!imgCtx) {
throw new Error('Failed to get image canvas context')
}
imgCtx.drawImage(image, 0, 0)
const maskCanvas = document.createElement('canvas')
maskCanvas.width = width
maskCanvas.height = height
const rgbCanvas = document.createElement('canvas')
rgbCanvas.width = width
rgbCanvas.height = height
editorStore.imgCanvas = imgCanvas
editorStore.maskCanvas = maskCanvas
editorStore.rgbCanvas = rgbCanvas
const maskCtx = maskCanvas.getContext('2d', {
willReadFrequently: true
})
const rgbCtx = rgbCanvas.getContext('2d', {
willReadFrequently: true
})
if (!maskCtx || !rgbCtx) {
throw new Error('Failed to get mask or RGB canvas context')
}
editorStore.maskCtx = maskCtx
editorStore.rgbCtx = rgbCtx
editorStore.imgCtx = imgCtx
}
canvasTools.clearMask()
await saver.save()
} finally {
const dialogOpen = dialogStore.isDialogOpen('global-mask-editor')
if (!dialogOpen) {
editorStore.imgCanvas = null
editorStore.maskCanvas = null
editorStore.rgbCanvas = null
dataStore.reset()
editorStore.resetState()
}
isClearingMask.value = false
}
}
return {
openMaskEditor
isClearingMask,
openMaskEditor,
clearMask
}
}

View File

@@ -12,6 +12,7 @@
"downloadVideo": "Download video",
"downloadAudio": "Download audio",
"editOrMaskImage": "Edit or mask image",
"clearMask": "Clear mask",
"editImage": "Edit image",
"decrement": "Decrement",
"deleteImage": "Delete image",
@@ -1194,6 +1195,8 @@
"maskEditor": {
"title": "Mask Editor",
"openMaskEditor": "Open in Mask Editor",
"cannotClearWhenOpenSummary": "Warning",
"cannotClearWhenOpenDetail": "Please close the mask editor before clearing masks.",
"invert": "Invert",
"clear": "Clear",
"undo": "Undo",
@@ -1233,7 +1236,9 @@
"baseLayerPreview": "Base layer preview",
"black": "Black",
"white": "White",
"negative": "Negative"
"negative": "Negative",
"clearMaskError": "Clear Mask Error",
"clearMaskFailed": "Failed to clear mask. Please try again."
},
"commands": {
"runWorkflow": "Run workflow",

View File

@@ -1,16 +1,39 @@
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
/* eslint-disable testing-library/prefer-user-event */
import { createTestingPinia } from '@pinia/testing'
import { render, screen, fireEvent } from '@testing-library/vue'
import {
cleanup,
fireEvent,
render,
screen,
waitFor
} from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
const mockClearMask = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
vi.mock('@/composables/maskeditor/useMaskEditor', async () => {
const { ref } = await import('vue')
return {
useMaskEditor: () => ({
openMaskEditor: vi.fn(),
clearMask: mockClearMask,
isClearingMask: ref(false)
})
}
})
vi.mock('@/utils/litegraphUtil', () => ({
resolveNode: vi.fn(() => undefined)
}))
import { downloadFile } from '@/base/common/downloadUtil'
import ImagePreview from '@/renderer/extensions/vueNodes/components/ImagePreview.vue'
import { resolveNode } from '@/utils/litegraphUtil'
// Mock downloadFile to avoid DOM errors
vi.mock('@/base/common/downloadUtil', () => ({
downloadFile: vi.fn()
}))
@@ -35,55 +58,67 @@ const i18n = createI18n({
unknownFile: 'Unknown file',
loading: 'Loading',
viewGrid: 'Grid view',
galleryThumbnail: 'Gallery thumbnail'
galleryThumbnail: 'Gallery thumbnail',
clearMask: 'Clear mask',
imageGallery: 'image gallery'
},
maskEditor: {
clearMaskError: 'Clear Mask Error',
clearMaskFailed: 'Failed to clear mask. Please try again.'
}
}
}
})
describe('ImagePreview', () => {
const defaultProps = {
imageUrls: [
'/api/view?filename=test1.png&type=output',
'/api/view?filename=test2.png&type=output'
]
}
const defaultProps = {
imageUrls: [
'/api/view?filename=test1.png&type=output',
'/api/view?filename=test2.png&type=output'
]
}
function renderImagePreview(props = {}) {
return render(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
}
function renderImagePreview(props: Record<string, unknown> = {}) {
return render(ImagePreview, {
props: { ...defaultProps, ...props },
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
stubs: {
'i-comfy:mask': true,
'i-lucide:venetian-mask': true,
'i-lucide:download': true,
'i-lucide:x': true,
'i-lucide:image-off': true,
Skeleton: true
}
})
}
}
})
}
async function switchToGallery(user: ReturnType<typeof userEvent.setup>) {
const thumbnails = screen.getAllByRole('button', { name: /^View image/ })
await user.click(thumbnails[0])
await nextTick()
}
async function switchToGallery(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('button', { name: 'View image 1 of 2' }))
await nextTick()
}
function viewImageNavButtons() {
return screen.getAllByRole('button', {
name: /View image \d+ of \d+/
})
}
describe('ImagePreview', () => {
afterEach(() => {
cleanup()
})
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(resolveNode).mockReturnValue(undefined)
})
it('does not render when no imageUrls provided', () => {
const { container } = renderImagePreview({ imageUrls: [] })
renderImagePreview({ imageUrls: [] })
expect(container.querySelector('.image-preview')).not.toBeInTheDocument()
expect(screen.queryByTestId('image-preview-root')).not.toBeInTheDocument()
})
it('displays calculating dimensions text in gallery mode', async () => {
@@ -91,18 +126,15 @@ describe('ImagePreview', () => {
imageUrls: [defaultProps.imageUrls[0]]
})
screen.getByText('Calculating dimensions')
expect(screen.getByText('Calculating dimensions')).toBeInTheDocument()
})
it('shows navigation dots for multiple images in gallery mode', async () => {
renderImagePreview()
const user = userEvent.setup()
renderImagePreview()
await switchToGallery(user)
const navigationDots = screen.getAllByRole('button', {
name: /View image/
})
expect(navigationDots).toHaveLength(2)
expect(viewImageNavButtons()).toHaveLength(2)
})
it('does not show navigation dots for single image', () => {
@@ -110,85 +142,92 @@ describe('ImagePreview', () => {
imageUrls: [defaultProps.imageUrls[0]]
})
const navigationDots = screen.queryAllByRole('button', {
name: /View image/
})
expect(navigationDots).toHaveLength(0)
expect(
screen.queryByRole('button', { name: /View image \d+ of \d+/ })
).not.toBeInTheDocument()
})
it('does not show mask/edit button for multiple images in gallery mode', async () => {
renderImagePreview()
it('shows mask/edit button only for single images', async () => {
const user = userEvent.setup()
renderImagePreview()
await switchToGallery(user)
expect(
screen.queryByRole('button', { name: 'Edit or mask image' })
).not.toBeInTheDocument()
})
it('shows mask/edit button for single images', () => {
cleanup()
renderImagePreview({
imageUrls: [defaultProps.imageUrls[0]]
})
screen.getByRole('button', { name: 'Edit or mask image' })
expect(
screen.getByRole('button', { name: 'Edit or mask image' })
).toBeInTheDocument()
})
it('handles download button click', async () => {
const user = userEvent.setup()
renderImagePreview({
imageUrls: [defaultProps.imageUrls[0]]
})
const user = userEvent.setup()
const downloadButton = screen.getByRole('button', {
name: 'Download image'
})
await user.click(downloadButton)
await user.click(screen.getByRole('button', { name: 'Download image' }))
expect(downloadFile).toHaveBeenCalledWith(defaultProps.imageUrls[0])
})
it('switches images when navigation dots are clicked', async () => {
renderImagePreview()
it('calls clearMask with resolved node when clear mask is clicked', async () => {
const stubNode = { id: 99, imgs: [] } as unknown as LGraphNode
vi.mocked(resolveNode).mockReturnValue(stubNode)
const user = userEvent.setup()
renderImagePreview({
imageUrls: [defaultProps.imageUrls[0]],
nodeId: '99'
})
await user.click(screen.getByRole('button', { name: 'Clear mask' }))
await waitFor(() => {
expect(mockClearMask).toHaveBeenCalledTimes(1)
})
expect(mockClearMask).toHaveBeenCalledWith(stubNode)
})
it('switches images when navigation dots are clicked', async () => {
const user = userEvent.setup()
renderImagePreview()
await switchToGallery(user)
// Initially shows first image
expect(screen.getByRole('img')).toHaveAttribute(
expect(screen.getByTestId('main-image')).toHaveAttribute(
'src',
defaultProps.imageUrls[0]
)
// Click second navigation dot
const navigationDots = screen.getAllByRole('button', {
name: /View image/
})
const navigationDots = viewImageNavButtons()
await user.click(navigationDots[1])
await nextTick()
expect(screen.getByRole('img')).toHaveAttribute(
expect(screen.getByTestId('main-image')).toHaveAttribute(
'src',
defaultProps.imageUrls[1]
)
})
it('marks active navigation dot with aria-current', async () => {
renderImagePreview()
const user = userEvent.setup()
renderImagePreview()
await switchToGallery(user)
const navigationDots = screen.getAllByRole('button', {
name: /View image/
})
const navigationDots = viewImageNavButtons()
// First dot should be active
expect(navigationDots[0]).toHaveAttribute('aria-current', 'true')
expect(navigationDots[1]).not.toHaveAttribute('aria-current')
await user.click(navigationDots[1])
await nextTick()
// Second dot should now be active
expect(navigationDots[0]).not.toHaveAttribute('aria-current')
expect(navigationDots[1]).toHaveAttribute('aria-current', 'true')
})
@@ -198,34 +237,36 @@ describe('ImagePreview', () => {
imageUrls: [defaultProps.imageUrls[0]]
})
expect(screen.getByRole('img')).toHaveAttribute('alt', 'View image 1 of 1')
expect(screen.getByRole('img', { name: 'View image 1 of 1' })).toBeTruthy()
})
it('updates alt text when switching images', async () => {
renderImagePreview()
const user = userEvent.setup()
renderImagePreview()
await switchToGallery(user)
expect(screen.getByRole('img')).toHaveAttribute('alt', 'View image 1 of 2')
expect(screen.getByTestId('main-image')).toHaveAttribute(
'alt',
'View image 1 of 2'
)
// Switch to second image
const navigationDots = screen.getAllByRole('button', {
name: /View image/
})
const navigationDots = viewImageNavButtons()
await user.click(navigationDots[1])
await nextTick()
expect(screen.getByRole('img')).toHaveAttribute('alt', 'View image 2 of 2')
expect(screen.getByTestId('main-image')).toHaveAttribute(
'alt',
'View image 2 of 2'
)
})
describe('keyboard navigation', () => {
it('navigates to next image with ArrowRight', async () => {
const { container } = renderImagePreview()
const user = userEvent.setup()
renderImagePreview()
await switchToGallery(user)
const preview = container.querySelector('.image-preview') as HTMLElement
await fireEvent.keyDown(preview, { key: 'ArrowRight' })
await user.keyboard('{ArrowRight}')
await nextTick()
expect(screen.getByTestId('main-image')).toHaveAttribute(
@@ -235,15 +276,13 @@ describe('ImagePreview', () => {
})
it('navigates to previous image with ArrowLeft', async () => {
const { container } = renderImagePreview()
const user = userEvent.setup()
renderImagePreview()
await switchToGallery(user)
const preview = container.querySelector('.image-preview') as HTMLElement
await fireEvent.keyDown(preview, { key: 'ArrowRight' })
await user.keyboard('{ArrowRight}')
await nextTick()
await fireEvent.keyDown(preview, { key: 'ArrowLeft' })
await user.keyboard('{ArrowLeft}')
await nextTick()
expect(screen.getByTestId('main-image')).toHaveAttribute(
@@ -253,14 +292,13 @@ describe('ImagePreview', () => {
})
it('wraps around from last to first with ArrowRight', async () => {
const { container } = renderImagePreview()
const user = userEvent.setup()
renderImagePreview()
await switchToGallery(user)
const preview = container.querySelector('.image-preview') as HTMLElement
await fireEvent.keyDown(preview, { key: 'ArrowRight' })
await user.keyboard('{ArrowRight}')
await nextTick()
await fireEvent.keyDown(preview, { key: 'ArrowRight' })
await user.keyboard('{ArrowRight}')
await nextTick()
expect(screen.getByTestId('main-image')).toHaveAttribute(
@@ -270,12 +308,11 @@ describe('ImagePreview', () => {
})
it('wraps around from first to last with ArrowLeft', async () => {
const { container } = renderImagePreview()
const user = userEvent.setup()
renderImagePreview()
await switchToGallery(user)
const preview = container.querySelector('.image-preview') as HTMLElement
await fireEvent.keyDown(preview, { key: 'ArrowLeft' })
await user.keyboard('{ArrowLeft}')
await nextTick()
expect(screen.getByTestId('main-image')).toHaveAttribute(
@@ -285,15 +322,13 @@ describe('ImagePreview', () => {
})
it('navigates to first image with Home', async () => {
const { container } = renderImagePreview()
const user = userEvent.setup()
renderImagePreview()
await switchToGallery(user)
const preview = container.querySelector('.image-preview') as HTMLElement
await fireEvent.keyDown(preview, { key: 'ArrowRight' })
await user.keyboard('{ArrowRight}')
await nextTick()
await fireEvent.keyDown(preview, { key: 'Home' })
await user.keyboard('{Home}')
await nextTick()
expect(screen.getByTestId('main-image')).toHaveAttribute(
@@ -303,12 +338,11 @@ describe('ImagePreview', () => {
})
it('navigates to last image with End', async () => {
const { container } = renderImagePreview()
const user = userEvent.setup()
renderImagePreview()
await switchToGallery(user)
const preview = container.querySelector('.image-preview') as HTMLElement
await fireEvent.keyDown(preview, { key: 'End' })
await user.keyboard('{End}')
await nextTick()
expect(screen.getByTestId('main-image')).toHaveAttribute(
@@ -318,31 +352,38 @@ describe('ImagePreview', () => {
})
it('ignores arrow keys in grid mode', async () => {
const { container } = renderImagePreview()
renderImagePreview()
const gridThumbnails = screen.getAllByRole('button', {
name: /^View image/
})
expect(gridThumbnails).toHaveLength(2)
expect(
screen.getAllByRole('button', { name: /View image \d+ of 2/ })
).toHaveLength(2)
const preview = container.querySelector('.image-preview') as HTMLElement
await fireEvent.keyDown(preview, { key: 'ArrowRight' })
const root = screen.getByTestId('image-preview-root')
// Thumbnail click opens gallery; grid-mode arrows are a no-op on the shell without moving focus into it.
// eslint-disable-next-line testing-library/prefer-user-event -- need keydown on root while view stays grid
void fireEvent.keyDown(root, { key: 'ArrowRight' })
await nextTick()
expect(screen.queryByRole('region')).not.toBeInTheDocument()
})
it('ignores arrow keys for single image', async () => {
const { container } = renderImagePreview({
const user = userEvent.setup()
renderImagePreview({
imageUrls: [defaultProps.imageUrls[0]]
})
const initialSrc = screen.getByRole('img').getAttribute('src')
const preview = container.querySelector('.image-preview') as HTMLElement
await fireEvent.keyDown(preview, { key: 'ArrowRight' })
const img = screen.getByRole('img')
const initialSrc = img.getAttribute('src')
await user.click(
screen.getByRole('region', {
name: 'Image preview - Use arrow keys to navigate between images'
})
)
await user.keyboard('{ArrowRight}')
await nextTick()
expect(screen.getByRole('img')).toHaveAttribute('src', initialSrc!)
expect(screen.getByRole('img').getAttribute('src')).toBe(initialSrc)
})
})
@@ -350,10 +391,9 @@ describe('ImagePreview', () => {
it('defaults to grid mode for multiple images', () => {
renderImagePreview()
const gridThumbnails = screen.getAllByRole('button', {
name: /^View image/
})
expect(gridThumbnails).toHaveLength(2)
expect(
screen.getAllByRole('button', { name: /View image \d+ of 2/ })
).toHaveLength(2)
})
it('defaults to gallery mode for single image', () => {
@@ -361,60 +401,65 @@ describe('ImagePreview', () => {
imageUrls: [defaultProps.imageUrls[0]]
})
screen.getByRole('region')
const gridThumbnails = screen.queryAllByRole('button', {
name: /^View image/
})
expect(gridThumbnails).toHaveLength(0)
expect(
screen.getByRole('region', {
name: 'Image preview - Use arrow keys to navigate between images'
})
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /View image \d+ of 1/ })
).not.toBeInTheDocument()
})
it('switches to gallery mode when grid thumbnail is clicked', async () => {
renderImagePreview()
const user = userEvent.setup()
renderImagePreview()
const thumbnails = screen.getAllByRole('button', {
name: /^View image/
name: /View image \d+ of 2/
})
await user.click(thumbnails[1])
await nextTick()
const mainImg = screen.getByTestId('main-image')
expect(mainImg).toBeInTheDocument()
expect(mainImg).toHaveAttribute('src', defaultProps.imageUrls[1])
})
it('shows back-to-grid button next to navigation dots', async () => {
renderImagePreview()
const user = userEvent.setup()
renderImagePreview()
await switchToGallery(user)
const gridButtons = screen.getAllByRole('button', { name: 'Grid view' })
expect(gridButtons.length).toBeGreaterThanOrEqual(1)
expect(
screen.getAllByRole('button', { name: 'Grid view' })[0]
).toBeInTheDocument()
})
it('switches back to grid mode via back-to-grid button', async () => {
renderImagePreview()
const user = userEvent.setup()
renderImagePreview()
await switchToGallery(user)
const gridButtons = screen.getAllByRole('button', { name: 'Grid view' })
await user.click(gridButtons[0])
await user.click(screen.getAllByRole('button', { name: 'Grid view' })[0])
await nextTick()
const gridThumbnails = screen.getAllByRole('button', {
name: /^View image/
})
expect(gridThumbnails).toHaveLength(2)
expect(
screen.getAllByRole('button', { name: /View image \d+ of 2/ })
).toHaveLength(2)
})
it('resets to grid mode when URLs change to multiple images', async () => {
const { rerender } = renderImagePreview()
const user = userEvent.setup()
const { rerender } = renderImagePreview()
await switchToGallery(user)
// Verify we're in gallery mode
screen.getByRole('region')
expect(
screen.getByRole('region', {
name: 'Image preview - Use arrow keys to navigate between images'
})
).toBeInTheDocument()
// Change URLs
await rerender({
imageUrls: [
'/api/view?filename=new1.png&type=output',
@@ -424,47 +469,50 @@ describe('ImagePreview', () => {
})
await nextTick()
// Should be back in grid mode
const gridThumbnails = screen.getAllByRole('button', {
name: /^View image/
})
expect(gridThumbnails).toHaveLength(3)
expect(
screen.getAllByRole('button', { name: /View image \d+ of 3/ })
).toHaveLength(3)
})
})
describe('batch cycling with identical URLs', () => {
it('should not enter persistent loading state when cycling through identical images', async () => {
vi.useFakeTimers()
const user = userEvent.setup({
advanceTimers: vi.advanceTimersByTime
})
try {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
const sameUrl = '/api/view?filename=test.png&type=output'
const { container } = renderImagePreview({
renderImagePreview({
imageUrls: [sameUrl, sameUrl, sameUrl]
})
await switchToGallery(user)
await user.click(
screen.getByRole('button', { name: 'View image 1 of 3' })
)
await nextTick()
// Simulate initial image load
await fireEvent.load(screen.getByRole('img'))
void fireEvent.load(screen.getByTestId('main-image'))
await nextTick()
expect(
container.querySelector('[aria-busy="true"]')
).not.toBeInTheDocument()
screen
.getByRole('region', {
name: 'Image preview - Use arrow keys to navigate between images'
})
.getAttribute('aria-busy')
).not.toBe('true')
// Click second navigation dot to cycle
const dots = screen.getAllByRole('button', { name: /View image/ })
const dots = viewImageNavButtons()
await user.click(dots[1])
await nextTick()
// Advance past the delayed loader timeout
await vi.advanceTimersByTimeAsync(300)
await nextTick()
// Should NOT be in loading state since URL didn't change
expect(
container.querySelector('[aria-busy="true"]')
).not.toBeInTheDocument()
screen
.getByRole('region', {
name: 'Image preview - Use arrow keys to navigate between images'
})
.getAttribute('aria-busy')
).not.toBe('true')
} finally {
vi.useRealTimers()
}
@@ -474,37 +522,31 @@ describe('ImagePreview', () => {
describe('URL change detection', () => {
it('should NOT reset loading state when imageUrls prop is reassigned with identical URLs', async () => {
vi.useFakeTimers()
const user = userEvent.setup({
advanceTimers: vi.advanceTimersByTime
})
try {
const urls = ['/api/view?filename=test.png&type=output']
const { container, rerender } = renderImagePreview({
imageUrls: urls
})
void user
const { rerender } = renderImagePreview({ imageUrls: urls })
// Simulate image load completing
await fireEvent.load(screen.getByRole('img'))
void fireEvent.load(screen.getByTestId('main-image'))
await nextTick()
// Verify loader is hidden after load
expect(
container.querySelector('[aria-busy="true"]')
).not.toBeInTheDocument()
const region = screen.getByRole('region', {
name: 'Image preview - Use arrow keys to navigate between images'
})
expect(region.getAttribute('aria-busy')).not.toBe('true')
// Reassign with new array reference but same content
await rerender({ imageUrls: [...urls] })
await nextTick()
// Advance past the 250ms delayed loader timeout
await vi.advanceTimersByTimeAsync(300)
await nextTick()
// Loading state should NOT have been reset
expect(
container.querySelector('[aria-busy="true"]')
).not.toBeInTheDocument()
screen
.getByRole('region', {
name: 'Image preview - Use arrow keys to navigate between images'
})
.getAttribute('aria-busy')
).not.toBe('true')
} finally {
vi.useRealTimers()
}
@@ -512,55 +554,48 @@ describe('ImagePreview', () => {
it('should reset loading state when imageUrls prop changes to different URLs', async () => {
vi.useFakeTimers()
const user = userEvent.setup({
advanceTimers: vi.advanceTimersByTime
})
try {
const urls = ['/api/view?filename=test.png&type=output']
const { container, rerender } = renderImagePreview({
imageUrls: urls
})
const { rerender } = renderImagePreview({ imageUrls: urls })
// Simulate image load completing
await fireEvent.load(screen.getByRole('img'))
void fireEvent.load(screen.getByTestId('main-image'))
await nextTick()
// Verify loader is hidden
expect(
container.querySelector('[aria-busy="true"]')
).not.toBeInTheDocument()
const region = screen.getByRole('region', {
name: 'Image preview - Use arrow keys to navigate between images'
})
expect(region.getAttribute('aria-busy')).not.toBe('true')
void user
// Change to different URL
await rerender({
imageUrls: ['/api/view?filename=different.png&type=output']
})
await nextTick()
// Advance past the 250ms delayed loader timeout
await vi.advanceTimersByTimeAsync(300)
await nextTick()
expect(
container.querySelector('[aria-busy="true"]')
).toBeInTheDocument()
screen.getByRole('region', {
name: 'Image preview - Use arrow keys to navigate between images'
})
).toHaveAttribute('aria-busy', 'true')
} finally {
vi.useRealTimers()
}
})
it('should handle empty to non-empty URL transitions correctly', async () => {
const { container, rerender } = renderImagePreview({ imageUrls: [] })
const { rerender } = renderImagePreview({ imageUrls: [] })
expect(container.querySelector('.image-preview')).not.toBeInTheDocument()
expect(screen.queryByTestId('image-preview-root')).not.toBeInTheDocument()
await rerender({
imageUrls: ['/api/view?filename=test.png&type=output']
})
await nextTick()
expect(container.querySelector('.image-preview')).toBeInTheDocument()
screen.getByRole('img')
expect(screen.getByTestId('image-preview-root')).toBeInTheDocument()
expect(screen.getByRole('img')).toBeInTheDocument()
})
})
})

View File

@@ -1,6 +1,7 @@
<template>
<div
v-if="imageUrls.length > 0"
data-testid="image-preview-root"
class="image-preview group relative flex size-full min-h-55 min-w-16 flex-col justify-center px-2"
@keydown="handleKeyDown"
>
@@ -89,6 +90,22 @@
<i-comfy:mask class="size-4" />
</button>
<button
v-if="!hasMultipleImages && nodeId"
:class="actionButtonClass"
:title="$t('g.clearMask')"
:aria-label="$t('g.clearMask')"
:disabled="isClearingMask"
@click.stop="handleClearMask"
>
<i
v-if="isClearingMask"
class="icon-[lucide--loader-circle] size-4 animate-spin"
aria-hidden="true"
/>
<i v-else class="icon-[lucide--eraser] size-4" aria-hidden="true" />
</button>
<!-- Download Button -->
<button
:class="actionButtonClass"
@@ -188,7 +205,7 @@ interface ImagePreviewProps {
const { imageUrls, nodeId } = defineProps<ImagePreviewProps>()
const { t } = useI18n()
const maskEditor = useMaskEditor()
const { openMaskEditor, clearMask, isClearingMask } = useMaskEditor()
const nodeOutputStore = useNodeOutputStore()
const toastStore = useToastStore()
@@ -284,7 +301,22 @@ function handleEditMask() {
if (!nodeId) return
const node = resolveNode(Number(nodeId))
if (!node) return
maskEditor.openMaskEditor(node)
openMaskEditor(node)
}
async function handleClearMask() {
if (!nodeId) return
const node = resolveNode(Number(nodeId))
if (!node) return
try {
await clearMask(node)
} catch {
toastStore.add({
severity: 'error',
summary: t('maskEditor.clearMaskError'),
detail: t('maskEditor.clearMaskFailed')
})
}
}
function handleDownload() {

6
src/types/vue-component.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}