From 980e3ebfabb397484fa8765d008eefe8bb12933b Mon Sep 17 00:00:00 2001 From: Comfy Org PR Bot Date: Sun, 10 Aug 2025 08:28:40 +0800 Subject: [PATCH] [backport 1.25] Add preview to workflow tabs (#4882) Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com> --- browser_tests/fixtures/ComfyPage.ts | 4 +- browser_tests/tests/interaction.spec.ts | 19 +- .../tests/workflowTabThumbnail.spec.ts | 155 ++++++++++ src/components/topbar/WorkflowTab.vue | 57 +++- src/components/topbar/WorkflowTabPopover.vue | 229 ++++++++++++++ src/composables/useMinimap.ts | 1 + src/composables/useWorkflowThumbnail.ts | 108 +++++++ src/services/workflowService.ts | 12 +- src/stores/workflowStore.ts | 10 + .../composables/useWorkflowThumbnail.spec.ts | 282 ++++++++++++++++++ 10 files changed, 858 insertions(+), 19 deletions(-) create mode 100644 browser_tests/tests/workflowTabThumbnail.spec.ts create mode 100644 src/components/topbar/WorkflowTabPopover.vue create mode 100644 src/composables/useWorkflowThumbnail.ts create mode 100644 tests-ui/tests/composables/useWorkflowThumbnail.spec.ts diff --git a/browser_tests/fixtures/ComfyPage.ts b/browser_tests/fixtures/ComfyPage.ts index 6369d654c..1add37597 100644 --- a/browser_tests/fixtures/ComfyPage.ts +++ b/browser_tests/fixtures/ComfyPage.ts @@ -767,8 +767,8 @@ export class ComfyPage { await this.nextFrame() } - async rightClickCanvas() { - await this.page.mouse.click(10, 10, { button: 'right' }) + async rightClickCanvas(x: number = 10, y: number = 10) { + await this.page.mouse.click(x, y, { button: 'right' }) await this.nextFrame() } diff --git a/browser_tests/tests/interaction.spec.ts b/browser_tests/tests/interaction.spec.ts index 94c2f1632..a19248c2e 100644 --- a/browser_tests/tests/interaction.spec.ts +++ b/browser_tests/tests/interaction.spec.ts @@ -1,4 +1,4 @@ -import { expect } from '@playwright/test' +import { Locator, expect } from '@playwright/test' import { Position } from '@vueuse/core' import { @@ -767,6 +767,17 @@ test.describe('Viewport settings', () => { comfyPage, comfyMouse }) => { + const changeTab = async (tab: Locator) => { + await tab.click() + await comfyPage.nextFrame() + await comfyMouse.move(comfyPage.emptySpace) + + // If tooltip is visible, wait for it to hide + await expect( + comfyPage.page.locator('.workflow-popover-fade') + ).toHaveCount(0) + } + // Screenshot the canvas element await comfyPage.setSetting('Comfy.Graph.CanvasMenu', true) const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button') @@ -794,15 +805,13 @@ test.describe('Viewport settings', () => { const tabB = comfyPage.menu.topbar.getWorkflowTab('Workflow B') // Go back to Workflow A - await tabA.click() - await comfyPage.nextFrame() + await changeTab(tabA) expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe( screenshotA ) // And back to Workflow B - await tabB.click() - await comfyPage.nextFrame() + await changeTab(tabB) expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe( screenshotB ) diff --git a/browser_tests/tests/workflowTabThumbnail.spec.ts b/browser_tests/tests/workflowTabThumbnail.spec.ts new file mode 100644 index 000000000..a1869430b --- /dev/null +++ b/browser_tests/tests/workflowTabThumbnail.spec.ts @@ -0,0 +1,155 @@ +import { expect } from '@playwright/test' + +import { type ComfyPage, comfyPageFixture as test } from '../fixtures/ComfyPage' + +test.describe('Workflow Tab Thumbnails', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Top') + await comfyPage.setSetting('Comfy.Workflow.WorkflowTabsPosition', 'Topbar') + await comfyPage.setup() + }) + + async function getTab(comfyPage: ComfyPage, index: number) { + const tab = comfyPage.page + .locator(`.workflow-tabs .p-togglebutton`) + .nth(index) + return tab + } + + async function getTabPopover( + comfyPage: ComfyPage, + index: number, + name?: string + ) { + const tab = await getTab(comfyPage, index) + await tab.hover() + + const popover = comfyPage.page.locator('.workflow-popover-fade') + await expect(popover).toHaveCount(1) + await expect(popover).toBeVisible({ timeout: 500 }) + if (name) { + await expect(popover).toContainText(name) + } + return popover + } + + async function getTabThumbnailImage( + comfyPage: ComfyPage, + index: number, + name?: string + ) { + const popover = await getTabPopover(comfyPage, index, name) + const thumbnailImg = popover.locator('.workflow-preview-thumbnail img') + return thumbnailImg + } + + async function getNodeThumbnailBase64(comfyPage: ComfyPage, index: number) { + const thumbnailImg = await getTabThumbnailImage(comfyPage, index) + const src = (await thumbnailImg.getAttribute('src'))! + + // Convert blob to base64, need to execute a script to get the base64 + const base64 = await comfyPage.page.evaluate(async (src: string) => { + const blob = await fetch(src).then((res) => res.blob()) + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onloadend = () => resolve(reader.result) + reader.onerror = reject + reader.readAsDataURL(blob) + }) + }, src) + return base64 + } + + test('Should show thumbnail when hovering over a non-active tab', async ({ + comfyPage + }) => { + await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New']) + const thumbnailImg = await getTabThumbnailImage( + comfyPage, + 0, + 'Unsaved Workflow' + ) + await expect(thumbnailImg).toBeVisible() + }) + + test('Should not show thumbnail for active tab', async ({ comfyPage }) => { + await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New']) + const thumbnailImg = await getTabThumbnailImage( + comfyPage, + 1, + 'Unsaved Workflow (2)' + ) + await expect(thumbnailImg).not.toBeVisible() + }) + + async function addNode(comfyPage: ComfyPage, category: string, node: string) { + const canvasArea = await comfyPage.canvas.boundingBox() + + await comfyPage.page.mouse.move( + canvasArea!.x + canvasArea!.width - 100, + 100 + ) + await comfyPage.delay(300) // Wait for the popover to hide + + await comfyPage.rightClickCanvas(200, 200) + await comfyPage.page.getByText('Add Node').click() + await comfyPage.nextFrame() + await comfyPage.page.getByText(category).click() + await comfyPage.nextFrame() + await comfyPage.page.getByText(node).click() + await comfyPage.nextFrame() + } + + test('Thumbnail should update when switching tabs', async ({ comfyPage }) => { + // Wait for initial workflow to load + await comfyPage.nextFrame() + + // Create a new workflow (tab 1) which will be empty + await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New']) + await comfyPage.nextFrame() + + // Now we have two tabs: tab 0 (default workflow with nodes) and tab 1 (empty) + // Tab 1 is currently active, so we can only get thumbnail for tab 0 + + // Step 1: Different tabs should show different previews + const tab0ThumbnailWithNodes = await getNodeThumbnailBase64(comfyPage, 0) + + // Add a node to tab 1 (current active tab) + await addNode(comfyPage, 'loaders', 'Load Checkpoint') + await comfyPage.nextFrame() + + // Switch to tab 0 so we can get tab 1's thumbnail + await (await getTab(comfyPage, 0)).click() + await comfyPage.nextFrame() + + const tab1ThumbnailWithNode = await getNodeThumbnailBase64(comfyPage, 1) + + // The thumbnails should be different + expect(tab0ThumbnailWithNodes).not.toBe(tab1ThumbnailWithNode) + + // Step 2: Switching without changes shouldn't update thumbnail + const tab1ThumbnailBefore = await getNodeThumbnailBase64(comfyPage, 1) + + // Switch to tab 1 and back to tab 0 without making changes + await (await getTab(comfyPage, 1)).click() + await comfyPage.nextFrame() + await (await getTab(comfyPage, 0)).click() + await comfyPage.nextFrame() + + const tab1ThumbnailAfter = await getNodeThumbnailBase64(comfyPage, 1) + expect(tab1ThumbnailBefore).toBe(tab1ThumbnailAfter) + + // Step 3: Adding another node should cause thumbnail to change + // We're on tab 0, add a node + await addNode(comfyPage, 'loaders', 'Load VAE') + await comfyPage.nextFrame() + + // Switch to tab 1 and back to update tab 0's thumbnail + await (await getTab(comfyPage, 1)).click() + + const tab0ThumbnailAfterNewNode = await getNodeThumbnailBase64(comfyPage, 0) + + // The thumbnail should have changed after adding a node + expect(tab0ThumbnailWithNodes).not.toBe(tab0ThumbnailAfterNewNode) + }) +}) diff --git a/src/components/topbar/WorkflowTab.vue b/src/components/topbar/WorkflowTab.vue index 667ce6730..8432a67f9 100644 --- a/src/components/topbar/WorkflowTab.vue +++ b/src/components/topbar/WorkflowTab.vue @@ -1,13 +1,13 @@ + + diff --git a/src/composables/useMinimap.ts b/src/composables/useMinimap.ts index 1467755b5..6eada5b6c 100644 --- a/src/composables/useMinimap.ts +++ b/src/composables/useMinimap.ts @@ -696,6 +696,7 @@ export function useMinimap() { init, destroy, toggle, + renderMinimap, handlePointerDown, handlePointerMove, handlePointerUp, diff --git a/src/composables/useWorkflowThumbnail.ts b/src/composables/useWorkflowThumbnail.ts new file mode 100644 index 000000000..5adab4945 --- /dev/null +++ b/src/composables/useWorkflowThumbnail.ts @@ -0,0 +1,108 @@ +import { ref } from 'vue' + +import { ComfyWorkflow } from '@/stores/workflowStore' + +import { useMinimap } from './useMinimap' + +// Store thumbnails for each workflow +const workflowThumbnails = ref>(new Map()) + +// Shared minimap instance +let minimap: ReturnType | null = null + +export const useWorkflowThumbnail = () => { + /** + * Capture a thumbnail of the canvas + */ + const createMinimapPreview = (): Promise => { + try { + if (!minimap) { + minimap = useMinimap() + minimap.canvasRef.value = document.createElement('canvas') + minimap.canvasRef.value.width = minimap.width + minimap.canvasRef.value.height = minimap.height + } + minimap.renderMinimap() + + return new Promise((resolve) => { + minimap!.canvasRef.value!.toBlob((blob) => { + if (blob) { + resolve(URL.createObjectURL(blob)) + } else { + resolve(null) + } + }) + }) + } catch (error) { + console.error('Failed to capture canvas thumbnail:', error) + return Promise.resolve(null) + } + } + + /** + * Store a thumbnail for a workflow + */ + const storeThumbnail = async (workflow: ComfyWorkflow) => { + const thumbnail = await createMinimapPreview() + if (thumbnail) { + // Clean up existing thumbnail if it exists + const existingThumbnail = workflowThumbnails.value.get(workflow.key) + if (existingThumbnail) { + URL.revokeObjectURL(existingThumbnail) + } + workflowThumbnails.value.set(workflow.key, thumbnail) + } + } + + /** + * Get a thumbnail for a workflow + */ + const getThumbnail = (workflowKey: string): string | undefined => { + return workflowThumbnails.value.get(workflowKey) + } + + /** + * Clear a thumbnail for a workflow + */ + const clearThumbnail = (workflowKey: string) => { + const thumbnail = workflowThumbnails.value.get(workflowKey) + if (thumbnail) { + URL.revokeObjectURL(thumbnail) + } + workflowThumbnails.value.delete(workflowKey) + } + + /** + * Clear all thumbnails + */ + const clearAllThumbnails = () => { + for (const thumbnail of workflowThumbnails.value.values()) { + URL.revokeObjectURL(thumbnail) + } + workflowThumbnails.value.clear() + } + + /** + * Move a thumbnail from one workflow key to another (useful for workflow renaming) + */ + const moveWorkflowThumbnail = (oldKey: string, newKey: string) => { + // Don't do anything if moving to the same key + if (oldKey === newKey) return + + const thumbnail = workflowThumbnails.value.get(oldKey) + if (thumbnail) { + workflowThumbnails.value.set(newKey, thumbnail) + workflowThumbnails.value.delete(oldKey) + } + } + + return { + createMinimapPreview, + storeThumbnail, + getThumbnail, + clearThumbnail, + clearAllThumbnails, + moveWorkflowThumbnail, + workflowThumbnails + } +} diff --git a/src/services/workflowService.ts b/src/services/workflowService.ts index 78c50d892..e52cb5c09 100644 --- a/src/services/workflowService.ts +++ b/src/services/workflowService.ts @@ -1,5 +1,6 @@ import { toRaw } from 'vue' +import { useWorkflowThumbnail } from '@/composables/useWorkflowThumbnail' import { t } from '@/i18n' import { LGraph, LGraphCanvas } from '@/lib/litegraph/src/litegraph' import type { SerialisableGraph, Vector2 } from '@/lib/litegraph/src/litegraph' @@ -21,6 +22,7 @@ export const useWorkflowService = () => { const workflowStore = useWorkflowStore() const toastStore = useToastStore() const dialogService = useDialogService() + const workflowThumbnail = useWorkflowThumbnail() const domWidgetStore = useDomWidgetStore() async function getFilename(defaultName: string): Promise { @@ -287,8 +289,14 @@ export const useWorkflowService = () => { */ const beforeLoadNewGraph = () => { // Use workspaceStore here as it is patched in unit tests. - useWorkspaceStore().workflow.activeWorkflow?.changeTracker?.store() - domWidgetStore.clear() + const workflowStore = useWorkspaceStore().workflow + const activeWorkflow = workflowStore.activeWorkflow + if (activeWorkflow) { + activeWorkflow.changeTracker.store() + // Capture thumbnail before loading new graph + void workflowThumbnail.storeThumbnail(activeWorkflow) + domWidgetStore.clear() + } } /** diff --git a/src/stores/workflowStore.ts b/src/stores/workflowStore.ts index 7de048646..125397334 100644 --- a/src/stores/workflowStore.ts +++ b/src/stores/workflowStore.ts @@ -2,6 +2,7 @@ import _ from 'lodash' import { defineStore } from 'pinia' import { type Raw, computed, markRaw, ref, shallowRef, watch } from 'vue' +import { useWorkflowThumbnail } from '@/composables/useWorkflowThumbnail' import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph' import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema' import type { NodeId } from '@/schemas/comfyWorkflowSchema' @@ -327,6 +328,8 @@ export const useWorkflowStore = defineStore('workflow', () => { (path) => path !== workflow.path ) if (workflow.isTemporary) { + // Clear thumbnail when temporary workflow is closed + clearThumbnail(workflow.key) delete workflowLookup.value[workflow.path] } else { workflow.unload() @@ -387,12 +390,14 @@ export const useWorkflowStore = defineStore('workflow', () => { /** A filesystem operation is currently in progress (e.g. save, rename, delete) */ const isBusy = ref(false) + const { moveWorkflowThumbnail, clearThumbnail } = useWorkflowThumbnail() const renameWorkflow = async (workflow: ComfyWorkflow, newPath: string) => { isBusy.value = true try { // Capture all needed values upfront const oldPath = workflow.path + const oldKey = workflow.key const wasBookmarked = bookmarkStore.isBookmarked(oldPath) const openIndex = detachWorkflow(workflow) @@ -403,6 +408,9 @@ export const useWorkflowStore = defineStore('workflow', () => { attachWorkflow(workflow, openIndex) } + // Move thumbnail from old key to new key (using workflow keys, not full paths) + const newKey = workflow.key + moveWorkflowThumbnail(oldKey, newKey) // Update bookmarks if (wasBookmarked) { await bookmarkStore.setBookmarked(oldPath, false) @@ -420,6 +428,8 @@ export const useWorkflowStore = defineStore('workflow', () => { if (bookmarkStore.isBookmarked(workflow.path)) { await bookmarkStore.setBookmarked(workflow.path, false) } + // Clear thumbnail when workflow is deleted + clearThumbnail(workflow.key) delete workflowLookup.value[workflow.path] } finally { isBusy.value = false diff --git a/tests-ui/tests/composables/useWorkflowThumbnail.spec.ts b/tests-ui/tests/composables/useWorkflowThumbnail.spec.ts new file mode 100644 index 000000000..37d132344 --- /dev/null +++ b/tests-ui/tests/composables/useWorkflowThumbnail.spec.ts @@ -0,0 +1,282 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore' + +vi.mock('@/composables/useMinimap', () => ({ + useMinimap: vi.fn() +})) + +vi.mock('@/scripts/api', () => ({ + api: { + moveUserData: vi.fn(), + listUserDataFullInfo: vi.fn(), + addEventListener: vi.fn(), + getUserData: vi.fn(), + storeUserData: vi.fn(), + apiURL: vi.fn((path: string) => `/api${path}`) + } +})) + +const { useWorkflowThumbnail } = await import( + '@/composables/useWorkflowThumbnail' +) +const { useMinimap } = await import('@/composables/useMinimap') +const { api } = await import('@/scripts/api') + +describe('useWorkflowThumbnail', () => { + let mockMinimapInstance: any + let workflowStore: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + workflowStore = useWorkflowStore() + + // Clear any existing thumbnails from previous tests BEFORE mocking + const { clearAllThumbnails } = useWorkflowThumbnail() + clearAllThumbnails() + + // Now set up mocks + vi.clearAllMocks() + + const blob = new Blob() + + global.URL.createObjectURL = vi.fn(() => 'data:image/png;base64,test') + global.URL.revokeObjectURL = vi.fn() + + // Mock API responses + vi.mocked(api.moveUserData).mockResolvedValue({ status: 200 } as Response) + + mockMinimapInstance = { + renderMinimap: vi.fn(), + canvasRef: { + value: { + toBlob: vi.fn((cb) => cb(blob)) + } + }, + width: 250, + height: 200 + } + + vi.mocked(useMinimap).mockReturnValue(mockMinimapInstance) + }) + + it('should capture minimap thumbnail', async () => { + const { createMinimapPreview } = useWorkflowThumbnail() + const thumbnail = await createMinimapPreview() + + expect(useMinimap).toHaveBeenCalledOnce() + expect(mockMinimapInstance.renderMinimap).toHaveBeenCalledOnce() + + expect(thumbnail).toBe('data:image/png;base64,test') + }) + + it('should store and retrieve thumbnails', async () => { + const { storeThumbnail, getThumbnail } = useWorkflowThumbnail() + + const mockWorkflow = { key: 'test-workflow-key' } as ComfyWorkflow + + await storeThumbnail(mockWorkflow) + + const thumbnail = getThumbnail('test-workflow-key') + expect(thumbnail).toBe('data:image/png;base64,test') + }) + + it('should clear thumbnail', async () => { + const { storeThumbnail, getThumbnail, clearThumbnail } = + useWorkflowThumbnail() + + const mockWorkflow = { key: 'test-workflow-key' } as ComfyWorkflow + + await storeThumbnail(mockWorkflow) + + expect(getThumbnail('test-workflow-key')).toBeDefined() + + clearThumbnail('test-workflow-key') + + expect(URL.revokeObjectURL).toHaveBeenCalledWith( + 'data:image/png;base64,test' + ) + expect(getThumbnail('test-workflow-key')).toBeUndefined() + }) + + it('should clear all thumbnails', async () => { + const { storeThumbnail, getThumbnail, clearAllThumbnails } = + useWorkflowThumbnail() + + const mockWorkflow1 = { key: 'workflow-1' } as ComfyWorkflow + const mockWorkflow2 = { key: 'workflow-2' } as ComfyWorkflow + + await storeThumbnail(mockWorkflow1) + await storeThumbnail(mockWorkflow2) + + expect(getThumbnail('workflow-1')).toBeDefined() + expect(getThumbnail('workflow-2')).toBeDefined() + + clearAllThumbnails() + + expect(URL.revokeObjectURL).toHaveBeenCalledTimes(2) + expect(getThumbnail('workflow-1')).toBeUndefined() + expect(getThumbnail('workflow-2')).toBeUndefined() + }) + + it('should automatically handle thumbnail cleanup when workflow is renamed', async () => { + const { storeThumbnail, getThumbnail, workflowThumbnails } = + useWorkflowThumbnail() + + // Create a temporary workflow + const workflow = workflowStore.createTemporary('test-workflow.json') + const originalKey = workflow.key + + // Store thumbnail for the workflow + await storeThumbnail(workflow) + expect(getThumbnail(originalKey)).toBe('data:image/png;base64,test') + expect(workflowThumbnails.value.size).toBe(1) + + // Rename the workflow - this should automatically handle thumbnail cleanup + const newPath = 'workflows/renamed-workflow.json' + await workflowStore.renameWorkflow(workflow, newPath) + + const newKey = workflow.key // The workflow's key should now be the new path + + // The thumbnail should be moved from old key to new key + expect(getThumbnail(originalKey)).toBeUndefined() + expect(getThumbnail(newKey)).toBe('data:image/png;base64,test') + expect(workflowThumbnails.value.size).toBe(1) + + // No URL should be revoked since we're moving the thumbnail, not deleting it + expect(URL.revokeObjectURL).not.toHaveBeenCalled() + }) + + it('should properly revoke old URL when storing thumbnail over existing one', async () => { + const { storeThumbnail, getThumbnail } = useWorkflowThumbnail() + + const mockWorkflow = { key: 'test-workflow' } as ComfyWorkflow + + // Store first thumbnail + await storeThumbnail(mockWorkflow) + const firstThumbnail = getThumbnail('test-workflow') + expect(firstThumbnail).toBe('data:image/png;base64,test') + + // Reset the mock to track new calls and create different URL + vi.clearAllMocks() + global.URL.createObjectURL = vi.fn(() => 'data:image/png;base64,test2') + + // Store second thumbnail for same workflow - should revoke the first URL + await storeThumbnail(mockWorkflow) + const secondThumbnail = getThumbnail('test-workflow') + expect(secondThumbnail).toBe('data:image/png;base64,test2') + + // URL.revokeObjectURL should have been called for the first thumbnail + expect(URL.revokeObjectURL).toHaveBeenCalledWith( + 'data:image/png;base64,test' + ) + expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1) + }) + + it('should clear thumbnail when workflow is deleted', async () => { + const { storeThumbnail, getThumbnail, workflowThumbnails } = + useWorkflowThumbnail() + + // Create a workflow and store thumbnail + const workflow = workflowStore.createTemporary('test-delete.json') + await storeThumbnail(workflow) + + expect(getThumbnail(workflow.key)).toBe('data:image/png;base64,test') + expect(workflowThumbnails.value.size).toBe(1) + + // Delete the workflow - this should clear the thumbnail + await workflowStore.deleteWorkflow(workflow) + + // Thumbnail should be cleared and URL revoked + expect(getThumbnail(workflow.key)).toBeUndefined() + expect(workflowThumbnails.value.size).toBe(0) + expect(URL.revokeObjectURL).toHaveBeenCalledWith( + 'data:image/png;base64,test' + ) + }) + + it('should clear thumbnail when temporary workflow is closed', async () => { + const { storeThumbnail, getThumbnail, workflowThumbnails } = + useWorkflowThumbnail() + + // Create a temporary workflow and store thumbnail + const workflow = workflowStore.createTemporary('temp-workflow.json') + await storeThumbnail(workflow) + + expect(getThumbnail(workflow.key)).toBe('data:image/png;base64,test') + expect(workflowThumbnails.value.size).toBe(1) + + // Close the workflow - this should clear the thumbnail for temporary workflows + await workflowStore.closeWorkflow(workflow) + + // Thumbnail should be cleared and URL revoked + expect(getThumbnail(workflow.key)).toBeUndefined() + expect(workflowThumbnails.value.size).toBe(0) + expect(URL.revokeObjectURL).toHaveBeenCalledWith( + 'data:image/png;base64,test' + ) + }) + + it('should handle multiple renames without leaking', async () => { + const { storeThumbnail, getThumbnail, workflowThumbnails } = + useWorkflowThumbnail() + + // Create workflow and store thumbnail + const workflow = workflowStore.createTemporary('original.json') + await storeThumbnail(workflow) + const originalKey = workflow.key + + expect(getThumbnail(originalKey)).toBe('data:image/png;base64,test') + expect(workflowThumbnails.value.size).toBe(1) + + // Rename multiple times + await workflowStore.renameWorkflow(workflow, 'workflows/renamed1.json') + const firstRenameKey = workflow.key + + expect(getThumbnail(originalKey)).toBeUndefined() + expect(getThumbnail(firstRenameKey)).toBe('data:image/png;base64,test') + expect(workflowThumbnails.value.size).toBe(1) + + await workflowStore.renameWorkflow(workflow, 'workflows/renamed2.json') + const secondRenameKey = workflow.key + + expect(getThumbnail(originalKey)).toBeUndefined() + expect(getThumbnail(firstRenameKey)).toBeUndefined() + expect(getThumbnail(secondRenameKey)).toBe('data:image/png;base64,test') + expect(workflowThumbnails.value.size).toBe(1) + + // No URLs should be revoked since we're just moving thumbnails + expect(URL.revokeObjectURL).not.toHaveBeenCalled() + }) + + it('should handle edge cases like empty keys or invalid operations', async () => { + const { + getThumbnail, + clearThumbnail, + moveWorkflowThumbnail, + workflowThumbnails + } = useWorkflowThumbnail() + + // Test getting non-existent thumbnail + expect(getThumbnail('non-existent')).toBeUndefined() + + // Test clearing non-existent thumbnail (should not throw) + expect(() => clearThumbnail('non-existent')).not.toThrow() + expect(URL.revokeObjectURL).not.toHaveBeenCalled() + + // Test moving non-existent thumbnail (should not throw) + expect(() => moveWorkflowThumbnail('non-existent', 'target')).not.toThrow() + expect(workflowThumbnails.value.size).toBe(0) + + // Test moving to same key (should not cause issues) + const { storeThumbnail } = useWorkflowThumbnail() + const mockWorkflow = { key: 'test-key' } as ComfyWorkflow + await storeThumbnail(mockWorkflow) + + expect(workflowThumbnails.value.size).toBe(1) + moveWorkflowThumbnail('test-key', 'test-key') + expect(workflowThumbnails.value.size).toBe(1) + expect(getThumbnail('test-key')).toBe('data:image/png;base64,test') + }) +})