Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { expect } from '@playwright/test'

import { comfyPageFixture as test } from '../../../../fixtures/ComfyPage'

test.describe('Vue Nodes Image Preview', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.loadWorkflow('widgets/load_image_widget')
await comfyPage.vueNodes.waitForNodes()
})

async function loadImageOnNode(
comfyPage: Awaited<
ReturnType<(typeof test)['info']>
>['fixtures']['comfyPage']
) {
Comment on lines +12 to +16
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find and inspect the ComfyPage fixture module
echo "=== ComfyPage fixture file ===" 
fd -a "ComfyPage.ts" browser_tests/fixtures -x head -100 {}

echo -e "\n=== Export patterns in ComfyPage ===" 
fd -a "ComfyPage.ts" browser_tests/fixtures -x rg "export (class|type|interface|const|function|default)" {} -n

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 3648


🏁 Script executed:

#!/bin/bash
# Inspect the imagePreview.spec.ts file around the flagged lines
echo "=== imagePreview.spec.ts (lines 1-30) ===" 
head -50 browser_tests/tests/vueNodes/interactions/node/imagePreview.spec.ts

echo -e "\n=== Check fixture import and usage ===" 
rg -n "import.*comfyPage|loadImageOnNode|test\.info" browser_tests/tests/vueNodes/interactions/node/imagePreview.spec.ts -A 3 -B 1

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 2765


🏁 Script executed:

#!/bin/bash
# Check if Playwright TestInfo exposes fixtures through test.info()
# by looking at how fixtures are typically accessed in test files
echo "=== Playwright fixture usage patterns in test files ===" 
rg -r "test\.info\(\)" browser_tests/tests -t ts --max-count=5 -B 2 -A 2

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 984


🏁 Script executed:

#!/bin/bash
# Verify TestIds exports for image preview, mask editor, and context menu
echo "=== TestIds defined in selectors.ts ===" 
rg -n "image|mask|context" browser_tests/fixtures/selectors.ts -i -B 1 -A 1

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 234


🏁 Script executed:

#!/bin/bash
# Check if there are TestIds for image preview or mask editor elsewhere
echo "=== All TestIds entries ===" 
head -100 browser_tests/fixtures/selectors.ts

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 2356


🏁 Script executed:

#!/bin/bash
# Check if image preview, mask editor, context menu components have test IDs in source
echo "=== Search for image-preview and mask-editor-dialog in src ===" 
rg "image-preview|mask-editor-dialog" src --include="*.vue" --include="*.ts" -l

echo -e "\n=== Check context menu component ===" 
rg "p-contextmenu|ContextMenu" src --include="*.vue" --include="*.ts" -l | head -5

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 1071


🏁 Script executed:

#!/bin/bash
# Check if image preview, mask editor, context menu components have test IDs in source
echo "=== Search for image-preview in src ===" 
rg "image-preview" src -t vue -t ts

echo -e "\n=== Search for mask-editor-dialog ===" 
rg "mask-editor-dialog" src -t vue -t ts

echo -e "\n=== Search for p-contextmenu ===" 
rg "p-contextmenu" src -t vue -t ts

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 283


🏁 Script executed:

#!/bin/bash
# Check if image preview, mask editor, context menu components have test IDs in source
echo "=== Search for image-preview in src ===" 
rg "image-preview" src

echo -e "\n=== Search for mask-editor-dialog ===" 
rg "mask-editor-dialog" src

echo -e "\n=== Search for p-contextmenu ===" 
rg "p-contextmenu" src

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 1163


🏁 Script executed:

#!/bin/bash
# Verify what's accessible on the comfyPageFixture object
echo "=== comfyPageFixture definition ===" 
rg -n "comfyPageFixture = base.extend" browser_tests/fixtures/ComfyPage.ts -A 20

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 974


Fixture type derivation is invalid; use the imported ComfyPage type directly.

Playwright TestInfo (from test.info()) doesn't expose a fixtures property, making the type path inaccessible. Import and use the concrete ComfyPage type instead.

Additionally, use centralized TestIds for element selection instead of class-based selectors (.image-preview, .mask-editor-dialog, .p-contextmenu). If TestIds doesn't have entries for these Vue node components yet, add them to browser_tests/fixtures/selectors.ts and update the components to use data-testid attributes.

✅ Proposed fix for fixture type
 import { expect } from '@playwright/test'
 
 import { comfyPageFixture as test } from '../../../../fixtures/ComfyPage'
+import type { ComfyPage } from '../../../../fixtures/ComfyPage'
 
   async function loadImageOnNode(
-    comfyPage: Awaited<
-      ReturnType<(typeof test)['info']>
-    >['fixtures']['comfyPage']
+    comfyPage: ComfyPage
   ) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@browser_tests/tests/vueNodes/interactions/node/imagePreview.spec.ts` around
lines 12 - 16, The helper loadImageOnNode currently derives the fixture type
from test.info() which is invalid; change its parameter type to the concrete
imported ComfyPage type (import ComfyPage from the fixture module) and update
the function signature to accept comfyPage: ComfyPage. Also replace class-based
DOM selectors ('.image-preview', '.mask-editor-dialog', '.p-contextmenu') with
centralized TestIds (e.g., TestIds.ImagePreview, TestIds.MaskEditorDialog,
TestIds.ContextMenu) by adding those keys to browser_tests/fixtures/selectors.ts
if missing and updating the Vue node components to expose data-testid attributes
that match those TestIds so the spec uses getByTestId /
locator(`[data-testid="${TestIds...}"]`).

const loadImageNode = (await comfyPage.getNodeRefsByType('LoadImage'))[0]
const { x, y } = await loadImageNode.getPosition()

await comfyPage.dragAndDropFile('image64x64.webp', {
dropPosition: { x, y }
})

const imagePreview = comfyPage.page.locator('.image-preview')
await expect(imagePreview).toBeVisible()
await expect(imagePreview.locator('img')).toBeVisible()
await expect(imagePreview).toContainText('x')

return imagePreview
}

test('opens mask editor from image preview button', async ({ comfyPage }) => {
const imagePreview = await loadImageOnNode(comfyPage)

await imagePreview.locator('[role="img"]').hover()
await comfyPage.page.getByLabel('Edit or mask image').click()

await expect(comfyPage.page.locator('.mask-editor-dialog')).toBeVisible()
})

test('shows image context menu options', async ({ comfyPage }) => {
await loadImageOnNode(comfyPage)

const nodeHeader = comfyPage.vueNodes.getNodeByTitle('Load Image')
await nodeHeader.click()
await nodeHeader.click({ button: 'right' })

const contextMenu = comfyPage.page.locator('.p-contextmenu')
await expect(contextMenu).toBeVisible()
await expect(contextMenu.getByText('Open Image')).toBeVisible()
await expect(contextMenu.getByText('Copy Image')).toBeVisible()
await expect(contextMenu.getByText('Save Image')).toBeVisible()
await expect(contextMenu.getByText('Open in Mask Editor')).toBeVisible()
})
})
25 changes: 9 additions & 16 deletions src/renderer/extensions/vueNodes/components/ImagePreview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@
<!-- Main Image -->
<img
v-if="!imageError"
ref="currentImageEl"
:src="currentImageUrl"
:alt="imageAltText"
class="block size-full object-contain pointer-events-none contain-size"
Expand Down Expand Up @@ -128,8 +127,8 @@ import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'

import { downloadFile } from '@/base/common/downloadUtil'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'

interface ImagePreviewProps {
Expand All @@ -142,7 +141,6 @@ interface ImagePreviewProps {
const props = defineProps<ImagePreviewProps>()

const { t } = useI18n()
const commandStore = useCommandStore()
const nodeOutputStore = useNodeOutputStore()

const actionButtonClass =
Expand All @@ -156,7 +154,6 @@ const actualDimensions = ref<string | null>(null)
const imageError = ref(false)
const showLoader = ref(false)

const currentImageEl = ref<HTMLImageElement>()
const imageWrapperEl = ref<HTMLDivElement>()

const { start: startDelayedLoader, stop: stopDelayedLoader } = useTimeoutFn(
Expand Down Expand Up @@ -209,6 +206,10 @@ const handleImageLoad = (event: Event) => {
if (img.naturalWidth && img.naturalHeight) {
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
}

if (props.nodeId) {
nodeOutputStore.syncLegacyNodeImgs(props.nodeId, img, currentIndex.value)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seeing sync always worries me.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's just doing what happens in litegraph

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does that not comfort me?

}
}

const handleImageError = () => {
Expand All @@ -218,19 +219,11 @@ const handleImageError = () => {
actualDimensions.value = null
}

// In vueNodes mode, we need to set them manually before opening the mask editor.
const setupNodeForMaskEditor = () => {
if (!props.nodeId || !currentImageEl.value) return
const node = app.rootGraph?.getNodeById(props.nodeId)
if (!node) return
node.imageIndex = currentIndex.value
node.imgs = [currentImageEl.value]
app.canvas?.select(node)
}

const handleEditMask = () => {
setupNodeForMaskEditor()
void commandStore.execute('Comfy.MaskEditor.OpenMaskEditor')
if (!props.nodeId) return
const node = app.rootGraph?.getNodeById(Number(props.nodeId))
if (!node) return
useMaskEditor().openMaskEditor(node)
Comment on lines 222 to +226
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Hoist useMaskEditor() to setup to avoid instance/timing pitfalls.

Calling composables inside event handlers can miss the active component instance if the composable uses injection. Safer to instantiate once in setup and reuse.

♻️ Suggested refactor
-const nodeOutputStore = useNodeOutputStore()
+const nodeOutputStore = useNodeOutputStore()
+const maskEditor = useMaskEditor()
...
-  useMaskEditor().openMaskEditor(node)
+  maskEditor.openMaskEditor(node)
🤖 Prompt for AI Agents
In `@src/renderer/extensions/vueNodes/components/ImagePreview.vue` around lines
214 - 218, Hoist the call to the composable out of the event handler by invoking
useMaskEditor() once in the component setup and storing its return (e.g. const
maskEditor = useMaskEditor()) so handleEditMask uses
maskEditor.openMaskEditor(node) instead of calling useMaskEditor() inside the
handler; update the setup block to create maskEditor and ensure handleEditMask
references that variable to avoid missing component instance/injection timing
issues.

}

const handleDownload = () => {
Expand Down
97 changes: 94 additions & 3 deletions src/stores/imagePreviewStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
Expand All @@ -13,15 +14,20 @@
isVideoNode: vi.fn()
}))

const mockGetNodeById = vi.fn()

vi.mock('@/scripts/app', () => ({
app: {
getPreviewFormatParam: vi.fn(() => '&format=test_webp'),
rootGraph: {
getNodeById: (...args: unknown[]) => mockGetNodeById(...args)
},
nodeOutputs: {} as Record<string, unknown>,
nodePreviewImages: {} as Record<string, string[]>
}
}))

const createMockNode = (overrides: Partial<LGraphNode> = {}): LGraphNode =>
const createMockNode = (overrides: Record<string, unknown> = {}): LGraphNode =>
({
id: 1,
type: 'TestNode',
Expand Down Expand Up @@ -157,10 +163,9 @@

it('should return empty string if output is animated', () => {
const store = useNodeOutputStore()
vi.mocked(litegraphUtil.isAnimatedOutput).mockReturnValue(true)
const node = createMockNode()
const node = createMockNode({ animatedImages: true })
const outputs = createMockOutputs([{ filename: 'img.png' }])
expect(store.getPreviewParam(node, outputs)).toBe('')

Check failure on line 168 in src/stores/imagePreviewStore.test.ts

View workflow job for this annotation

GitHub Actions / test

src/stores/imagePreviewStore.test.ts > imagePreviewStore getPreviewParam > should return empty string if output is animated

AssertionError: expected '&format=test_webp' to be '' // Object.is equality - Expected + Received + &format=test_webp ❯ src/stores/imagePreviewStore.test.ts:168:50

Check failure on line 168 in src/stores/imagePreviewStore.test.ts

View workflow job for this annotation

GitHub Actions / test

src/stores/imagePreviewStore.test.ts > imagePreviewStore getPreviewParam > should return empty string if output is animated

AssertionError: expected '&format=test_webp' to be '' // Object.is equality - Expected + Received + &format=test_webp ❯ src/stores/imagePreviewStore.test.ts:168:50

Check failure on line 168 in src/stores/imagePreviewStore.test.ts

View workflow job for this annotation

GitHub Actions / test

src/stores/imagePreviewStore.test.ts > imagePreviewStore getPreviewParam > should return empty string if output is animated

AssertionError: expected '&format=test_webp' to be '' // Object.is equality - Expected + Received + &format=test_webp ❯ src/stores/imagePreviewStore.test.ts:168:50
expect(vi.mocked(app).getPreviewFormatParam).not.toHaveBeenCalled()
})

Expand Down Expand Up @@ -216,3 +221,89 @@
expect(vi.mocked(app).getPreviewFormatParam).toHaveBeenCalledTimes(1)
})
})

describe('imagePreviewStore syncLegacyNodeImgs', () => {
beforeEach(() => {
setActivePinia(createPinia())

Check failure on line 227 in src/stores/imagePreviewStore.test.ts

View workflow job for this annotation

GitHub Actions / collect

Cannot find name 'createPinia'.

Check failure on line 227 in src/stores/imagePreviewStore.test.ts

View workflow job for this annotation

GitHub Actions / scan

Cannot find name 'createPinia'.

Check failure on line 227 in src/stores/imagePreviewStore.test.ts

View workflow job for this annotation

GitHub Actions / test

src/stores/imagePreviewStore.test.ts > imagePreviewStore syncLegacyNodeImgs > should sync with correct activeIndex

ReferenceError: createPinia is not defined ❯ src/stores/imagePreviewStore.test.ts:227:5

Check failure on line 227 in src/stores/imagePreviewStore.test.ts

View workflow job for this annotation

GitHub Actions / test

src/stores/imagePreviewStore.test.ts > imagePreviewStore syncLegacyNodeImgs > should sync node.imgs when vueNodesMode is enabled

ReferenceError: createPinia is not defined ❯ src/stores/imagePreviewStore.test.ts:227:5

Check failure on line 227 in src/stores/imagePreviewStore.test.ts

View workflow job for this annotation

GitHub Actions / test

src/stores/imagePreviewStore.test.ts > imagePreviewStore syncLegacyNodeImgs > should sync node.imgs when vueNodesMode is enabled

ReferenceError: createPinia is not defined ❯ src/stores/imagePreviewStore.test.ts:227:5

Check failure on line 227 in src/stores/imagePreviewStore.test.ts

View workflow job for this annotation

GitHub Actions / test

src/stores/imagePreviewStore.test.ts > imagePreviewStore syncLegacyNodeImgs > should sync node.imgs when vueNodesMode is enabled

ReferenceError: createPinia is not defined ❯ src/stores/imagePreviewStore.test.ts:227:5

Check failure on line 227 in src/stores/imagePreviewStore.test.ts

View workflow job for this annotation

GitHub Actions / test

src/stores/imagePreviewStore.test.ts > imagePreviewStore syncLegacyNodeImgs > should not sync when vueNodesMode is disabled

ReferenceError: createPinia is not defined ❯ src/stores/imagePreviewStore.test.ts:227:5

Check failure on line 227 in src/stores/imagePreviewStore.test.ts

View workflow job for this annotation

GitHub Actions / test

src/stores/imagePreviewStore.test.ts > imagePreviewStore syncLegacyNodeImgs > should not sync when vueNodesMode is disabled

ReferenceError: createPinia is not defined ❯ src/stores/imagePreviewStore.test.ts:227:5

Check failure on line 227 in src/stores/imagePreviewStore.test.ts

View workflow job for this annotation

GitHub Actions / test

src/stores/imagePreviewStore.test.ts > imagePreviewStore syncLegacyNodeImgs > should not sync when vueNodesMode is disabled

ReferenceError: createPinia is not defined ❯ src/stores/imagePreviewStore.test.ts:227:5

Check failure on line 227 in src/stores/imagePreviewStore.test.ts

View workflow job for this annotation

GitHub Actions / setup

Cannot find name 'createPinia'.
vi.clearAllMocks()
LiteGraph.vueNodesMode = false
})

it('should not sync when vueNodesMode is disabled', () => {
const store = useNodeOutputStore()
const mockNode = createMockNode({ id: 1 })
const mockImg = document.createElement('img')

mockGetNodeById.mockReturnValue(mockNode)

store.syncLegacyNodeImgs(1, mockImg, 0)

expect(mockNode.imgs).toBeUndefined()
expect(mockNode.imageIndex).toBeUndefined()
})

it('should sync node.imgs when vueNodesMode is enabled', () => {
LiteGraph.vueNodesMode = true
const store = useNodeOutputStore()
const mockNode = createMockNode({ id: 1 })
const mockImg = document.createElement('img')

mockGetNodeById.mockReturnValue(mockNode)

store.syncLegacyNodeImgs(1, mockImg, 0)

expect(mockNode.imgs).toEqual([mockImg])
expect(mockNode.imageIndex).toBe(0)
})

it('should sync with correct activeIndex', () => {
LiteGraph.vueNodesMode = true
const store = useNodeOutputStore()
const mockNode = createMockNode({ id: 42 })
const mockImg = document.createElement('img')

mockGetNodeById.mockReturnValue(mockNode)

store.syncLegacyNodeImgs(42, mockImg, 3)

expect(mockNode.imgs).toEqual([mockImg])
expect(mockNode.imageIndex).toBe(3)
})

it('should handle string nodeId', () => {
LiteGraph.vueNodesMode = true
const store = useNodeOutputStore()
const mockNode = createMockNode({ id: 123 })
const mockImg = document.createElement('img')

mockGetNodeById.mockReturnValue(mockNode)

store.syncLegacyNodeImgs('123', mockImg, 0)

expect(mockGetNodeById).toHaveBeenCalledWith(123)
expect(mockNode.imgs).toEqual([mockImg])
})

it('should not throw when node is not found', () => {
LiteGraph.vueNodesMode = true
const store = useNodeOutputStore()
const mockImg = document.createElement('img')

mockGetNodeById.mockReturnValue(undefined)

expect(() => store.syncLegacyNodeImgs(999, mockImg, 0)).not.toThrow()
})

it('should default activeIndex to 0', () => {
LiteGraph.vueNodesMode = true
const store = useNodeOutputStore()
const mockNode = createMockNode({ id: 1 })
const mockImg = document.createElement('img')

mockGetNodeById.mockReturnValue(mockNode)

store.syncLegacyNodeImgs(1, mockImg)

expect(mockNode.imageIndex).toBe(0)
})
})
28 changes: 28 additions & 0 deletions src/stores/imagePreviewStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'

import type { LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type {
ExecutedWsMessage,
Expand Down Expand Up @@ -394,6 +395,32 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
revokeAllPreviews()
}

/**
* Sync legacy node.imgs property for backwards compatibility.
*
* In Vue Nodes mode, legacy systems (Copy Image, Open Image, Save Image,
* Open in Mask Editor) rely on `node.imgs` containing HTMLImageElement
* references. Since Vue handles image rendering, we need to sync the
* already-loaded element from the Vue component to the node.
*
* @param nodeId - The node ID
* @param element - The loaded HTMLImageElement from the Vue component
* @param activeIndex - The current image index (for multi-image outputs)
*/
function syncLegacyNodeImgs(
nodeId: string | number,
element: HTMLImageElement,
activeIndex: number = 0
) {
if (!LiteGraph.vueNodesMode) return

const node = app.rootGraph?.getNodeById(Number(nodeId))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We really need to standardize our NodeId type.

if (!node) return

node.imgs = [element]
node.imageIndex = activeIndex
}

return {
// Getters
getNodeOutputs,
Expand All @@ -407,6 +434,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
setNodePreviewsByExecutionId,
setNodePreviewsByNodeId,
updateNodeImages,
syncLegacyNodeImgs,

// Cleanup
revokePreviewsByExecutionId,
Expand Down
Loading