mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
## Summary Simplifies test setup for common settings ## Changes - **What**: - add vue-nodes tag to auto enable nodes 2.0 - remove UseNewMenu Top as this is default ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11184-test-Simplify-vue-node-menu-test-setup-3416d73d3650815487e0c357d28761fe) by [Unito](https://www.unito.io)
530 lines
18 KiB
TypeScript
530 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')
|
|
|
|
const titleInput = comfyPage.page.getByTestId(TestIds.node.titleInput)
|
|
await titleInput.waitFor({ state: 'visible' })
|
|
await titleInput.fill('My Renamed Sampler')
|
|
await titleInput.press('Enter')
|
|
await comfyPage.nextFrame()
|
|
|
|
const renamedNode =
|
|
comfyPage.vueNodes.getNodeByTitle('My Renamed Sampler')
|
|
await expect(renamedNode).toBeVisible()
|
|
})
|
|
|
|
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 to group node via context menu', async ({
|
|
comfyPage
|
|
}) => {
|
|
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
|
await clickExactMenuItem(comfyPage, 'Convert to Group Node')
|
|
|
|
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'visible' })
|
|
await comfyPage.nodeOps.fillPromptDialog('TestGroupNode')
|
|
|
|
await expect
|
|
.poll(async () => {
|
|
const groupNodes = await comfyPage.nodeOps.getNodeRefsByType(
|
|
'workflow>TestGroupNode'
|
|
)
|
|
return groupNodes.length
|
|
})
|
|
.toBe(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)
|
|
})
|
|
})
|
|
})
|