mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-21 21:09:00 +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,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)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -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