mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 05:19:03 +00:00
Compare commits
2 Commits
glary/node
...
glary/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
614d764e6a | ||
|
|
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,32 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
|
||||
|
||||
test.describe(
|
||||
'Properties panel - Node Info via context menu',
|
||||
{ tag: '@vue-nodes' },
|
||||
() => {
|
||||
let panel: PropertiesPanelHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
})
|
||||
|
||||
test('opens the right side panel Info tab when clicked from the node context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await expect(panel.root).toBeHidden()
|
||||
|
||||
const fixture = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
await comfyPage.contextMenu.openForVueNode(fixture.header)
|
||||
await comfyPage.contextMenu.clickMenuItemExact('Node Info')
|
||||
|
||||
await expect(panel.root).toBeVisible()
|
||||
await expect(panel.getTab('Info')).toBeVisible()
|
||||
await expect(
|
||||
panel.contentArea.getByRole('heading', { name: 'Inputs' })
|
||||
).toBeVisible()
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -9,13 +9,13 @@ import { createI18n } from 'vue-i18n'
|
||||
import InfoButton from '@/components/graph/selectionToolbox/InfoButton.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { showNodeHelpMock } = vi.hoisted(() => ({
|
||||
showNodeHelpMock: vi.fn()
|
||||
const { openPanelMock } = vi.hoisted(() => ({
|
||||
openPanelMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useSelectionState', () => ({
|
||||
useSelectionState: () => ({
|
||||
showNodeHelp: showNodeHelpMock
|
||||
vi.mock('@/stores/workspace/rightSidePanelStore', () => ({
|
||||
useRightSidePanelStore: () => ({
|
||||
openPanel: openPanelMock
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -53,12 +53,12 @@ describe('InfoButton', () => {
|
||||
})
|
||||
}
|
||||
|
||||
it('should call showNodeHelp on click', async () => {
|
||||
it('should open the info panel on click', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Node Info' }))
|
||||
|
||||
expect(showNodeHelpMock).toHaveBeenCalledTimes(1)
|
||||
expect(openPanelMock).toHaveBeenCalledWith('info')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,15 +15,18 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useSelectionState } from '@/composables/graph/useSelectionState'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
const { showNodeHelp } = useSelectionState()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
|
||||
/**
|
||||
* Track node info button click and toggle node help.
|
||||
*/
|
||||
const onInfoClick = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'selection_toolbox_node_info_opened'
|
||||
})
|
||||
showNodeHelp()
|
||||
rightSidePanelStore.openPanel('info')
|
||||
}
|
||||
</script>
|
||||
|
||||
142
src/composables/graph/contextMenuConverter.property.test.ts
Normal file
142
src/composables/graph/contextMenuConverter.property.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import * as fc from 'fast-check'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { convertContextMenuToOptions } from './contextMenuConverter'
|
||||
|
||||
interface InvocationRecord {
|
||||
this: unknown
|
||||
args: unknown[]
|
||||
}
|
||||
|
||||
function makeFakeNode(id: number): LGraphNode {
|
||||
return { id, isFakeNode: true } as unknown as LGraphNode
|
||||
}
|
||||
|
||||
const arbItemContent = fc
|
||||
.stringMatching(/^[A-Za-z][A-Za-z0-9 ]{0,40}$/)
|
||||
.filter(
|
||||
(s) =>
|
||||
!['Properties', 'Title', 'Mode', 'Properties Panel'].includes(s) &&
|
||||
!s.startsWith('Copy (') &&
|
||||
s !== 'Collapse' &&
|
||||
s !== 'Expand' &&
|
||||
s !== 'Colors' &&
|
||||
s !== 'Shapes'
|
||||
)
|
||||
|
||||
describe('convertContextMenuToOptions callback wrapping (property)', () => {
|
||||
it('forwards the LGraphNode passed to convertContextMenuToOptions as the 5th arg of every callback', () => {
|
||||
fc.assert(
|
||||
fc.property(arbItemContent, fc.integer(), (content, nodeId) => {
|
||||
const invocations: InvocationRecord[] = []
|
||||
const callback = vi.fn(function (this: unknown, ...args: unknown[]) {
|
||||
invocations.push({ this: this, args })
|
||||
})
|
||||
|
||||
const node = makeFakeNode(nodeId)
|
||||
const options = convertContextMenuToOptions(
|
||||
[{ content, callback }],
|
||||
node,
|
||||
false
|
||||
)
|
||||
|
||||
const target = options.find((opt) => opt.label === content)
|
||||
expect(target, `Item "${content}" should be in result`).toBeDefined()
|
||||
expect(
|
||||
target?.action,
|
||||
`Item "${content}" should be invokable`
|
||||
).toBeTypeOf('function')
|
||||
|
||||
target?.action?.()
|
||||
|
||||
expect(callback).toHaveBeenCalledOnce()
|
||||
expect(invocations).toHaveLength(1)
|
||||
expect(
|
||||
invocations[0].args[4],
|
||||
'Wrapper must forward the LGraphNode as the 5th argument'
|
||||
).toBe(node)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('passes undefined as the 5th arg when no node is provided', () => {
|
||||
fc.assert(
|
||||
fc.property(arbItemContent, (content) => {
|
||||
const invocations: InvocationRecord[] = []
|
||||
const callback = vi.fn(function (this: unknown, ...args: unknown[]) {
|
||||
invocations.push({ this: this, args })
|
||||
})
|
||||
|
||||
const options = convertContextMenuToOptions(
|
||||
[{ content, callback }],
|
||||
undefined,
|
||||
false
|
||||
)
|
||||
|
||||
const target = options.find((opt) => opt.label === content)
|
||||
target?.action?.()
|
||||
|
||||
expect(invocations).toHaveLength(1)
|
||||
expect(invocations[0].args[4]).toBeUndefined()
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards item.value as the 1st arg', () => {
|
||||
fc.assert(
|
||||
fc.property(arbItemContent, fc.anything(), (content, value) => {
|
||||
const invocations: InvocationRecord[] = []
|
||||
const callback = vi.fn(function (this: unknown, ...args: unknown[]) {
|
||||
invocations.push({ this: this, args })
|
||||
})
|
||||
|
||||
const options = convertContextMenuToOptions(
|
||||
[{ content, value, callback }],
|
||||
makeFakeNode(0),
|
||||
false
|
||||
)
|
||||
|
||||
const target = options.find((opt) => opt.label === content)
|
||||
target?.action?.()
|
||||
|
||||
expect(invocations).toHaveLength(1)
|
||||
expect(invocations[0].args[0]).toStrictEqual(value)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('survives callbacks that throw without breaking subsequent items', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.uniqueArray(arbItemContent, { minLength: 2, maxLength: 6 }),
|
||||
(contents) => {
|
||||
const errorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const calls: string[] = []
|
||||
const items = contents.map((content, idx) => ({
|
||||
content,
|
||||
callback: () => {
|
||||
calls.push(content)
|
||||
if (idx === 0) throw new Error('boom')
|
||||
}
|
||||
}))
|
||||
|
||||
const options = convertContextMenuToOptions(
|
||||
items,
|
||||
makeFakeNode(1),
|
||||
false
|
||||
)
|
||||
|
||||
for (const opt of options) opt.action?.()
|
||||
|
||||
expect(calls).toEqual(contents)
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -194,6 +194,29 @@ describe('contextMenuConverter', () => {
|
||||
expect(result[0].disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('forwards the LGraphNode argument to the callback (LGraphCanvas.onMenuNode* contract)', () => {
|
||||
const fakeNode = { id: 42, isFakeNode: true } as unknown as Parameters<
|
||||
typeof convertContextMenuToOptions
|
||||
>[1]
|
||||
let receivedNode: unknown = 'NOT_CALLED'
|
||||
const callback = function (
|
||||
this: unknown,
|
||||
_value: unknown,
|
||||
_options: unknown,
|
||||
_e: unknown,
|
||||
_menu: unknown,
|
||||
node: unknown
|
||||
) {
|
||||
receivedNode = node
|
||||
}
|
||||
const items = [{ content: 'Custom Action', callback }]
|
||||
|
||||
const result = convertContextMenuToOptions(items, fakeNode, false)
|
||||
result[0].action?.()
|
||||
|
||||
expect(receivedNode).toBe(fakeNode)
|
||||
})
|
||||
|
||||
it('should apply structuring by default', () => {
|
||||
const items = [
|
||||
{ content: 'Copy', callback: () => {} },
|
||||
|
||||
@@ -427,10 +427,7 @@ export function convertContextMenuToOptions(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle callback (only if not disabled and not a submenu)
|
||||
else if (item.callback && !item.disabled) {
|
||||
// Wrap the callback to match the () => void signature
|
||||
} else if (item.callback && !item.disabled) {
|
||||
option.action = () => {
|
||||
try {
|
||||
void item.callback?.call(
|
||||
@@ -439,7 +436,7 @@ export function convertContextMenuToOptions(
|
||||
{},
|
||||
undefined,
|
||||
undefined,
|
||||
item
|
||||
node
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Error executing context menu callback:', error)
|
||||
|
||||
@@ -126,8 +126,6 @@ export function useMoreOptionsMenu() {
|
||||
selectedNodes,
|
||||
nodeDef,
|
||||
showNodeHelp,
|
||||
isSingleNode,
|
||||
isSingleSubgraph,
|
||||
hasSubgraphs: hasSubgraphsComputed,
|
||||
hasImageNode,
|
||||
hasOutputNodesSelected,
|
||||
@@ -153,7 +151,9 @@ export function useMoreOptionsMenu() {
|
||||
const {
|
||||
getBasicSelectionOptions,
|
||||
getMultipleNodesOptions,
|
||||
getSubgraphOptions
|
||||
getSubgraphOptions,
|
||||
getDeleteOption,
|
||||
getAlignmentOptions
|
||||
} = useSelectionMenuOptions()
|
||||
|
||||
const hasSubgraphs = hasSubgraphsComputed
|
||||
@@ -232,6 +232,7 @@ export function useMoreOptionsMenu() {
|
||||
)
|
||||
if (hasMultipleNodes.value) {
|
||||
options.push(...getMultipleNodesOptions())
|
||||
options.push(...getAlignmentOptions())
|
||||
}
|
||||
if (groupContext) {
|
||||
options.push(getFitGroupToNodesOption(groupContext))
|
||||
@@ -245,8 +246,7 @@ export function useMoreOptionsMenu() {
|
||||
options.push({ type: 'divider' })
|
||||
|
||||
// Section 4: Node properties (Node Info, Shape, Color)
|
||||
// Match the right side panel's Info tab visibility: single non-subgraph node.
|
||||
if (nodeDef.value && isSingleNode.value && !isSingleSubgraph.value) {
|
||||
if (nodeDef.value) {
|
||||
options.push(getNodeInfoOption(showNodeHelp))
|
||||
}
|
||||
if (groupContext) {
|
||||
@@ -288,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
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { useNodeMenuOptions } from '@/composables/graph/useNodeMenuOptions'
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...(actual as object),
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key.split('.').pop() ?? key
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/graph/useNodeCustomization', () => ({
|
||||
useNodeCustomization: () => ({
|
||||
shapeOptions: [],
|
||||
applyShape: vi.fn(),
|
||||
applyColor: vi.fn(),
|
||||
colorOptions: [],
|
||||
isLightTheme: { value: false }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useSelectedNodeActions', () => ({
|
||||
useSelectedNodeActions: () => ({
|
||||
adjustNodeSize: vi.fn(),
|
||||
toggleNodeCollapse: vi.fn(),
|
||||
toggleNodePin: vi.fn(),
|
||||
toggleNodeBypass: vi.fn(),
|
||||
runBranch: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useNodeMenuOptions', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ createSpy: vi.fn }))
|
||||
})
|
||||
|
||||
describe('getNodeInfoOption', () => {
|
||||
test('builds a menu option labeled "Node Info"', () => {
|
||||
const { getNodeInfoOption } = useNodeMenuOptions()
|
||||
const option = getNodeInfoOption(vi.fn())
|
||||
|
||||
expect(option.label).toBe('Node Info')
|
||||
expect(option.icon).toBe('icon-[lucide--info]')
|
||||
})
|
||||
|
||||
test('invokes the supplied showNodeHelp callback when the option is activated', () => {
|
||||
const showNodeHelp = vi.fn()
|
||||
const { getNodeInfoOption } = useNodeMenuOptions()
|
||||
const option = getNodeInfoOption(showNodeHelp)
|
||||
|
||||
option.action?.()
|
||||
|
||||
expect(showNodeHelp).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,10 +3,9 @@ import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { useSelectionState } from '@/composables/graph/useSelectionState'
|
||||
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
|
||||
import {
|
||||
@@ -14,10 +13,14 @@ import {
|
||||
createMockPositionable
|
||||
} from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
// Mock composables
|
||||
vi.mock('@/composables/sidebarTabs/useNodeLibrarySidebarTab', () => ({
|
||||
useNodeLibrarySidebarTab: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
isLGraphNode: vi.fn(),
|
||||
isImageNode: vi.fn(),
|
||||
isLoad3dNode: vi.fn(() => false)
|
||||
isImageNode: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/nodeFilterUtil', () => ({
|
||||
@@ -40,13 +43,22 @@ describe('useSelectionState', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Create testing Pinia instance
|
||||
setActivePinia(
|
||||
createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
stubActions: false
|
||||
createSpy: vi.fn
|
||||
})
|
||||
)
|
||||
|
||||
// Setup mock composables
|
||||
vi.mocked(useNodeLibrarySidebarTab).mockReturnValue({
|
||||
id: 'node-library-tab',
|
||||
title: 'Node Library',
|
||||
type: 'custom',
|
||||
render: () => null
|
||||
} as ReturnType<typeof useNodeLibrarySidebarTab>)
|
||||
|
||||
// Setup mock utility functions
|
||||
vi.mocked(isLGraphNode).mockImplementation((item: unknown) => {
|
||||
const typedItem = item as { isNode?: boolean }
|
||||
return typedItem?.isNode !== false
|
||||
@@ -175,110 +187,4 @@ describe('useSelectionState', () => {
|
||||
expect(newIsPinned).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('showNodeHelp', () => {
|
||||
test('opens the right side panel Info tab when a single node is selected', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const node = createMockLGraphNode({ id: 10, type: 'KSampler' })
|
||||
canvasStore.$state.selectedItems = [node]
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
vi.spyOn(nodeDefStore, 'fromLGraphNode').mockReturnValue({
|
||||
nodePath: 'KSampler'
|
||||
} as ReturnType<typeof nodeDefStore.fromLGraphNode>)
|
||||
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const openPanelSpy = vi
|
||||
.spyOn(rightSidePanelStore, 'openPanel')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const { showNodeHelp } = useSelectionState()
|
||||
showNodeHelp()
|
||||
|
||||
expect(openPanelSpy).toHaveBeenCalledWith('info')
|
||||
})
|
||||
|
||||
test('does nothing when no single node is selected', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
canvasStore.$state.selectedItems = []
|
||||
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const openPanelSpy = vi
|
||||
.spyOn(rightSidePanelStore, 'openPanel')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const { showNodeHelp } = useSelectionState()
|
||||
showNodeHelp()
|
||||
|
||||
expect(openPanelSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('does nothing when selection includes more than one item', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const node = createMockLGraphNode({ id: 11, type: 'KSampler' })
|
||||
const otherItem = { id: 12, isNode: false } as unknown as Parameters<
|
||||
typeof canvasStore.$state.selectedItems.push
|
||||
>[0]
|
||||
canvasStore.$state.selectedItems = [node, otherItem]
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
vi.spyOn(nodeDefStore, 'fromLGraphNode').mockReturnValue({
|
||||
nodePath: 'KSampler'
|
||||
} as ReturnType<typeof nodeDefStore.fromLGraphNode>)
|
||||
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const openPanelSpy = vi
|
||||
.spyOn(rightSidePanelStore, 'openPanel')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const { showNodeHelp } = useSelectionState()
|
||||
showNodeHelp()
|
||||
|
||||
expect(openPanelSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('does nothing when the selected node is a subgraph node', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const subgraphNode = createMockLGraphNode({
|
||||
id: 13,
|
||||
type: 'Subgraph'
|
||||
})
|
||||
Object.assign(subgraphNode, { isSubgraphNode: () => true })
|
||||
canvasStore.$state.selectedItems = [subgraphNode]
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
vi.spyOn(nodeDefStore, 'fromLGraphNode').mockReturnValue({
|
||||
nodePath: 'Subgraph'
|
||||
} as ReturnType<typeof nodeDefStore.fromLGraphNode>)
|
||||
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const openPanelSpy = vi
|
||||
.spyOn(rightSidePanelStore, 'openPanel')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const { showNodeHelp } = useSelectionState()
|
||||
showNodeHelp()
|
||||
|
||||
expect(openPanelSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('does nothing when no node definition is available', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const node = createMockLGraphNode({ id: 14, type: 'UnknownType' })
|
||||
canvasStore.$state.selectedItems = [node]
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
vi.spyOn(nodeDefStore, 'fromLGraphNode').mockReturnValue(null)
|
||||
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const openPanelSpy = vi
|
||||
.spyOn(rightSidePanelStore, 'openPanel')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const { showNodeHelp } = useSelectionState()
|
||||
showNodeHelp()
|
||||
|
||||
expect(openPanelSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { isImageNode, isLGraphNode, isLoad3dNode } from '@/utils/litegraphUtil'
|
||||
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
|
||||
|
||||
@@ -23,7 +25,9 @@ export interface NodeSelectionState {
|
||||
export function useSelectionState() {
|
||||
const canvasStore = useCanvasStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
const { id: nodeLibraryTabId } = useNodeLibrarySidebarTab()
|
||||
|
||||
const { selectedItems } = storeToRefs(canvasStore)
|
||||
|
||||
@@ -94,10 +98,27 @@ export function useSelectionState() {
|
||||
const computeSelectionFlags = (): NodeSelectionState =>
|
||||
computeSelectionStatesFromNodes(selectedNodes.value)
|
||||
|
||||
/** Open the right side panel Info tab for the selected node. */
|
||||
/** Toggle node help sidebar/panel for the single selected node (if any). */
|
||||
const showNodeHelp = () => {
|
||||
if (!nodeDef.value || !isSingleNode.value || isSingleSubgraph.value) return
|
||||
rightSidePanelStore.openPanel('info')
|
||||
const def = nodeDef.value
|
||||
if (!def) return
|
||||
|
||||
const isSidebarActive =
|
||||
sidebarTabStore.activeSidebarTabId === nodeLibraryTabId
|
||||
const currentHelpNode = nodeHelpStore.currentHelpNode
|
||||
const isSameNodeHelpOpen =
|
||||
isSidebarActive &&
|
||||
nodeHelpStore.isHelpOpen &&
|
||||
currentHelpNode?.nodePath === def.nodePath
|
||||
|
||||
if (isSameNodeHelpOpen) {
|
||||
nodeHelpStore.closeHelp()
|
||||
sidebarTabStore.toggleSidebarTab(nodeLibraryTabId)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isSidebarActive) sidebarTabStore.toggleSidebarTab(nodeLibraryTabId)
|
||||
nodeHelpStore.openHelp(def)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user