Files
ComfyUI_frontend/tests-ui/tests/litegraph/core/contextMenuCompat.test.ts
Johnpaul Chiwetelu c414635ead [feat] Add context menu converter infrastructure (#7113)
## Summary
- Add `contextMenuConverter.ts` with utilities for converting LiteGraph
context menu items to Vue menu format
- Improve `contextMenuCompat.ts` with set-based diffing for more
reliable legacy extension detection
- Extend `MenuOption`/`SubMenuOption` types with `source`, `disabled`,
`isColorPicker`, and `category` type fields
- Add unit tests for converter functions

## Context
This is foundational work for migrating the node context menu from a
custom Popover-based component to PrimeVue ContextMenu.

The converter provides:
- Menu ordering and section grouping (core items first, then extensions)
- Deduplication with preference for Vue-native items over LiteGraph
items
- Extension categorization with labeled section
- Support for disabled states and color picker submenus

## Test plan
- [x] Unit tests pass for `buildStructuredMenu` (9 tests)
- [x] Unit tests pass for `convertContextMenuToOptions` (7 tests)
- [x] Typecheck passes
- [x] Lint passes
- [x] Knip passes (no unused exports)

## Related
This is PR 1 of 2 for the node context menu migration. PR 2 will wire up
the UI component.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7113-feat-Add-context-menu-converter-infrastructure-2be6d73d3650816ca6c9d2cf50f10159)
by [Unito](https://www.unito.io)
2025-12-14 21:01:12 -07:00

368 lines
13 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { legacyMenuCompat } from '@/lib/litegraph/src/contextMenuCompat'
import type { IContextMenuValue } from '@/lib/litegraph/src/litegraph'
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
describe('contextMenuCompat', () => {
let originalGetCanvasMenuOptions: typeof LGraphCanvas.prototype.getCanvasMenuOptions
let mockCanvas: LGraphCanvas
beforeEach(() => {
// Save original method
originalGetCanvasMenuOptions = LGraphCanvas.prototype.getCanvasMenuOptions
// Create mock canvas
mockCanvas = {
constructor: {
prototype: LGraphCanvas.prototype
}
} as unknown as LGraphCanvas
// Clear console warnings
vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
// Restore original method
LGraphCanvas.prototype.getCanvasMenuOptions = originalGetCanvasMenuOptions
vi.restoreAllMocks()
})
describe('install', () => {
it('should install compatibility layer on prototype', () => {
const methodName = 'getCanvasMenuOptions'
// Install compatibility layer
legacyMenuCompat.install(LGraphCanvas.prototype, methodName)
// The method should still be callable
expect(typeof LGraphCanvas.prototype.getCanvasMenuOptions).toBe(
'function'
)
})
it('should detect monkey patches and warn', () => {
const methodName = 'getCanvasMenuOptions'
const warnSpy = vi.spyOn(console, 'warn')
// Install compatibility layer
legacyMenuCompat.install(LGraphCanvas.prototype, methodName)
// Set current extension before monkey-patching
legacyMenuCompat.setCurrentExtension('Test Extension')
// Simulate extension monkey-patching
const original = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
const items = (original as any).apply(this, args)
items.push({ content: 'Custom Item', callback: () => {} })
return items
}
// Should have logged a warning with extension name
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('[DEPRECATED]'),
expect.any(String),
expect.any(String)
)
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('"Test Extension"'),
expect.any(String),
expect.any(String)
)
// Clear extension
legacyMenuCompat.setCurrentExtension(null)
})
it('should only warn once per unique function', () => {
const methodName = 'getCanvasMenuOptions'
const warnSpy = vi.spyOn(console, 'warn')
legacyMenuCompat.install(LGraphCanvas.prototype, methodName)
legacyMenuCompat.setCurrentExtension('test.extension')
const patchFunction = function (this: LGraphCanvas, ...args: any[]) {
const items = (originalGetCanvasMenuOptions as any).apply(this, args)
items.push({ content: 'Custom', callback: () => {} })
return items
}
// Patch twice with same function
LGraphCanvas.prototype.getCanvasMenuOptions = patchFunction
LGraphCanvas.prototype.getCanvasMenuOptions = patchFunction
// Should only warn once
expect(warnSpy).toHaveBeenCalledTimes(1)
})
})
describe('extractLegacyItems', () => {
// Cache base items to ensure reference equality for set-based diffing
const baseItem1 = { content: 'Item 1', callback: () => {} }
const baseItem2 = { content: 'Item 2', callback: () => {} }
beforeEach(() => {
// Setup a mock original method that returns cached items
// This ensures reference equality when set-based diffing compares items
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
return [baseItem1, baseItem2]
}
// Install compatibility layer
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
})
it('should extract items added by monkey patches', () => {
// Monkey-patch to add items
const original = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions =
function (): (IContextMenuValue | null)[] {
const items = original.apply(this)
items.push({ content: 'Custom Item 1', callback: () => {} })
items.push({ content: 'Custom Item 2', callback: () => {} })
return items
}
// Extract legacy items
const legacyItems = legacyMenuCompat.extractLegacyItems(
'getCanvasMenuOptions',
mockCanvas
)
expect(legacyItems).toHaveLength(2)
expect(legacyItems[0]).toMatchObject({ content: 'Custom Item 1' })
expect(legacyItems[1]).toMatchObject({ content: 'Custom Item 2' })
})
it('should return empty array when no items added', () => {
// No monkey-patching, so no extra items
const legacyItems = legacyMenuCompat.extractLegacyItems(
'getCanvasMenuOptions',
mockCanvas
)
expect(legacyItems).toHaveLength(0)
})
it('should detect replaced items as additions and warn about removed items', () => {
const warnSpy = vi.spyOn(console, 'warn')
// Monkey-patch that replaces items with different ones (same count)
// With set-based diffing, these are detected as new items since they're different references
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
return [
{ content: 'Replaced 1', callback: () => {} },
{ content: 'Replaced 2', callback: () => {} }
]
}
const legacyItems = legacyMenuCompat.extractLegacyItems(
'getCanvasMenuOptions',
mockCanvas
)
// Set-based diffing detects the replaced items as additions
expect(legacyItems).toHaveLength(2)
expect(legacyItems[0]).toMatchObject({ content: 'Replaced 1' })
expect(legacyItems[1]).toMatchObject({ content: 'Replaced 2' })
// Should warn about removed original items
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('removed'))
})
it('should handle errors gracefully', () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
// Monkey-patch that throws error
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
throw new Error('Test error')
}
const legacyItems = legacyMenuCompat.extractLegacyItems(
'getCanvasMenuOptions',
mockCanvas
)
expect(legacyItems).toHaveLength(0)
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('Failed to extract legacy items'),
expect.any(Error)
)
})
})
describe('integration', () => {
// Cache base items to ensure reference equality for set-based diffing
const integrationBaseItem = { content: 'Base Item', callback: () => {} }
const integrationBaseItem1 = { content: 'Base Item 1', callback: () => {} }
const integrationBaseItem2 = { content: 'Base Item 2', callback: () => {} }
it('should work with multiple extensions patching', () => {
// Setup base method with cached item
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
return [integrationBaseItem]
}
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
// First extension patches
const original1 = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions =
function (): (IContextMenuValue | null)[] {
const items = original1.apply(this)
items.push({ content: 'Extension 1 Item', callback: () => {} })
return items
}
// Second extension patches
const original2 = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions =
function (): (IContextMenuValue | null)[] {
const items = original2.apply(this)
items.push({ content: 'Extension 2 Item', callback: () => {} })
return items
}
// Extract legacy items
const legacyItems = legacyMenuCompat.extractLegacyItems(
'getCanvasMenuOptions',
mockCanvas
)
// Should extract both items added by extensions
expect(legacyItems).toHaveLength(2)
expect(legacyItems[0]).toMatchObject({ content: 'Extension 1 Item' })
expect(legacyItems[1]).toMatchObject({ content: 'Extension 2 Item' })
})
it('should extract legacy items only once even when called multiple times', () => {
// Setup base method with cached items
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
return [integrationBaseItem1, integrationBaseItem2]
}
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
// Simulate legacy extension monkey-patching the prototype
const original = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions =
function (): (IContextMenuValue | null)[] {
const items = original.apply(this)
items.push({ content: 'Legacy Item 1', callback: () => {} })
items.push({ content: 'Legacy Item 2', callback: () => {} })
return items
}
// Extract legacy items multiple times (simulating repeated menu opens)
const legacyItems1 = legacyMenuCompat.extractLegacyItems(
'getCanvasMenuOptions',
mockCanvas
)
const legacyItems2 = legacyMenuCompat.extractLegacyItems(
'getCanvasMenuOptions',
mockCanvas
)
const legacyItems3 = legacyMenuCompat.extractLegacyItems(
'getCanvasMenuOptions',
mockCanvas
)
// Each extraction should return the same items (no accumulation)
expect(legacyItems1).toHaveLength(2)
expect(legacyItems2).toHaveLength(2)
expect(legacyItems3).toHaveLength(2)
// Verify items are the expected ones
expect(legacyItems1[0]).toMatchObject({ content: 'Legacy Item 1' })
expect(legacyItems1[1]).toMatchObject({ content: 'Legacy Item 2' })
expect(legacyItems2[0]).toMatchObject({ content: 'Legacy Item 1' })
expect(legacyItems2[1]).toMatchObject({ content: 'Legacy Item 2' })
expect(legacyItems3[0]).toMatchObject({ content: 'Legacy Item 1' })
expect(legacyItems3[1]).toMatchObject({ content: 'Legacy Item 2' })
})
it('should not extract items from registered wrapper methods', () => {
// Setup base method with cached item
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
return [integrationBaseItem]
}
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
// Create a wrapper that adds new API items (simulating useContextMenuTranslation)
const originalMethod = LGraphCanvas.prototype.getCanvasMenuOptions
const wrapperMethod = function (
this: LGraphCanvas
): (IContextMenuValue | null)[] {
const items = originalMethod.apply(this)
// Add new API items
items.push({ content: 'New API Item 1', callback: () => {} })
items.push({ content: 'New API Item 2', callback: () => {} })
return items
}
// Set the wrapper as the current method
LGraphCanvas.prototype.getCanvasMenuOptions = wrapperMethod
// Register the wrapper so it's not treated as a legacy patch
legacyMenuCompat.registerWrapper(
'getCanvasMenuOptions',
wrapperMethod,
originalMethod,
LGraphCanvas.prototype // Wrapper is installed
)
// Extract legacy items - should return empty because current method is a registered wrapper
const legacyItems = legacyMenuCompat.extractLegacyItems(
'getCanvasMenuOptions',
mockCanvas
)
expect(legacyItems).toHaveLength(0)
})
it('should extract legacy items even when a wrapper is registered but not active', () => {
// Setup base method with cached item
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
return [integrationBaseItem]
}
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
// Register a wrapper (but don't set it as the current method)
const originalMethod = LGraphCanvas.prototype.getCanvasMenuOptions
const wrapperMethod = function (): (IContextMenuValue | null)[] {
return [{ content: 'Wrapper Item', callback: () => {} }]
}
legacyMenuCompat.registerWrapper(
'getCanvasMenuOptions',
wrapperMethod,
originalMethod
// NOT passing prototype, so it won't be marked as installed
)
// Monkey-patch with a different function (legacy extension)
const original = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions =
function (): (IContextMenuValue | null)[] {
const items = original.apply(this)
items.push({ content: 'Legacy Item', callback: () => {} })
return items
}
// Extract legacy items - should return the legacy item because current method is NOT the wrapper
const legacyItems = legacyMenuCompat.extractLegacyItems(
'getCanvasMenuOptions',
mockCanvas
)
expect(legacyItems).toHaveLength(1)
expect(legacyItems[0]).toMatchObject({ content: 'Legacy Item' })
})
})
})