Compare commits

..

2 Commits

Author SHA1 Message Date
Glary-Bot
614d764e6a fix: forward LGraphNode to LiteGraph context-menu callbacks
The wrapper that adapts LiteGraph IContextMenuValue callbacks for
the Vue ContextMenu invoked them with the menu item itself as the
5th positional argument. LGraphCanvas.onMenuNode* handlers declare
the signature (value, options, e, menu, node) and dereference
node.graph, so passing item caused NullGraphError to be thrown and
swallowed by the surrounding try/catch — every LiteGraph-source
menu action that needed the node silently no-op'd (Collapse,
Expand, Resize, Mode, Colors, Shapes, Clone, etc).

Pass the LGraphNode that was already accepted as the second
parameter of convertContextMenuToOptions instead.

Adds property tests in contextMenuConverter.property.test.ts using
fast-check to lock the wrapper's argument forwarding contract for
arbitrary content / values, plus a targeted unit test pinning the
LGraphCanvas.onMenuNode* contract.
2026-05-13 19:58:25 +00:00
Glary-Bot
f11789378f test: comprehensive Vue right-click context menu e2e coverage
Adds a new e2e suite filling gaps left by the existing
contextMenu.spec.ts: Node Info, Color/Shape submenus, Delete,
Run Branch (output-node only / hidden for non-output), Open in
Mask Editor, Align Selected To submenu, Distribute Nodes
submenu, and widget-extra options (Favorite/Rename Widget) on
hovered widgets.

Refactors the existing contextMenu.spec.ts to share fixtures
via a new browser_tests/fixtures/utils/contextMenuTestHelpers.ts
so both files use one canonical set of openContextMenu /
clickExactMenuItem / openMultiNodeContextMenu / getNodeRef /
getNodeWrapper helpers.
2026-05-13 19:58:06 +00:00
14 changed files with 621 additions and 292 deletions

View 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
}

View File

@@ -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

View File

@@ -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()
})
}
)

View File

@@ -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 }) => {

View File

@@ -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)
})
})
}
)

View File

@@ -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')
})
})

View File

@@ -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>

View 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()
}
)
)
})
})

View File

@@ -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: () => {} },

View File

@@ -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)

View File

@@ -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

View File

@@ -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)
})
})
})

View File

@@ -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()
})
})
})

View File

@@ -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 {