mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 21:38:52 +00:00
Compare commits
1 Commits
drjkl/subg
...
glary/cont
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f11789378f |
90
browser_tests/fixtures/utils/contextMenuTestHelpers.ts
Normal file
90
browser_tests/fixtures/utils/contextMenuTestHelpers.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import { comfyExpect as expect } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
|
||||
/**
|
||||
* Click a menu item by exact label and wait for the menu to close.
|
||||
*/
|
||||
export async function clickExactMenuItem(comfyPage: ComfyPage, name: string) {
|
||||
await comfyPage.contextMenu.clickMenuItemExact(name)
|
||||
await expect(comfyPage.contextMenu.primeVueMenu).toBeHidden()
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the context menu for a single Vue node by title.
|
||||
* Selects the node first (required for correct menu items).
|
||||
*/
|
||||
export async function openContextMenu(
|
||||
comfyPage: ComfyPage,
|
||||
nodeTitle: string
|
||||
): Promise<Locator> {
|
||||
const fixture = await comfyPage.vueNodes.getFixtureByTitle(nodeTitle)
|
||||
await comfyPage.contextMenu.openForVueNode(fixture.header)
|
||||
return comfyPage.contextMenu.primeVueMenu
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the context menu for multiple selected Vue nodes.
|
||||
*/
|
||||
export async function openMultiNodeContextMenu(
|
||||
comfyPage: ComfyPage,
|
||||
titles: string[]
|
||||
): Promise<Locator> {
|
||||
if (titles.length === 0) {
|
||||
throw new Error('openMultiNodeContextMenu requires at least one title')
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the inner wrapper locator for a Vue node by title.
|
||||
*/
|
||||
export function getNodeWrapper(
|
||||
comfyPage: ComfyPage,
|
||||
nodeTitle: string
|
||||
): Locator {
|
||||
return comfyPage.vueNodes
|
||||
.getNodeByTitle(nodeTitle)
|
||||
.getByTestId(TestIds.node.innerWrapper)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first NodeReference matching the given title.
|
||||
*/
|
||||
export async function getNodeRef(
|
||||
comfyPage: ComfyPage,
|
||||
nodeTitle: string
|
||||
): Promise<NodeReference> {
|
||||
const refs = await comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
|
||||
const firstRef = refs[0]
|
||||
if (!firstRef) {
|
||||
throw new Error(`No node found with title "${nodeTitle}"`)
|
||||
}
|
||||
return firstRef
|
||||
}
|
||||
@@ -51,7 +51,7 @@ test.describe(
|
||||
return menu
|
||||
}
|
||||
|
||||
test('last menu item "Remove" is reachable via scroll', async ({
|
||||
test('last menu item "Delete" is reachable via scroll', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const menu = await openMoreOptions(comfyPage)
|
||||
@@ -67,26 +67,26 @@ test.describe(
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
// "Remove" is the last item in the More Options menu.
|
||||
// "Delete" is the last item in the More Options menu.
|
||||
// It must become reachable by scrolling the bounded menu list.
|
||||
const removeItem = menu.getByText('Remove', { exact: true })
|
||||
const deleteItem = menu.getByText('Delete', { exact: true })
|
||||
const didScroll = await rootList.evaluate((el) => {
|
||||
const previousScrollTop = el.scrollTop
|
||||
el.scrollTo({ top: el.scrollHeight })
|
||||
return el.scrollTop > previousScrollTop
|
||||
})
|
||||
expect(didScroll).toBe(true)
|
||||
await expect(removeItem).toBeVisible()
|
||||
await expect(deleteItem).toBeVisible()
|
||||
})
|
||||
|
||||
test('last menu item "Remove" is clickable and removes the node', async ({
|
||||
test('last menu item "Delete" is clickable and removes the node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const menu = await openMoreOptions(comfyPage)
|
||||
|
||||
const removeItem = menu.getByText('Remove', { exact: true })
|
||||
await removeItem.scrollIntoViewIfNeeded()
|
||||
await removeItem.click()
|
||||
const deleteItem = menu.getByText('Delete', { exact: true })
|
||||
await deleteItem.scrollIntoViewIfNeeded()
|
||||
await deleteItem.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// The node should be removed from the graph
|
||||
|
||||
@@ -1,65 +1,18 @@
|
||||
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'
|
||||
import {
|
||||
clickExactMenuItem,
|
||||
getNodeRef,
|
||||
getNodeWrapper,
|
||||
openContextMenu,
|
||||
openMultiNodeContextMenu
|
||||
} from '@e2e/fixtures/utils/contextMenuTestHelpers'
|
||||
|
||||
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 }) => {
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import {
|
||||
clickExactMenuItem,
|
||||
getNodeRef,
|
||||
openContextMenu,
|
||||
openMultiNodeContextMenu
|
||||
} from '@e2e/fixtures/utils/contextMenuTestHelpers'
|
||||
|
||||
test.describe(
|
||||
'Vue Node Context Menu — Extended Coverage',
|
||||
{ tag: '@vue-nodes' },
|
||||
() => {
|
||||
test.describe('Single Node Actions', () => {
|
||||
test.fixme('should open node info via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openContextMenu(comfyPage, 'KSampler')
|
||||
await clickExactMenuItem(comfyPage, 'Node Info')
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.root)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('should change node color via Color submenu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodeRef = await getNodeRef(comfyPage, 'KSampler')
|
||||
const initialColor = await nodeRef.getProperty<string | undefined>(
|
||||
'color'
|
||||
)
|
||||
|
||||
await openContextMenu(comfyPage, 'KSampler')
|
||||
const menu = comfyPage.contextMenu.primeVueMenu
|
||||
await menu.getByRole('menuitem', { name: 'Color', exact: true }).click()
|
||||
|
||||
const redSwatch = comfyPage.page.getByTitle('Red', { exact: true })
|
||||
await expect(redSwatch.first()).toBeVisible()
|
||||
await redSwatch.first().click()
|
||||
|
||||
await expect
|
||||
.poll(() => nodeRef.getProperty<string | undefined>('color'))
|
||||
.not.toBe(initialColor)
|
||||
})
|
||||
|
||||
test('should change node shape via Shape submenu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodeRef = await getNodeRef(comfyPage, 'KSampler')
|
||||
|
||||
await openContextMenu(comfyPage, 'KSampler')
|
||||
const menu = comfyPage.contextMenu.primeVueMenu
|
||||
await menu.getByRole('menuitem', { name: 'Shape', exact: true }).hover()
|
||||
|
||||
const boxItem = menu
|
||||
.getByRole('menuitem', { name: 'Box', exact: true })
|
||||
.last()
|
||||
await expect(boxItem).toBeVisible()
|
||||
await boxItem.click()
|
||||
|
||||
await expect.poll(() => nodeRef.getProperty<number>('shape')).toBe(1)
|
||||
})
|
||||
|
||||
test('should delete node via Delete context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await openContextMenu(comfyPage, 'KSampler')
|
||||
await clickExactMenuItem(comfyPage, 'Delete')
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBe(initialCount - 1)
|
||||
})
|
||||
|
||||
test('should not show Run Branch for non-output nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const menu = await openContextMenu(comfyPage, 'Load Checkpoint')
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(
|
||||
menu.getByRole('menuitem', {
|
||||
name: 'Run Branch',
|
||||
exact: true
|
||||
})
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test.fixme('should show Run Branch for output nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeByTitle('Save Image'),
|
||||
'Default workflow must contain Save Image node'
|
||||
).toBeVisible()
|
||||
|
||||
await openContextMenu(comfyPage, 'Save Image')
|
||||
await expect(
|
||||
comfyPage.contextMenu.primeVueMenu.getByRole('menuitem', {
|
||||
name: 'Run Branch',
|
||||
exact: true
|
||||
})
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Image Node Actions', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
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 open mask editor via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openContextMenu(comfyPage, 'Load Image')
|
||||
await clickExactMenuItem(comfyPage, 'Open in Mask Editor')
|
||||
|
||||
const maskEditorDialog = comfyPage.page.locator('.mask-editor-dialog')
|
||||
await expect(maskEditorDialog).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Multi-Node Actions', () => {
|
||||
const nodeTitles = ['Load Checkpoint', 'KSampler']
|
||||
|
||||
test('should align selected nodes via Align Selected To submenu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodeRef0 = await getNodeRef(comfyPage, nodeTitles[0])
|
||||
const nodeRef1 = await getNodeRef(comfyPage, nodeTitles[1])
|
||||
|
||||
const initialPos0 = await nodeRef0.getPosition()
|
||||
const initialPos1 = await nodeRef1.getPosition()
|
||||
expect(
|
||||
initialPos0.y !== initialPos1.y,
|
||||
'Nodes should start at different y positions'
|
||||
).toBe(true)
|
||||
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
const menu = comfyPage.contextMenu.primeVueMenu
|
||||
await menu
|
||||
.getByRole('menuitem', {
|
||||
name: 'Align Selected To',
|
||||
exact: true
|
||||
})
|
||||
.hover()
|
||||
|
||||
const topItem = menu
|
||||
.getByRole('menuitem', { name: 'Top', exact: true })
|
||||
.last()
|
||||
await expect(topItem).toBeVisible()
|
||||
await topItem.click()
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const pos0 = await nodeRef0.getPosition()
|
||||
const pos1 = await nodeRef1.getPosition()
|
||||
return Math.abs(pos0.y - pos1.y)
|
||||
})
|
||||
.toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('should distribute selected nodes via Distribute Nodes submenu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const threeNodes = ['Load Checkpoint', 'KSampler', 'Empty Latent Image']
|
||||
|
||||
await openMultiNodeContextMenu(comfyPage, threeNodes)
|
||||
const menu = comfyPage.contextMenu.primeVueMenu
|
||||
await menu
|
||||
.getByRole('menuitem', {
|
||||
name: 'Distribute Nodes',
|
||||
exact: true
|
||||
})
|
||||
.hover()
|
||||
|
||||
const horizontalItem = menu
|
||||
.getByRole('menuitem', {
|
||||
name: 'Horizontal',
|
||||
exact: true
|
||||
})
|
||||
.last()
|
||||
await expect(horizontalItem).toBeVisible()
|
||||
await horizontalItem.click()
|
||||
|
||||
const nodeRef0 = await getNodeRef(comfyPage, threeNodes[0])
|
||||
const nodeRef1 = await getNodeRef(comfyPage, threeNodes[1])
|
||||
const nodeRef2 = await getNodeRef(comfyPage, threeNodes[2])
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bounds = await Promise.all([
|
||||
nodeRef0.getBounding(),
|
||||
nodeRef1.getBounding(),
|
||||
nodeRef2.getBounding()
|
||||
])
|
||||
const sorted = bounds.toSorted((a, b) => a.x - b.x)
|
||||
const gap1 = sorted[1].x - (sorted[0].x + sorted[0].width)
|
||||
const gap2 = sorted[2].x - (sorted[1].x + sorted[1].width)
|
||||
return Math.abs(gap1 - gap2)
|
||||
})
|
||||
.toBeLessThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Menu Visibility Invariants', () => {
|
||||
test('should show Delete menu item for any node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openContextMenu(comfyPage, 'KSampler')
|
||||
await expect(
|
||||
comfyPage.contextMenu.primeVueMenu.getByRole('menuitem', {
|
||||
name: 'Delete',
|
||||
exact: true
|
||||
})
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Widget Extra Options', () => {
|
||||
test('should show widget-specific options when right-clicking a named widget', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const widgetLocator = comfyPage.vueNodes.getWidgetByName(
|
||||
'KSampler',
|
||||
'seed'
|
||||
)
|
||||
await expect(
|
||||
widgetLocator,
|
||||
'KSampler must expose a "seed" widget'
|
||||
).toBeVisible()
|
||||
|
||||
await widgetLocator.hover()
|
||||
await widgetLocator.dispatchEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 2
|
||||
})
|
||||
|
||||
const menu = comfyPage.contextMenu.primeVueMenu
|
||||
await menu.waitFor({ state: 'visible' })
|
||||
|
||||
const menuItems = menu.getByRole('menuitem')
|
||||
const labels = await menuItems.allTextContents()
|
||||
const trimmedLabels = labels.map((l) => l.trim())
|
||||
|
||||
const hasFavoriteOrRename = trimmedLabels.some(
|
||||
(label) =>
|
||||
label.startsWith('Favorite Widget') ||
|
||||
label.startsWith('Unfavorite Widget') ||
|
||||
label.startsWith('Rename Widget')
|
||||
)
|
||||
|
||||
expect(
|
||||
hasFavoriteOrRename,
|
||||
'Widget-specific menu options (Favorite/Unfavorite/Rename Widget) should appear for the "seed" widget'
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -151,7 +151,9 @@ export function useMoreOptionsMenu() {
|
||||
const {
|
||||
getBasicSelectionOptions,
|
||||
getMultipleNodesOptions,
|
||||
getSubgraphOptions
|
||||
getSubgraphOptions,
|
||||
getDeleteOption,
|
||||
getAlignmentOptions
|
||||
} = useSelectionMenuOptions()
|
||||
|
||||
const hasSubgraphs = hasSubgraphsComputed
|
||||
@@ -230,6 +232,7 @@ export function useMoreOptionsMenu() {
|
||||
)
|
||||
if (hasMultipleNodes.value) {
|
||||
options.push(...getMultipleNodesOptions())
|
||||
options.push(...getAlignmentOptions())
|
||||
}
|
||||
if (groupContext) {
|
||||
options.push(getFitGroupToNodesOption(groupContext))
|
||||
@@ -285,6 +288,8 @@ export function useMoreOptionsMenu() {
|
||||
}
|
||||
}
|
||||
|
||||
options.push(getDeleteOption())
|
||||
|
||||
// Section 6 & 7: Extensions and Delete are handled by buildStructuredMenu
|
||||
|
||||
// Mark all Vue options with source
|
||||
|
||||
Reference in New Issue
Block a user