Files
ComfyUI_frontend/tests-ui/tests/extensions/contextMenuExtension.test.ts
Johnpaul Chiwetelu b3da6cf1b4 Contextmenu extension migration (#5993)
This pull request refactors how context menu items are contributed by
extensions in the LiteGraph-based canvas. The legacy monkey-patching
approach for adding context menu options is replaced by a new, explicit
API (`getCanvasMenuItems` and `getNodeMenuItems`) for extensions. A
compatibility layer is added to support legacy extensions and warn
developers about deprecated usage. The changes improve maintainability,
extension interoperability, and migration to the new context menu
system.

### Context Menu System Refactor

* Introduced a new API for extensions to contribute context menu items
via `getCanvasMenuItems` and `getNodeMenuItems` methods, replacing
legacy monkey-patching of `LGraphCanvas.prototype.getCanvasMenuOptions`.
Major extension files (`groupNode.ts`, `groupOptions.ts`,
`nodeTemplates.ts`) now use this new API.
[[1]](diffhunk://#diff-b29f141b89433027e7bb7cde57fad84f9e97ffbe5c58040d3e0fdb7905022917L1779-R1771)
[[2]](diffhunk://#diff-91169f3a27ff8974d5c8fc3346bd99c07bdfb5399984484630125fdd647ff02fL232-R239)
[[3]](diffhunk://#diff-04c18583d2dbfc013888e4c02fd432c250acbcecdef82bf7f6d9fd888e632a6eL447-R458)
* Added a compatibility layer (`legacyMenuCompat` in
`contextMenuCompat.ts`) to detect and warn when legacy monkey-patching
is used, and to extract legacy-added menu items for backward
compatibility.
[[1]](diffhunk://#diff-2b724cb107c04e290369fb927e2ae9fad03be9e617a7d4de2487deab89d0d018R2-R45)
[[2]](diffhunk://#diff-d3a8284ec16ae3f9512e33abe44ae653ed1aa45c9926485ef6270cc8d2b94ae6R1-R115)

### Extension Migration

* Refactored core extensions (`groupNode`, `groupOptions`, and
`nodeTemplates`) to implement the new context menu API, moving menu item
logic out of monkey-patched methods and into explicit extension methods.
[[1]](diffhunk://#diff-b29f141b89433027e7bb7cde57fad84f9e97ffbe5c58040d3e0fdb7905022917L1633-L1683)
[[2]](diffhunk://#diff-91169f3a27ff8974d5c8fc3346bd99c07bdfb5399984484630125fdd647ff02fL19-R77)
[[3]](diffhunk://#diff-04c18583d2dbfc013888e4c02fd432c250acbcecdef82bf7f6d9fd888e632a6eL366-R373)

### Type and Import Cleanup

* Updated imports for context menu types (`IContextMenuValue`) across
affected files for consistency with the new API.
[[1]](diffhunk://#diff-b29f141b89433027e7bb7cde57fad84f9e97ffbe5c58040d3e0fdb7905022917R4-L7)
[[2]](diffhunk://#diff-91169f3a27ff8974d5c8fc3346bd99c07bdfb5399984484630125fdd647ff02fL1-R11)
[[3]](diffhunk://#diff-04c18583d2dbfc013888e4c02fd432c250acbcecdef82bf7f6d9fd888e632a6eL2-R6)
[[4]](diffhunk://#diff-bde0dce9fe2403685d27b0e94a938c3d72824d02d01d1fd6167a0dddc6e585ddR10)

### Backward Compatibility and Migration Guidance

* The compatibility layer logs a deprecation warning to the console when
legacy monkey-patching is detected, helping developers migrate to the
new API.

---

These changes collectively modernize the context menu extension
mechanism, improve code clarity, and provide a migration path for legacy
extensions.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5993-Contextmenu-extension-migration-2876d73d3650813fae07c1141679637a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-10-28 04:02:28 +01:00

232 lines
7.3 KiB
TypeScript

import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useExtensionService } from '@/services/extensionService'
import { useExtensionStore } from '@/stores/extensionStore'
import type { ComfyExtension } from '@/types/comfy'
describe('Context Menu Extension API', () => {
let mockCanvas: LGraphCanvas
let mockNode: LGraphNode
let extensionStore: ReturnType<typeof useExtensionStore>
let extensionService: ReturnType<typeof useExtensionService>
// Mock menu items
const canvasMenuItem1: IContextMenuValue = {
content: 'Canvas Item 1',
callback: () => {}
}
const canvasMenuItem2: IContextMenuValue = {
content: 'Canvas Item 2',
callback: () => {}
}
const nodeMenuItem1: IContextMenuValue = {
content: 'Node Item 1',
callback: () => {}
}
const nodeMenuItem2: IContextMenuValue = {
content: 'Node Item 2',
callback: () => {}
}
// Mock extensions
const createCanvasMenuExtension = (
name: string,
items: IContextMenuValue[]
): ComfyExtension => ({
name,
getCanvasMenuItems: () => items
})
const createNodeMenuExtension = (
name: string,
items: IContextMenuValue[]
): ComfyExtension => ({
name,
getNodeMenuItems: () => items
})
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
extensionStore = useExtensionStore()
extensionService = useExtensionService()
mockCanvas = {
graph_mouse: [100, 100],
selectedItems: new Set()
} as unknown as LGraphCanvas
mockNode = {
id: 1,
type: 'TestNode',
pos: [0, 0]
} as unknown as LGraphNode
})
describe('collectCanvasMenuItems', () => {
it('should call getCanvasMenuItems and collect into flat array', () => {
const ext1 = createCanvasMenuExtension('Extension 1', [canvasMenuItem1])
const ext2 = createCanvasMenuExtension('Extension 2', [
canvasMenuItem2,
{ content: 'Item 3', callback: () => {} }
])
extensionStore.registerExtension(ext1)
extensionStore.registerExtension(ext2)
const items: IContextMenuValue[] = extensionService
.invokeExtensions('getCanvasMenuItems', mockCanvas)
.flat()
expect(items).toHaveLength(3)
expect(items[0]).toMatchObject({ content: 'Canvas Item 1' })
expect(items[1]).toMatchObject({ content: 'Canvas Item 2' })
expect(items[2]).toMatchObject({ content: 'Item 3' })
})
it('should support submenus and separators', () => {
const extension = createCanvasMenuExtension('Test Extension', [
{
content: 'Menu with Submenu',
has_submenu: true,
submenu: {
options: [
{ content: 'Submenu Item 1', callback: () => {} },
{ content: 'Submenu Item 2', callback: () => {} }
]
}
},
null as unknown as IContextMenuValue,
{ content: 'After Separator', callback: () => {} }
])
extensionStore.registerExtension(extension)
const items: IContextMenuValue[] = extensionService
.invokeExtensions('getCanvasMenuItems', mockCanvas)
.flat()
expect(items).toHaveLength(3)
expect(items[0].content).toBe('Menu with Submenu')
expect(items[0].submenu?.options).toHaveLength(2)
expect(items[1]).toBeNull()
expect(items[2].content).toBe('After Separator')
})
it('should skip extensions without getCanvasMenuItems', () => {
const canvasExtension = createCanvasMenuExtension('Canvas Ext', [
canvasMenuItem1
])
const extensionWithoutCanvasMenu: ComfyExtension = {
name: 'No Canvas Menu'
}
extensionStore.registerExtension(canvasExtension)
extensionStore.registerExtension(extensionWithoutCanvasMenu)
const items: IContextMenuValue[] = extensionService
.invokeExtensions('getCanvasMenuItems', mockCanvas)
.flat()
expect(items).toHaveLength(1)
expect(items[0].content).toBe('Canvas Item 1')
})
it('should not duplicate menu items when collected multiple times', () => {
const extension = createCanvasMenuExtension('Test Extension', [
canvasMenuItem1,
canvasMenuItem2
])
extensionStore.registerExtension(extension)
// Collect items multiple times (simulating repeated menu opens)
const items1: IContextMenuValue[] = extensionService
.invokeExtensions('getCanvasMenuItems', mockCanvas)
.flat()
const items2: IContextMenuValue[] = extensionService
.invokeExtensions('getCanvasMenuItems', mockCanvas)
.flat()
// Both collections should have the same items (no duplication)
expect(items1).toHaveLength(2)
expect(items2).toHaveLength(2)
// Verify items are unique by checking their content
const contents1 = items1.map((item) => item.content)
const uniqueContents1 = new Set(contents1)
expect(uniqueContents1.size).toBe(contents1.length)
const contents2 = items2.map((item) => item.content)
const uniqueContents2 = new Set(contents2)
expect(uniqueContents2.size).toBe(contents2.length)
})
})
describe('collectNodeMenuItems', () => {
it('should call getNodeMenuItems and collect into flat array', () => {
const ext1 = createNodeMenuExtension('Extension 1', [nodeMenuItem1])
const ext2 = createNodeMenuExtension('Extension 2', [
nodeMenuItem2,
{ content: 'Item 3', callback: () => {} }
])
extensionStore.registerExtension(ext1)
extensionStore.registerExtension(ext2)
const items: IContextMenuValue[] = extensionService
.invokeExtensions('getNodeMenuItems', mockNode)
.flat()
expect(items).toHaveLength(3)
expect(items[0]).toMatchObject({ content: 'Node Item 1' })
expect(items[1]).toMatchObject({ content: 'Node Item 2' })
})
it('should support submenus', () => {
const extension = createNodeMenuExtension('Submenu Extension', [
{
content: 'Node Menu with Submenu',
has_submenu: true,
submenu: {
options: [
{ content: 'Node Submenu 1', callback: () => {} },
{ content: 'Node Submenu 2', callback: () => {} }
]
}
}
])
extensionStore.registerExtension(extension)
const items: IContextMenuValue[] = extensionService
.invokeExtensions('getNodeMenuItems', mockNode)
.flat()
expect(items[0].content).toBe('Node Menu with Submenu')
expect(items[0].submenu?.options).toHaveLength(2)
})
it('should skip extensions without getNodeMenuItems', () => {
const nodeExtension = createNodeMenuExtension('Node Ext', [nodeMenuItem1])
const extensionWithoutNodeMenu: ComfyExtension = {
name: 'No Node Menu'
}
extensionStore.registerExtension(nodeExtension)
extensionStore.registerExtension(extensionWithoutNodeMenu)
const items: IContextMenuValue[] = extensionService
.invokeExtensions('getNodeMenuItems', mockNode)
.flat()
expect(items).toHaveLength(1)
expect(items[0].content).toBe('Node Item 1')
})
})
})