diff --git a/src/composables/useContextMenuTranslation.ts b/src/composables/useContextMenuTranslation.ts index 1fd851420b..e5e0c94125 100644 --- a/src/composables/useContextMenuTranslation.ts +++ b/src/composables/useContextMenuTranslation.ts @@ -1,4 +1,5 @@ import { st, te } from '@/i18n' +import { legacyMenuCompat } from '@/lib/litegraph/src/contextMenuCompat' import type { IContextMenuOptions, IContextMenuValue, @@ -6,18 +7,42 @@ import type { IWidget } from '@/lib/litegraph/src/litegraph' import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph' +import { app } from '@/scripts/app' import { normalizeI18nKey } from '@/utils/formatUtil' /** * Add translation for litegraph context menu. */ export const useContextMenuTranslation = () => { + // Install compatibility layer BEFORE any extensions load + legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions') + const f = LGraphCanvas.prototype.getCanvasMenuOptions const getCanvasCenterMenuOptions = function ( this: LGraphCanvas, ...args: Parameters ) { const res = f.apply(this, args) as ReturnType + + // Add items from new extension API + const newApiItems = app.collectCanvasMenuItems(this) + for (const item of newApiItems) { + // @ts-expect-error - Generic types differ but runtime compatibility is ensured + res.push(item) + } + + // Add legacy monkey-patched items + const legacyItems = legacyMenuCompat.extractLegacyItems( + 'getCanvasMenuOptions', + this, + ...args + ) + for (const item of legacyItems) { + // @ts-expect-error - Generic types differ but runtime compatibility is ensured + res.push(item) + } + + // Translate all items for (const item of res) { if (item?.content) { item.content = st(`contextMenu.${item.content}`, item.content) diff --git a/src/lib/litegraph/src/contextMenuCompat.ts b/src/lib/litegraph/src/contextMenuCompat.ts new file mode 100644 index 0000000000..13074667c5 --- /dev/null +++ b/src/lib/litegraph/src/contextMenuCompat.ts @@ -0,0 +1,115 @@ +import type { IContextMenuValue } from './interfaces' + +/** + * Simple compatibility layer for legacy getCanvasMenuOptions and getNodeMenuOptions monkey patches. + * To disable legacy support, set ENABLE_LEGACY_SUPPORT = false + */ +const ENABLE_LEGACY_SUPPORT = true + +type AnyFunction = (...args: any[]) => any + +class LegacyMenuCompat { + private originalMethods = new Map() + private hasWarned = new Set() + private currentExtension: string | null = null + + /** + * Set the name of the extension that is currently being set up. + * This allows us to track which extension is monkey-patching. + * @param extensionName The name of the extension + */ + setCurrentExtension(extensionName: string | null) { + this.currentExtension = extensionName + } + + /** + * Install compatibility layer to detect monkey-patching + * @param prototype The prototype to install on (e.g., LGraphCanvas.prototype) + * @param methodName The method name to track (e.g., 'getCanvasMenuOptions') + */ + install(prototype: any, methodName: string) { + if (!ENABLE_LEGACY_SUPPORT) return + + // Store original + const originalMethod = prototype[methodName] + this.originalMethods.set(methodName, originalMethod) + + // Wrap with getter/setter to detect patches + let currentImpl = originalMethod + + Object.defineProperty(prototype, methodName, { + get() { + return currentImpl + }, + set: (newImpl: AnyFunction) => { + // Log once per unique function + const fnKey = `${methodName}:${newImpl.toString().slice(0, 100)}` + if (!this.hasWarned.has(fnKey)) { + this.hasWarned.add(fnKey) + + const extensionInfo = this.currentExtension + ? ` (Extension: "${this.currentExtension}")` + : '' + + console.warn( + `%c[DEPRECATED]%c Monkey-patching ${methodName} is deprecated.${extensionInfo}\n` + + `Please use the new context menu API instead.\n\n` + + `See: https://docs.comfy.org/custom-nodes/js/context-menu-migration`, + 'color: orange; font-weight: bold', + 'color: inherit' + ) + } + currentImpl = newImpl + }, + configurable: true + }) + } + + /** + * Extract items that were added by legacy monkey patches + * @param methodName The method name that was monkey-patched + * @param context The context to call methods with (e.g., canvas instance) + * @param args Arguments to pass to the methods + * @returns Array of menu items added by monkey patches + */ + extractLegacyItems( + methodName: string, + context: any, + ...args: any[] + ): IContextMenuValue[] { + if (!ENABLE_LEGACY_SUPPORT) return [] + + const originalMethod = this.originalMethods.get(methodName) + if (!originalMethod) return [] + + try { + // Get baseline from original + const originalItems = originalMethod.apply(context, args) as + | IContextMenuValue[] + | undefined + if (!originalItems) return [] + + // Get current method (potentially patched) + const currentMethod = context.constructor.prototype[methodName] + if (!currentMethod || currentMethod === originalMethod) return [] + + // Get items from patched method + const patchedItems = currentMethod.apply(context, args) as + | IContextMenuValue[] + | undefined + if (!patchedItems) return [] + + // Return items that were added (simple slice approach) + if (patchedItems.length > originalItems.length) { + return patchedItems.slice(originalItems.length) + } + + return [] + } catch (e) { + console.error('[Context Menu Compat] Failed to extract legacy items:', e) + return [] + } + } +} + +export const legacyMenuCompat = new LegacyMenuCompat() diff --git a/src/services/extensionService.ts b/src/services/extensionService.ts index 26b67fba77..8a55fe9b5f 100644 --- a/src/services/extensionService.ts +++ b/src/services/extensionService.ts @@ -1,5 +1,6 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser' import { useErrorHandling } from '@/composables/useErrorHandling' +import { legacyMenuCompat } from '@/lib/litegraph/src/contextMenuCompat' import { useSettingStore } from '@/platform/settings/settingStore' import { api } from '@/scripts/api' import { app } from '@/scripts/app' @@ -122,8 +123,25 @@ export const useExtensionService = () => { extensionStore.enabledExtensions.map(async (ext) => { if (method in ext) { try { - return await ext[method](...args, app) + // Set current extension name for legacy compatibility tracking + if (method === 'setup') { + legacyMenuCompat.setCurrentExtension(ext.name) + } + + const result = await ext[method](...args, app) + + // Clear current extension after setup + if (method === 'setup') { + legacyMenuCompat.setCurrentExtension(null) + } + + return result } catch (error) { + // Clear current extension on error too + if (method === 'setup') { + legacyMenuCompat.setCurrentExtension(null) + } + console.error( `Error calling extension '${ext.name}' method '${method}'`, { error }, diff --git a/tests-ui/tests/extensions/contextMenuExtensionName.test.ts b/tests-ui/tests/extensions/contextMenuExtensionName.test.ts new file mode 100644 index 0000000000..485df79de9 --- /dev/null +++ b/tests-ui/tests/extensions/contextMenuExtensionName.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from 'vitest' + +import { legacyMenuCompat } from '@/lib/litegraph/src/contextMenuCompat' +import { LGraphCanvas } from '@/lib/litegraph/src/litegraph' + +/** + * Test that demonstrates the extension name appearing in deprecation warnings + */ +describe('Context Menu Extension Name in Warnings', () => { + it('should include extension name in deprecation warning', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + // Install compatibility layer + legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions') + + // Simulate what happens during extension setup + legacyMenuCompat.setCurrentExtension('MyCustomExtension') + + // Extension monkey-patches the method + const original = LGraphCanvas.prototype.getCanvasMenuOptions + LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) { + const items = (original as any).apply(this, args) + items.push({ content: 'My Custom Menu Item', callback: () => {} }) + return items + } + + // Clear extension (happens after setup completes) + legacyMenuCompat.setCurrentExtension(null) + + // Verify the warning includes the extension name + expect(warnSpy).toHaveBeenCalled() + const warningMessage = warnSpy.mock.calls[0][0] + + expect(warningMessage).toContain('[DEPRECATED]') + expect(warningMessage).toContain('getCanvasMenuOptions') + expect(warningMessage).toContain('"MyCustomExtension"') + + vi.restoreAllMocks() + }) + + it('should not include extension name if not set', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + // Install compatibility layer + legacyMenuCompat.install(LGraphCanvas.prototype, 'getNodeMenuOptions') + + // Extension monkey-patches without setting current extension + const original = LGraphCanvas.prototype.getNodeMenuOptions + LGraphCanvas.prototype.getNodeMenuOptions = function (...args: any[]) { + const items = (original as any).apply(this, args) + items.push({ content: 'My Node Menu Item', callback: () => {} }) + return items + } + + // Verify the warning does NOT include extension info + expect(warnSpy).toHaveBeenCalled() + const warningMessage = warnSpy.mock.calls[0][0] + + expect(warningMessage).toContain('[DEPRECATED]') + expect(warningMessage).toContain('getNodeMenuOptions') + expect(warningMessage).not.toContain('Extension:') + + vi.restoreAllMocks() + }) +}) diff --git a/tests-ui/tests/litegraph/core/contextMenuCompat.test.ts b/tests-ui/tests/litegraph/core/contextMenuCompat.test.ts new file mode 100644 index 0000000000..bd1e4ccdb0 --- /dev/null +++ b/tests-ui/tests/litegraph/core/contextMenuCompat.test.ts @@ -0,0 +1,219 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { legacyMenuCompat } from '@/lib/litegraph/src/contextMenuCompat' +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) + + 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', () => { + beforeEach(() => { + // Setup a mock original method + LGraphCanvas.prototype.getCanvasMenuOptions = function () { + return [ + { content: 'Item 1', callback: () => {} }, + { content: 'Item 2', callback: () => {} } + ] + } + + // 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 (...args: any[]) { + const items = (original as any).apply(this, args) + 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 return empty array when patched method returns same count', () => { + // Monkey-patch that replaces items but keeps same count + LGraphCanvas.prototype.getCanvasMenuOptions = function () { + return [ + { content: 'Replaced 1', callback: () => {} }, + { content: 'Replaced 2', callback: () => {} } + ] + } + + const legacyItems = legacyMenuCompat.extractLegacyItems( + 'getCanvasMenuOptions', + mockCanvas + ) + + expect(legacyItems).toHaveLength(0) + }) + + 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', () => { + it('should work with multiple extensions patching', () => { + // Setup base method + LGraphCanvas.prototype.getCanvasMenuOptions = function () { + return [{ content: 'Base Item', callback: () => {} }] + } + + legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions') + + // First extension patches + const original1 = LGraphCanvas.prototype.getCanvasMenuOptions + LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) { + const items = (original1 as any).apply(this, args) + items.push({ content: 'Extension 1 Item', callback: () => {} }) + return items + } + + // Second extension patches + const original2 = LGraphCanvas.prototype.getCanvasMenuOptions + LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) { + const items = (original2 as any).apply(this, args) + 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' }) + }) + }) +})