mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-24 14:45:36 +00:00
Group Nodes are a legacy feature superseded by Subgraphs. This removes all UI entry points for creating new Group Nodes, while keeping the loading, ungrouping, and management code intact so existing workflows that contain Group Nodes continue to load and can still be unpacked or managed. Removed entry points: - 'Convert selected nodes to group node' command - Alt+G keybinding - 'Convert to Group Node (Deprecated)' canvas and node context menu items - 'Convert to Group Node' option in the Vue selection menu - Associated en locale strings - Browser tests that exercised the creation flow
527 lines
18 KiB
TypeScript
527 lines
18 KiB
TypeScript
import type { Locator } from '@playwright/test'
|
|
|
|
import {
|
|
comfyExpect as expect,
|
|
comfyPageFixture as test
|
|
} from '@e2e/fixtures/ComfyPage'
|
|
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
|
import { TestIds } from '@e2e/fixtures/selectors'
|
|
|
|
const BYPASS_CLASS = /before:bg-bypass\/60/
|
|
|
|
async function clickExactMenuItem(comfyPage: ComfyPage, name: string) {
|
|
await comfyPage.contextMenu.clickMenuItemExact(name)
|
|
await expect(comfyPage.contextMenu.primeVueMenu).toBeHidden()
|
|
}
|
|
|
|
async function openContextMenu(comfyPage: ComfyPage, nodeTitle: string) {
|
|
const fixture = await comfyPage.vueNodes.getFixtureByTitle(nodeTitle)
|
|
await comfyPage.contextMenu.openForVueNode(fixture.header)
|
|
return comfyPage.contextMenu.primeVueMenu
|
|
}
|
|
|
|
async function openMultiNodeContextMenu(
|
|
comfyPage: ComfyPage,
|
|
titles: string[]
|
|
) {
|
|
// deselectAll via evaluate — clearSelection() clicks at a fixed position
|
|
// which can hit nodes or the toolbar overlay
|
|
await comfyPage.page.evaluate(() => window.app!.canvas.deselectAll())
|
|
await comfyPage.nextFrame()
|
|
|
|
for (const title of titles) {
|
|
const fixture = await comfyPage.vueNodes.getFixtureByTitle(title)
|
|
await fixture.header.click({ modifiers: ['ControlOrMeta'] })
|
|
}
|
|
await comfyPage.nextFrame()
|
|
|
|
const firstFixture = await comfyPage.vueNodes.getFixtureByTitle(titles[0])
|
|
const box = await firstFixture.header.boundingBox()
|
|
if (!box) throw new Error(`Header for "${titles[0]}" not found`)
|
|
await comfyPage.page.mouse.click(
|
|
box.x + box.width / 2,
|
|
box.y + box.height / 2,
|
|
{ button: 'right' }
|
|
)
|
|
|
|
const menu = comfyPage.contextMenu.primeVueMenu
|
|
await menu.waitFor({ state: 'visible' })
|
|
return menu
|
|
}
|
|
|
|
function getNodeWrapper(comfyPage: ComfyPage, nodeTitle: string): Locator {
|
|
return comfyPage.vueNodes
|
|
.getNodeByTitle(nodeTitle)
|
|
.getByTestId(TestIds.node.innerWrapper)
|
|
}
|
|
|
|
async function getNodeRef(comfyPage: ComfyPage, nodeTitle: string) {
|
|
const refs = await comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
|
|
return refs[0]
|
|
}
|
|
|
|
test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
|
|
test.describe('Single Node Actions', () => {
|
|
test('should rename node via context menu', async ({ comfyPage }) => {
|
|
await openContextMenu(comfyPage, 'KSampler')
|
|
await clickExactMenuItem(comfyPage, 'Rename')
|
|
|
|
await comfyPage.titleEditor.expectVisible()
|
|
await comfyPage.titleEditor.setTitle('My Renamed Sampler')
|
|
await comfyPage.nextFrame()
|
|
|
|
const renamedNode =
|
|
comfyPage.vueNodes.getNodeByTitle('My Renamed Sampler')
|
|
await expect(renamedNode).toBeVisible()
|
|
})
|
|
|
|
test('should open node info in the right side panel via context menu', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', false)
|
|
await expect(comfyPage.menu.propertiesPanel.root).toBeHidden()
|
|
|
|
await openContextMenu(comfyPage, 'KSampler')
|
|
await clickExactMenuItem(comfyPage, 'Node Info')
|
|
|
|
const panel = comfyPage.menu.propertiesPanel.root
|
|
await expect(panel).toBeVisible()
|
|
await expect(panel.getByTestId('panel-tab-info')).toHaveAttribute(
|
|
'aria-selected',
|
|
'true'
|
|
)
|
|
await expect(comfyPage.menu.nodeLibraryTab.selectedTabButton).toBeHidden()
|
|
})
|
|
|
|
test('should copy and paste node via context menu', async ({
|
|
comfyPage
|
|
}) => {
|
|
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
|
|
|
await openContextMenu(comfyPage, 'Load Checkpoint')
|
|
await clickExactMenuItem(comfyPage, 'Copy')
|
|
|
|
// Internal clipboard paste (menu Copy uses canvas clipboard, not OS)
|
|
await comfyPage.page.evaluate(() => {
|
|
window.app!.canvas.pasteFromClipboard({ connectInputs: false })
|
|
})
|
|
await comfyPage.nextFrame()
|
|
|
|
await expect
|
|
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
|
.toBe(initialCount + 1)
|
|
})
|
|
|
|
test('should duplicate node via context menu', async ({ comfyPage }) => {
|
|
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
|
|
|
await openContextMenu(comfyPage, 'Load Checkpoint')
|
|
await clickExactMenuItem(comfyPage, 'Duplicate')
|
|
|
|
await expect
|
|
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
|
.toBe(initialCount + 1)
|
|
})
|
|
|
|
test('should pin and unpin node via context menu', async ({
|
|
comfyPage
|
|
}) => {
|
|
const nodeTitle = 'Load Checkpoint'
|
|
const nodeRef = await getNodeRef(comfyPage, nodeTitle)
|
|
|
|
// Pin via context menu
|
|
await openContextMenu(comfyPage, nodeTitle)
|
|
await clickExactMenuItem(comfyPage, 'Pin')
|
|
|
|
const fixture = await comfyPage.vueNodes.getFixtureByTitle(nodeTitle)
|
|
await expect(fixture.pinIndicator).toBeVisible()
|
|
await expect.poll(() => nodeRef.isPinned()).toBe(true)
|
|
|
|
// Verify drag blocked
|
|
const header = fixture.header
|
|
const posBeforeDrag = await header.boundingBox()
|
|
if (!posBeforeDrag) throw new Error('Header not found')
|
|
await comfyPage.canvasOps.dragAndDrop(
|
|
{ x: posBeforeDrag.x + 10, y: posBeforeDrag.y + 10 },
|
|
{ x: posBeforeDrag.x + 256, y: posBeforeDrag.y + 256 }
|
|
)
|
|
await expect
|
|
.poll(async () => await header.boundingBox())
|
|
.toEqual(posBeforeDrag)
|
|
|
|
// Unpin via context menu
|
|
await openContextMenu(comfyPage, nodeTitle)
|
|
await clickExactMenuItem(comfyPage, 'Unpin')
|
|
|
|
await expect(fixture.pinIndicator).toBeHidden()
|
|
await expect.poll(() => nodeRef.isPinned()).toBe(false)
|
|
})
|
|
|
|
test('should bypass node and remove bypass via context menu', async ({
|
|
comfyPage
|
|
}) => {
|
|
const nodeTitle = 'Load Checkpoint'
|
|
const nodeRef = await getNodeRef(comfyPage, nodeTitle)
|
|
|
|
await openContextMenu(comfyPage, nodeTitle)
|
|
await clickExactMenuItem(comfyPage, 'Bypass')
|
|
|
|
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
|
|
await expect(getNodeWrapper(comfyPage, nodeTitle)).toHaveClass(
|
|
BYPASS_CLASS
|
|
)
|
|
|
|
await openContextMenu(comfyPage, nodeTitle)
|
|
await clickExactMenuItem(comfyPage, 'Remove Bypass')
|
|
|
|
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
|
|
await expect(getNodeWrapper(comfyPage, nodeTitle)).not.toHaveClass(
|
|
BYPASS_CLASS
|
|
)
|
|
})
|
|
|
|
test('should minimize and expand node via context menu', async ({
|
|
comfyPage
|
|
}) => {
|
|
const fixture = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
|
await expect(fixture.body).toBeVisible()
|
|
|
|
await openContextMenu(comfyPage, 'KSampler')
|
|
await clickExactMenuItem(comfyPage, 'Minimize Node')
|
|
await expect(fixture.body).toBeHidden()
|
|
|
|
await openContextMenu(comfyPage, 'KSampler')
|
|
await clickExactMenuItem(comfyPage, 'Expand Node')
|
|
await expect(fixture.body).toBeVisible()
|
|
})
|
|
|
|
test('should convert node to subgraph via context menu', async ({
|
|
comfyPage
|
|
}) => {
|
|
await openContextMenu(comfyPage, 'KSampler')
|
|
await clickExactMenuItem(comfyPage, 'Convert to Subgraph')
|
|
|
|
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
|
|
await expect(subgraphNode).toBeVisible()
|
|
|
|
await expect(comfyPage.vueNodes.getNodeByTitle('KSampler')).toBeHidden()
|
|
})
|
|
})
|
|
|
|
test.describe('Image Node Actions', () => {
|
|
test.beforeEach(async ({ comfyPage }) => {
|
|
await comfyPage.page
|
|
.context()
|
|
.grantPermissions(['clipboard-read', 'clipboard-write'])
|
|
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
|
await comfyPage.vueNodes.waitForNodes(1)
|
|
await comfyPage.page
|
|
.locator('[data-node-id] img')
|
|
.first()
|
|
.waitFor({ state: 'visible' })
|
|
|
|
const [loadImageNode] =
|
|
await comfyPage.nodeOps.getNodeRefsByTitle('Load Image')
|
|
if (!loadImageNode) throw new Error('Load Image node not found')
|
|
|
|
await expect
|
|
.poll(() =>
|
|
comfyPage.page.evaluate(
|
|
(nodeId) =>
|
|
window.app!.graph.getNodeById(nodeId)?.imgs?.length ?? 0,
|
|
loadImageNode.id
|
|
)
|
|
)
|
|
.toBeGreaterThan(0)
|
|
})
|
|
|
|
test('should copy image to clipboard via context menu', async ({
|
|
comfyPage
|
|
}) => {
|
|
await openContextMenu(comfyPage, 'Load Image')
|
|
await clickExactMenuItem(comfyPage, 'Copy Image')
|
|
|
|
// Verify the clipboard contains an image
|
|
await expect
|
|
.poll(async () => {
|
|
return comfyPage.page.evaluate(async () => {
|
|
const items = await navigator.clipboard.read()
|
|
return items.some((item) =>
|
|
item.types.some((t) => t.startsWith('image/'))
|
|
)
|
|
})
|
|
})
|
|
.toBe(true)
|
|
})
|
|
|
|
test('should paste image to LoadImage node via context menu', async ({
|
|
comfyPage
|
|
}) => {
|
|
// Capture the original image src from the node's preview
|
|
const imagePreview = comfyPage.vueNodes
|
|
.getNodeByTitle('Load Image')
|
|
.getByTestId(TestIds.node.mainImage)
|
|
const originalSrc = await imagePreview.getAttribute('src')
|
|
|
|
// Write a test image into the browser clipboard
|
|
await comfyPage.page.evaluate(async () => {
|
|
const resp = await fetch('/api/view?filename=example.png&type=input')
|
|
const blob = await resp.blob()
|
|
await navigator.clipboard.write([
|
|
new ClipboardItem({ [blob.type]: blob })
|
|
])
|
|
})
|
|
|
|
// Right-click and select Paste Image
|
|
await openContextMenu(comfyPage, 'Load Image')
|
|
await clickExactMenuItem(comfyPage, 'Paste Image')
|
|
|
|
// Verify the image preview src changed
|
|
await expect(imagePreview).not.toHaveAttribute('src', originalSrc!)
|
|
})
|
|
|
|
test('should open image in new tab via context menu', async ({
|
|
comfyPage
|
|
}) => {
|
|
await openContextMenu(comfyPage, 'Load Image')
|
|
|
|
const popupPromise = comfyPage.page.waitForEvent('popup')
|
|
await clickExactMenuItem(comfyPage, 'Open Image')
|
|
const popup = await popupPromise
|
|
|
|
expect(popup.url()).toContain('/api/view')
|
|
expect(popup.url()).toContain('filename=')
|
|
await popup.close()
|
|
})
|
|
|
|
test('should download image via Save Image context menu', async ({
|
|
comfyPage
|
|
}) => {
|
|
await openContextMenu(comfyPage, 'Load Image')
|
|
|
|
const downloadPromise = comfyPage.page.waitForEvent('download')
|
|
await clickExactMenuItem(comfyPage, 'Save Image')
|
|
const download = await downloadPromise
|
|
|
|
expect(download.suggestedFilename()).toBeTruthy()
|
|
})
|
|
})
|
|
|
|
test.describe('Subgraph Actions', () => {
|
|
test('should convert to subgraph and unpack back', async ({
|
|
comfyPage
|
|
}) => {
|
|
// Convert KSampler to subgraph
|
|
await openContextMenu(comfyPage, 'KSampler')
|
|
await clickExactMenuItem(comfyPage, 'Convert to Subgraph')
|
|
|
|
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
|
|
await expect(subgraphNode).toBeVisible()
|
|
await expect(comfyPage.vueNodes.getNodeByTitle('KSampler')).toBeHidden()
|
|
|
|
// Unpack the subgraph
|
|
await openContextMenu(comfyPage, 'New Subgraph')
|
|
await clickExactMenuItem(comfyPage, 'Unpack Subgraph')
|
|
|
|
await expect(comfyPage.vueNodes.getNodeByTitle('KSampler')).toBeVisible()
|
|
await expect(
|
|
comfyPage.vueNodes.getNodeByTitle('New Subgraph')
|
|
).toBeHidden()
|
|
})
|
|
|
|
test('should open properties panel via Edit Subgraph Widgets', async ({
|
|
comfyPage
|
|
}) => {
|
|
// Convert to subgraph first
|
|
await openContextMenu(comfyPage, 'Empty Latent Image')
|
|
await clickExactMenuItem(comfyPage, 'Convert to Subgraph')
|
|
await expect(
|
|
comfyPage.vueNodes.getNodeByTitle('New Subgraph')
|
|
).toBeVisible()
|
|
|
|
// Right-click subgraph and edit widgets
|
|
await openContextMenu(comfyPage, 'New Subgraph')
|
|
await clickExactMenuItem(comfyPage, 'Edit Subgraph Widgets')
|
|
|
|
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
|
|
})
|
|
|
|
test('should add subgraph to library and find in node library', async ({
|
|
comfyPage
|
|
}) => {
|
|
// Convert to subgraph first
|
|
await openContextMenu(comfyPage, 'KSampler')
|
|
await clickExactMenuItem(comfyPage, 'Convert to Subgraph')
|
|
await expect(
|
|
comfyPage.vueNodes.getNodeByTitle('New Subgraph')
|
|
).toBeVisible()
|
|
|
|
// Add to library
|
|
await openContextMenu(comfyPage, 'New Subgraph')
|
|
await clickExactMenuItem(comfyPage, 'Add Subgraph to Library')
|
|
|
|
// Fill the blueprint name
|
|
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'visible' })
|
|
await comfyPage.nodeOps.fillPromptDialog('TestBlueprint')
|
|
|
|
// Open node library sidebar and search for the blueprint
|
|
await comfyPage.menu.nodeLibraryTab.tabButton.click()
|
|
const searchBox = comfyPage.page.getByRole('combobox', {
|
|
name: 'Search'
|
|
})
|
|
await searchBox.waitFor({ state: 'visible' })
|
|
await searchBox.fill('TestBlueprint')
|
|
|
|
await expect(comfyPage.page.getByText('TestBlueprint')).toBeVisible()
|
|
})
|
|
})
|
|
|
|
test.describe('Multi-Node Actions', () => {
|
|
const nodeTitles = ['Load Checkpoint', 'KSampler']
|
|
|
|
test('should batch rename selected nodes via context menu', async ({
|
|
comfyPage
|
|
}) => {
|
|
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
|
await clickExactMenuItem(comfyPage, 'Rename')
|
|
|
|
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'visible' })
|
|
await comfyPage.nodeOps.fillPromptDialog('MyNode')
|
|
|
|
await expect(comfyPage.vueNodes.getNodeByTitle('MyNode 1')).toBeVisible()
|
|
await expect(comfyPage.vueNodes.getNodeByTitle('MyNode 2')).toBeVisible()
|
|
})
|
|
|
|
test('should copy and paste selected nodes via context menu', async ({
|
|
comfyPage
|
|
}) => {
|
|
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
|
|
|
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
|
await clickExactMenuItem(comfyPage, 'Copy')
|
|
|
|
await comfyPage.page.evaluate(() => {
|
|
window.app!.canvas.pasteFromClipboard({ connectInputs: false })
|
|
})
|
|
await comfyPage.nextFrame()
|
|
|
|
await expect
|
|
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
|
.toBe(initialCount + nodeTitles.length)
|
|
})
|
|
|
|
test('should duplicate selected nodes via context menu', async ({
|
|
comfyPage
|
|
}) => {
|
|
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
|
|
|
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
|
await clickExactMenuItem(comfyPage, 'Duplicate')
|
|
|
|
await expect
|
|
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
|
.toBe(initialCount + nodeTitles.length)
|
|
})
|
|
|
|
test('should pin and unpin selected nodes via context menu', async ({
|
|
comfyPage
|
|
}) => {
|
|
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
|
await clickExactMenuItem(comfyPage, 'Pin')
|
|
|
|
for (const title of nodeTitles) {
|
|
const fixture = await comfyPage.vueNodes.getFixtureByTitle(title)
|
|
await expect(fixture.pinIndicator).toBeVisible()
|
|
}
|
|
|
|
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
|
await clickExactMenuItem(comfyPage, 'Unpin')
|
|
|
|
for (const title of nodeTitles) {
|
|
const fixture = await comfyPage.vueNodes.getFixtureByTitle(title)
|
|
await expect(fixture.pinIndicator).toBeHidden()
|
|
}
|
|
})
|
|
|
|
test('should bypass and remove bypass on selected nodes via context menu', async ({
|
|
comfyPage
|
|
}) => {
|
|
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
|
await clickExactMenuItem(comfyPage, 'Bypass')
|
|
|
|
for (const title of nodeTitles) {
|
|
const nodeRef = await getNodeRef(comfyPage, title)
|
|
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
|
|
await expect(getNodeWrapper(comfyPage, title)).toHaveClass(BYPASS_CLASS)
|
|
}
|
|
|
|
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
|
await clickExactMenuItem(comfyPage, 'Remove Bypass')
|
|
|
|
for (const title of nodeTitles) {
|
|
const nodeRef = await getNodeRef(comfyPage, title)
|
|
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
|
|
await expect(getNodeWrapper(comfyPage, title)).not.toHaveClass(
|
|
BYPASS_CLASS
|
|
)
|
|
}
|
|
})
|
|
|
|
test('should minimize and expand selected nodes via context menu', async ({
|
|
comfyPage
|
|
}) => {
|
|
const fixture1 =
|
|
await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
|
|
const fixture2 = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
|
|
|
await expect(fixture1.body).toBeVisible()
|
|
await expect(fixture2.body).toBeVisible()
|
|
|
|
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
|
await clickExactMenuItem(comfyPage, 'Minimize Node')
|
|
|
|
await expect(fixture1.body).toBeHidden()
|
|
await expect(fixture2.body).toBeHidden()
|
|
|
|
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
|
await clickExactMenuItem(comfyPage, 'Expand Node')
|
|
|
|
await expect(fixture1.body).toBeVisible()
|
|
await expect(fixture2.body).toBeVisible()
|
|
})
|
|
|
|
test('should frame selected nodes via context menu', async ({
|
|
comfyPage
|
|
}) => {
|
|
const initialGroupCount = await comfyPage.page.evaluate(
|
|
() => window.app!.graph.groups.length
|
|
)
|
|
|
|
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
|
await clickExactMenuItem(comfyPage, 'Frame Nodes')
|
|
|
|
await expect
|
|
.poll(() =>
|
|
comfyPage.page.evaluate(() => window.app!.graph.groups.length)
|
|
)
|
|
.toBe(initialGroupCount + 1)
|
|
})
|
|
|
|
test('should convert selected nodes to subgraph via context menu', async ({
|
|
comfyPage
|
|
}) => {
|
|
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
|
|
|
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
|
await clickExactMenuItem(comfyPage, 'Convert to Subgraph')
|
|
|
|
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
|
|
await expect(subgraphNode).toBeVisible()
|
|
|
|
await expect
|
|
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
|
.toBe(initialCount - nodeTitles.length + 1)
|
|
})
|
|
})
|
|
})
|