From 315fd0bc39cb4efd4fa91a35dffbfb7b7011ef67 Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Tue, 26 May 2026 08:40:46 -0700 Subject: [PATCH] feat: add dev mode deprecation panel - add deprecation warning panel with deduped counts - add store, litegraph bridge and seed with server logs - update existing deprecation warnings and logs --- .../fixtures/components/SidebarTab.ts | 33 +++- .../tests/sidebar/deprecationWarnings.spec.ts | 96 +++++++++++ .../helpcenter/HelpCenterMenuContent.vue | 35 +--- src/components/sidebar/SideToolbar.vue | 21 ++- .../tabs/DeprecationWarningsTab.test.ts | 116 +++++++++++++ .../sidebar/tabs/DeprecationWarningsTab.vue | 99 +++++++++++ .../useDeprecationWarningsSidebarTab.test.ts | 58 +++++++ .../useDeprecationWarningsSidebarTab.ts | 30 ++++ src/extensions/core/maskeditor.ts | 23 +-- .../core/widgetInputs.deprecation.test.ts | 74 +++++++++ src/extensions/core/widgetInputs.ts | 18 +- src/lib/litegraph/src/LGraphNode.ts | 4 +- src/lib/litegraph/src/utils/feedback.ts | 49 ++---- src/lib/litegraph/src/widgets/ComboWidget.ts | 4 +- src/locales/en/main.json | 9 + src/locales/en/settings.json | 3 +- .../dev/backfillServerDeprecations.test.ts | 155 ++++++++++++++++++ .../dev/backfillServerDeprecations.ts | 42 +++++ .../dev/deprecationWarningsStore.test.ts | 91 ++++++++++ src/platform/dev/deprecationWarningsStore.ts | 84 ++++++++++ .../installLiteGraphDeprecationBridge.test.ts | 70 ++++++++ .../dev/installLiteGraphDeprecationBridge.ts | 16 ++ src/platform/dev/warnDeprecated.test.ts | 154 +++++++++++++++++ src/platform/dev/warnDeprecated.ts | 57 +++++++ .../settings/constants/coreSettings.ts | 2 + .../widgets/composables/useStringWidget.ts | 2 +- src/scripts/changeTracker.ts | 13 +- src/scripts/ui.ts | 13 +- src/scripts/ui/settings.ts | 10 +- src/services/litegraphService.ts | 8 +- src/stores/commandStore.ts | 5 + src/stores/nodeDefStore.test.ts | 67 +++++++- src/stores/nodeDefStore.ts | 18 +- src/stores/workspace/sidebarTabStore.test.ts | 145 +++++++++++++++- src/stores/workspace/sidebarTabStore.ts | 33 +++- src/types/extensionTypes.ts | 2 + src/utils/numberUtil.test.ts | 26 ++- src/utils/numberUtil.ts | 4 + src/utils/relativeTime.test.ts | 42 +++++ src/utils/relativeTime.ts | 25 +++ 40 files changed, 1624 insertions(+), 132 deletions(-) create mode 100644 browser_tests/tests/sidebar/deprecationWarnings.spec.ts create mode 100644 src/components/sidebar/tabs/DeprecationWarningsTab.test.ts create mode 100644 src/components/sidebar/tabs/DeprecationWarningsTab.vue create mode 100644 src/composables/sidebarTabs/useDeprecationWarningsSidebarTab.test.ts create mode 100644 src/composables/sidebarTabs/useDeprecationWarningsSidebarTab.ts create mode 100644 src/extensions/core/widgetInputs.deprecation.test.ts create mode 100644 src/platform/dev/backfillServerDeprecations.test.ts create mode 100644 src/platform/dev/backfillServerDeprecations.ts create mode 100644 src/platform/dev/deprecationWarningsStore.test.ts create mode 100644 src/platform/dev/deprecationWarningsStore.ts create mode 100644 src/platform/dev/installLiteGraphDeprecationBridge.test.ts create mode 100644 src/platform/dev/installLiteGraphDeprecationBridge.ts create mode 100644 src/platform/dev/warnDeprecated.test.ts create mode 100644 src/platform/dev/warnDeprecated.ts create mode 100644 src/utils/relativeTime.test.ts create mode 100644 src/utils/relativeTime.ts diff --git a/browser_tests/fixtures/components/SidebarTab.ts b/browser_tests/fixtures/components/SidebarTab.ts index d0532d745e..02eb9f09bb 100644 --- a/browser_tests/fixtures/components/SidebarTab.ts +++ b/browser_tests/fixtures/components/SidebarTab.ts @@ -7,6 +7,7 @@ import { TestIds } from '@e2e/fixtures/selectors' class SidebarTab { public readonly tabButton: Locator public readonly selectedTabButton: Locator + public readonly badge: Locator constructor( public readonly page: Page, @@ -16,6 +17,7 @@ class SidebarTab { this.selectedTabButton = page.locator( `.${tabId}-tab-button.side-bar-button-selected` ) + this.badge = this.tabButton.locator('.sidebar-icon-badge') } async open() { @@ -25,7 +27,7 @@ class SidebarTab { await this.tabButton.click() } async close() { - if (!this.tabButton.isVisible()) { + if (!(await this.tabButton.isVisible())) { return } await this.tabButton.click() @@ -54,7 +56,7 @@ export class NodeLibrarySidebarTab extends SidebarTab { } override async close() { - if (!this.tabButton.isVisible()) { + if (!(await this.tabButton.isVisible())) { return } @@ -464,3 +466,30 @@ export class AssetsSidebarTab extends SidebarTab { } } } + +export class DeprecationWarningsSidebarTab extends SidebarTab { + public readonly panel: Locator + public readonly list: Locator + public readonly clearAllButton: Locator + public readonly emptyState: Locator + + constructor(public override readonly page: Page) { + super(page, 'deprecation-warnings') + this.panel = page.getByTestId('deprecation-warnings-sidebar') + this.list = page.getByTestId('deprecation-warnings-list') + this.clearAllButton = this.panel.getByRole('button', { name: 'Clear all' }) + this.emptyState = this.panel.getByText( + 'No deprecation warnings reported in this session.' + ) + } + + rowFor(message: string): Locator { + return this.list.locator('li').filter({ hasText: message }) + } + + async emitDeprecation(message: string): Promise { + await this.page.evaluate((m: string) => { + window.LiteGraph!.onDeprecationWarning.forEach((cb) => cb(m)) + }, message) + } +} diff --git a/browser_tests/tests/sidebar/deprecationWarnings.spec.ts b/browser_tests/tests/sidebar/deprecationWarnings.spec.ts new file mode 100644 index 0000000000..8a48eba194 --- /dev/null +++ b/browser_tests/tests/sidebar/deprecationWarnings.spec.ts @@ -0,0 +1,96 @@ +import { + comfyExpect as expect, + comfyPageFixture as test +} from '@e2e/fixtures/ComfyPage' +import { DeprecationWarningsSidebarTab } from '@e2e/fixtures/components/SidebarTab' + +test.describe('Deprecation warnings sidebar', { tag: '@ui' }, () => { + test.describe('with DevMode disabled', () => { + test.use({ initialSettings: { 'Comfy.DevMode': false } }) + + test('does not render the sidebar icon', async ({ comfyPage }) => { + const tab = new DeprecationWarningsSidebarTab(comfyPage.page) + await expect(tab.tabButton).toHaveCount(0) + }) + }) + + test.describe('with DevMode enabled', () => { + test.use({ initialSettings: { 'Comfy.DevMode': true } }) + + test('records a new deprecation, shows it in the panel, dedupes repeats', async ({ + comfyPage + }) => { + const tab = new DeprecationWarningsSidebarTab(comfyPage.page) + await expect(tab.tabButton).toBeVisible() + + const uniqueMessage = `e2e-${Date.now()} is deprecated.` + + await tab.emitDeprecation(uniqueMessage) + await tab.open() + await expect(tab.panel).toBeVisible() + + const row = tab.rowFor(uniqueMessage) + await expect(row).toBeVisible() + + await tab.emitDeprecation(uniqueMessage) + await expect(row.getByTestId('deprecation-warning-badge')).toHaveText('2') + await expect(tab.rowFor(uniqueMessage)).toHaveCount(1) + }) + + test('opening the panel clears the unseen badge', async ({ comfyPage }) => { + const tab = new DeprecationWarningsSidebarTab(comfyPage.page) + await expect(tab.tabButton).toBeVisible() + + // Mark any pre-existing warnings as seen. + await tab.open() + await expect(tab.panel).toBeVisible() + await tab.close() + await expect(tab.panel).toBeHidden() + + await expect(tab.badge).toHaveCount(0) + + await tab.emitDeprecation(`e2e-badge-${Date.now()} is deprecated.`) + await expect(tab.badge).toHaveText('1') + + await tab.open() + await expect(tab.badge).toHaveCount(0) + }) + + test('clear all empties the panel and shows the empty state', async ({ + comfyPage + }) => { + const tab = new DeprecationWarningsSidebarTab(comfyPage.page) + await tab.open() + await expect(tab.panel).toBeVisible() + + const uniqueMessage = `e2e-clearAll-${Date.now()} is deprecated.` + await tab.emitDeprecation(uniqueMessage) + await expect(tab.rowFor(uniqueMessage)).toBeVisible() + + // Tool-buttons slot is hover/focus-gated; hover the panel to reveal it. + await tab.panel.hover() + await tab.clearAllButton.click() + + await expect(tab.list).toHaveCount(0) + await expect(tab.emptyState).toBeVisible() + await expect(tab.clearAllButton).toHaveCount(0) + }) + + test('clamps the toolbar badge at "9+" when many unseen warnings pile up', async ({ + comfyPage + }) => { + const tab = new DeprecationWarningsSidebarTab(comfyPage.page) + await tab.open() + await expect(tab.panel).toBeVisible() + await tab.close() + await expect(tab.panel).toBeHidden() + + const stamp = Date.now() + for (let i = 0; i < 12; i++) { + await tab.emitDeprecation(`e2e-${stamp}-${i} is deprecated.`) + } + + await expect(tab.badge).toHaveText('9+') + }) + }) +}) diff --git a/src/components/helpcenter/HelpCenterMenuContent.vue b/src/components/helpcenter/HelpCenterMenuContent.vue index 7deab72887..deb14854c2 100644 --- a/src/components/helpcenter/HelpCenterMenuContent.vue +++ b/src/components/helpcenter/HelpCenterMenuContent.vue @@ -170,6 +170,7 @@ import { useReleaseStore } from '@/platform/updates/common/releaseStore' import { useCommandStore } from '@/stores/commandStore' import { electronAPI } from '@/utils/envUtil' import { formatVersionAnchor } from '@/utils/formatUtil' +import { formatRelativeTime } from '@/utils/relativeTime' import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment' import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState' import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService' @@ -188,16 +189,6 @@ interface MenuItem { showExternalIcon?: boolean } -// Constants -const TIME_UNITS = { - MINUTE: 60 * 1000, - HOUR: 60 * 60 * 1000, - DAY: 24 * 60 * 60 * 1000, - WEEK: 7 * 24 * 60 * 60 * 1000, - MONTH: 30 * 24 * 60 * 60 * 1000, - YEAR: 365 * 24 * 60 * 60 * 1000 -} as const - const SUBMENU_CONFIG = { DELAY_MS: 100, OFFSET_PX: 8, @@ -492,28 +483,8 @@ const calculateSubmenuPosition = (button: HTMLElement): CSSProperties => { const formatReleaseDate = (dateString?: string): string => { if (!dateString) return 'date' - - const date = new Date(dateString) - const now = new Date() - const diffTime = Math.abs(now.getTime() - date.getTime()) - - const timeUnits = [ - { unit: TIME_UNITS.YEAR, key: 'yearsAgo' }, - { unit: TIME_UNITS.MONTH, key: 'monthsAgo' }, - { unit: TIME_UNITS.WEEK, key: 'weeksAgo' }, - { unit: TIME_UNITS.DAY, key: 'daysAgo' }, - { unit: TIME_UNITS.HOUR, key: 'hoursAgo' }, - { unit: TIME_UNITS.MINUTE, key: 'minutesAgo' } - ] - - for (const { unit, key } of timeUnits) { - const value = Math.floor(diffTime / unit) - if (value > 0) { - return t(`g.relativeTime.${key}`, { count: value }) - } - } - - return t('g.relativeTime.now') + const diffMs = Math.abs(Date.now() - new Date(dateString).getTime()) + return formatRelativeTime(t, diffMs) } // Event Handlers diff --git a/src/components/sidebar/SideToolbar.vue b/src/components/sidebar/SideToolbar.vue index 86d2d87f92..0f3f49cff9 100644 --- a/src/components/sidebar/SideToolbar.vue +++ b/src/components/sidebar/SideToolbar.vue @@ -21,7 +21,7 @@
+ workspaceStore.getSidebarTabs()) +const topTabs = computed(() => + tabs.value.filter((tab) => tab.placement !== 'bottom') +) +const bottomTabs = computed(() => + tabs.value.filter((tab) => tab.placement === 'bottom') +) const selectedTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab) /** diff --git a/src/components/sidebar/tabs/DeprecationWarningsTab.test.ts b/src/components/sidebar/tabs/DeprecationWarningsTab.test.ts new file mode 100644 index 0000000000..9c4b887d8d --- /dev/null +++ b/src/components/sidebar/tabs/DeprecationWarningsTab.test.ts @@ -0,0 +1,116 @@ +import { createTestingPinia } from '@pinia/testing' +import { cleanup, render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createI18n } from 'vue-i18n' + +import DeprecationWarningsTab from '@/components/sidebar/tabs/DeprecationWarningsTab.vue' +import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore' + +function renderTab() { + const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { en: {} } + }) + return { + user: userEvent.setup(), + ...render(DeprecationWarningsTab, { global: { plugins: [i18n] } }) + } +} + +describe('DeprecationWarningsTab', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + // Drain warnings buffered during module-load imports. + useDeprecationWarningsStore().clear() + }) + + afterEach(() => { + cleanup() + }) + + it('renders the empty state when there are no warnings', () => { + renderTab() + + screen.getByText('deprecationWarnings.empty') + expect(screen.queryByTestId('deprecation-warnings-list')).toBeNull() + }) + + it('renders each warning with its message, suggestion, and source', () => { + const store = useDeprecationWarningsStore() + store.report({ + message: 'foo() is deprecated.', + suggestion: 'Use bar() instead.', + source: 'lib' + }) + + renderTab() + + screen.getByText('foo() is deprecated.') + screen.getByText('Use bar() instead.') + screen.getByText('lib') + }) + + it('shows the per-warning count badge only when the warning has fired more than once', () => { + const store = useDeprecationWarningsStore() + store.report({ message: 'once' }) + store.report({ message: 'twice' }) + store.report({ message: 'twice' }) + + renderTab() + + const badge = screen.getByTestId('deprecation-warning-badge') + expect(badge.textContent).toBe('2') + expect(badge.getAttribute('aria-label')).toBe( + 'deprecationWarnings.occurrenceCount' + ) + expect(screen.queryAllByTestId('deprecation-warning-badge')).toHaveLength(1) + }) + + it('marks all warnings as seen on mount', () => { + const store = useDeprecationWarningsStore() + store.report({ message: 'one' }) + store.report({ message: 'two' }) + expect(store.unseenCount).toBe(2) + + renderTab() + + expect(store.unseenCount).toBe(0) + }) + + it('re-renders relative timestamps as time advances', async () => { + vi.useFakeTimers() + try { + vi.setSystemTime(new Date('2026-01-01T12:00:00Z')) + + const store = useDeprecationWarningsStore() + store.report({ message: 'aged-out' }) + + renderTab() + screen.getByText('g.relativeTime.now') + + await vi.advanceTimersByTimeAsync(2 * 60_000) + + screen.getByText('g.relativeTime.minutesAgo') + } finally { + vi.useRealTimers() + } + }) + + it('clear-all button empties the store and shows the empty state', async () => { + const store = useDeprecationWarningsStore() + store.report({ message: 'one' }) + + const { user } = renderTab() + + const clearAll = screen.getByRole('button', { + name: 'deprecationWarnings.clearAll' + }) + await user.click(clearAll) + + expect(store.warnings).toEqual([]) + screen.getByText('deprecationWarnings.empty') + }) +}) diff --git a/src/components/sidebar/tabs/DeprecationWarningsTab.vue b/src/components/sidebar/tabs/DeprecationWarningsTab.vue new file mode 100644 index 0000000000..48055a64a1 --- /dev/null +++ b/src/components/sidebar/tabs/DeprecationWarningsTab.vue @@ -0,0 +1,99 @@ + + + diff --git a/src/composables/sidebarTabs/useDeprecationWarningsSidebarTab.test.ts b/src/composables/sidebarTabs/useDeprecationWarningsSidebarTab.test.ts new file mode 100644 index 0000000000..7f34b372e9 --- /dev/null +++ b/src/composables/sidebarTabs/useDeprecationWarningsSidebarTab.test.ts @@ -0,0 +1,58 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it } from 'vitest' + +import { + DEPRECATION_WARNINGS_TAB_ID, + useDeprecationWarningsSidebarTab +} from '@/composables/sidebarTabs/useDeprecationWarningsSidebarTab' +import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore' +import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore' + +function iconBadgeOf(tab = useDeprecationWarningsSidebarTab()): string | null { + if (typeof tab.iconBadge !== 'function') { + throw new Error('expected iconBadge to be a function') + } + return tab.iconBadge() +} + +describe('useDeprecationWarningsSidebarTab', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + // Drain warnings buffered during module-load imports. + useDeprecationWarningsStore().clear() + }) + + it('iconBadge returns null when there are no unseen warnings', () => { + expect(iconBadgeOf()).toBeNull() + }) + + it('iconBadge returns the unseen count when warnings are present', () => { + const store = useDeprecationWarningsStore() + store.report({ message: 'one' }) + store.report({ message: 'two' }) + expect(iconBadgeOf()).toBe('2') + }) + + it('iconBadge clamps the count at "9+"', () => { + const store = useDeprecationWarningsStore() + for (let i = 0; i < 12; i++) store.report({ message: `m${i}` }) + expect(iconBadgeOf()).toBe('9+') + }) + + it('iconBadge returns null while the tab itself is active', () => { + const store = useDeprecationWarningsStore() + store.report({ message: 'one' }) + + useSidebarTabStore().activeSidebarTabId = DEPRECATION_WARNINGS_TAB_ID + + expect(iconBadgeOf()).toBeNull() + }) + + it('iconBadge returns null after markAllSeen', () => { + const store = useDeprecationWarningsStore() + store.report({ message: 'one' }) + store.markAllSeen() + expect(iconBadgeOf()).toBeNull() + }) +}) diff --git a/src/composables/sidebarTabs/useDeprecationWarningsSidebarTab.ts b/src/composables/sidebarTabs/useDeprecationWarningsSidebarTab.ts new file mode 100644 index 0000000000..156f6e2d4a --- /dev/null +++ b/src/composables/sidebarTabs/useDeprecationWarningsSidebarTab.ts @@ -0,0 +1,30 @@ +import { markRaw } from 'vue' + +import DeprecationWarningsTab from '@/components/sidebar/tabs/DeprecationWarningsTab.vue' +import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore' +import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore' +import type { SidebarTabExtension } from '@/types/extensionTypes' +import { formatBadgeCount } from '@/utils/numberUtil' + +export const DEPRECATION_WARNINGS_TAB_ID = 'deprecation-warnings' + +export function useDeprecationWarningsSidebarTab(): SidebarTabExtension { + return { + id: DEPRECATION_WARNINGS_TAB_ID, + icon: 'icon-[lucide--triangle-alert]', + title: 'deprecationWarnings.title', + tooltip: 'deprecationWarnings.title', + label: 'deprecationWarnings.label', + component: markRaw(DeprecationWarningsTab), + type: 'vue', + placement: 'bottom', + iconBadge: () => { + const sidebarTabStore = useSidebarTabStore() + if (sidebarTabStore.activeSidebarTabId === DEPRECATION_WARNINGS_TAB_ID) { + return null + } + const count = useDeprecationWarningsStore().unseenCount + return count > 0 ? formatBadgeCount(count, 9) : null + } + } +} diff --git a/src/extensions/core/maskeditor.ts b/src/extensions/core/maskeditor.ts index 550bec00a6..458b84298b 100644 --- a/src/extensions/core/maskeditor.ts +++ b/src/extensions/core/maskeditor.ts @@ -1,11 +1,12 @@ import _ from 'es-toolkit/compat' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' -import { app, ComfyApp } from '@/scripts/app' -import { useMaskEditorStore } from '@/stores/maskEditorStore' -import { useDialogStore } from '@/stores/dialogStore' -import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor' import { useCanvasTransform } from '@/composables/maskeditor/useCanvasTransform' +import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor' +import { warnDeprecated } from '@/platform/dev/warnDeprecated' +import { app, ComfyApp } from '@/scripts/app' +import { useDialogStore } from '@/stores/dialogStore' +import { useMaskEditorStore } from '@/stores/maskEditorStore' function openMaskEditor(node: LGraphNode): void { if (!node) { @@ -150,11 +151,13 @@ app.registerExtension({ ], init() { // Set up ComfyApp static methods for plugin compatibility (deprecated) - ComfyApp.open_maskeditor = openMaskEditorFromClipspace - - console.warn( - '[MaskEditor] ComfyApp.open_maskeditor is deprecated. ' + - 'Plugins should migrate to using the command system or direct node context menu integration.' - ) + ComfyApp.open_maskeditor = function open_maskeditor() { + warnDeprecated('ComfyApp.open_maskeditor is deprecated.', { + suggestion: + 'Migrate to the command system or direct node context menu integration.', + source: 'MaskEditor' + }) + openMaskEditorFromClipspace() + } } }) diff --git a/src/extensions/core/widgetInputs.deprecation.test.ts b/src/extensions/core/widgetInputs.deprecation.test.ts new file mode 100644 index 0000000000..a32e5aa5c1 --- /dev/null +++ b/src/extensions/core/widgetInputs.deprecation.test.ts @@ -0,0 +1,74 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { registerExtension } = vi.hoisted(() => ({ + registerExtension: vi.fn() +})) +vi.mock('@/scripts/app', () => ({ + app: { registerExtension } +})) + +import { convertToInput } from '@/extensions/core/widgetInputs' +import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' +import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore' +import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils' + +describe('widgetInputs deprecation', () => { + let warnSpy: ReturnType + + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + useDeprecationWarningsStore().clear() + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + }) + + describe('convertToInput', () => { + it('returns the input slot whose widget name matches', () => { + const slot = { widget: { name: 'seed' } } + const node = createMockLGraphNode({ + inputs: [slot, { widget: { name: 'other' } }] + }) + + expect(convertToInput(node, { name: 'seed' } as IBaseWidget)).toBe(slot) + }) + + it('reports a deprecation warning to the store', () => { + const node = createMockLGraphNode({ inputs: [] }) + + convertToInput(node, { name: 'x' } as IBaseWidget) + + const store = useDeprecationWarningsStore() + expect(store.warnings).toHaveLength(1) + expect(store.warnings[0]).toMatchObject({ + message: 'convertToInput is no longer necessary.', + source: 'widgetInputs' + }) + }) + }) + + describe('convertWidgetToInput prototype patch', () => { + it('reports a deprecation when the patched method is called', async () => { + const config = registerExtension.mock.calls.find( + ([c]) => c?.name === 'Comfy.WidgetInputs' + )?.[0] + expect(config?.beforeRegisterNodeDef).toBeDefined() + + class MockNode {} + await config.beforeRegisterNodeDef(MockNode, {}) + const instance = new MockNode() as { convertWidgetToInput: () => boolean } + + expect(instance.convertWidgetToInput()).toBe(false) + + const store = useDeprecationWarningsStore() + expect(store.warnings[0]).toMatchObject({ + message: 'convertWidgetToInput is no longer necessary.', + source: 'widgetInputs' + }) + }) + }) +}) diff --git a/src/extensions/core/widgetInputs.ts b/src/extensions/core/widgetInputs.ts index 3c2ac3a6e9..b7f4c2fdbc 100644 --- a/src/extensions/core/widgetInputs.ts +++ b/src/extensions/core/widgetInputs.ts @@ -13,6 +13,7 @@ import type { } from '@/lib/litegraph/src/types/widgets' import { assetService } from '@/platform/assets/services/assetService' import { createAssetWidget } from '@/platform/assets/utils/createAssetWidget' +import { warnDeprecated } from '@/platform/dev/warnDeprecated' import type { ComfyNodeDef, InputSpec } from '@/schemas/nodeDefSchema' import { app } from '@/scripts/app' import { @@ -426,15 +427,16 @@ function getConfig(this: LGraphNode, widgetName: string) { * @param node The node to convert the widget to an input slot for. * @param widget The widget to convert to an input slot. * @returns The input slot that was converted from the widget or undefined if the widget is not found. - * @knipIgnoreUnusedButUsedByCustomNodes */ export function convertToInput( node: LGraphNode, widget: IBaseWidget ): INodeInputSlot | undefined { - console.warn( - 'Please remove call to convertToInput. Widget to socket conversion is no longer necessary, as they co-exist now.' - ) + warnDeprecated('convertToInput is no longer necessary.', { + suggestion: + 'Remove the call — widgets and sockets now co-exist on each input.', + source: 'widgetInputs' + }) return node.inputs.find((slot) => slot.widget?.name === widget.name) } @@ -517,9 +519,11 @@ app.registerExtension({ ) { // @ts-expect-error adding extra property nodeType.prototype.convertWidgetToInput = function (this: LGraphNode) { - console.warn( - 'Please remove call to convertWidgetToInput. Widget to socket conversion is no longer necessary, as they co-exist now.' - ) + warnDeprecated('convertWidgetToInput is no longer necessary.', { + suggestion: + 'Remove the call — widgets and sockets now co-exist on each input.', + source: 'widgetInputs' + }) return false } diff --git a/src/lib/litegraph/src/LGraphNode.ts b/src/lib/litegraph/src/LGraphNode.ts index fd5133d046..2903a2ecbc 100644 --- a/src/lib/litegraph/src/LGraphNode.ts +++ b/src/lib/litegraph/src/LGraphNode.ts @@ -95,7 +95,7 @@ import type { TWidgetValue } from './types/widgets' import { findFreeSlotOfType } from './utils/collections' -import { warnDeprecated } from './utils/feedback' +import { dispatchLitegraphDeprecation } from './utils/feedback' import { distributeSpace } from './utils/spaceDistribution' import { truncateText } from './utils/textUtils' import { BaseWidget } from './widgets/BaseWidget' @@ -3521,7 +3521,7 @@ export class LGraphNode * @deprecated Use {@link LGraphCanvas.pointer} instead. */ captureInput(v: boolean): void { - warnDeprecated( + dispatchLitegraphDeprecation( '[DEPRECATED] captureInput will be removed in a future version. Please use LGraphCanvas.pointer (CanvasPointer) instead.' ) if (!this.graph || !this.graph.list_of_graphcanvas) return diff --git a/src/lib/litegraph/src/utils/feedback.ts b/src/lib/litegraph/src/utils/feedback.ts index 8215735a43..374d8f559c 100644 --- a/src/lib/litegraph/src/utils/feedback.ts +++ b/src/lib/litegraph/src/utils/feedback.ts @@ -5,19 +5,23 @@ const UNIQUE_MESSAGE_LIMIT = 10_000 const sentWarnings: Set = new Set() /** - * Warns that a deprecated function has been used via the public - * {@link onDeprecationWarning} / {@link onEveryDeprecationWarning} callback arrays. + * Litegraph-internal deprecation dispatcher. Fires + * {@link LiteGraph.onDeprecationWarning} callbacks once per unique message + * (unless {@link LiteGraph.alwaysRepeatWarnings}). + * + * Frontend code outside the litegraph package should use + * `warnDeprecated` from `@/platform/dev/warnDeprecated` instead. + * * @param message Plain-language detail about what has been deprecated. This **should not** include unique data; use {@link source}. * @param source A reference object to include alongside the message, e.g. `this`. */ -export function warnDeprecated(message: string, source?: object): void { +export function dispatchLitegraphDeprecation( + message: string, + source?: object +): void { if (!LiteGraph.alwaysRepeatWarnings) { - // Do not repeat if (sentWarnings.has(message)) return - - // Hard limit of unique messages per session if (sentWarnings.size > UNIQUE_MESSAGE_LIMIT) return - sentWarnings.add(message) } @@ -25,34 +29,3 @@ export function warnDeprecated(message: string, source?: object): void { callback(message, source) } } - -/** - * Defines a deprecated property alias that proxies to a current property, - * logging a deprecation warning on first access. - * - * Warning is deduplicated by {@link warnDeprecated} (once per unique message per session). - * - * @param target The object to define the deprecated property on. - * @param deprecatedKey The old property name to deprecate. - * @param currentKey The new property name to proxy to. - * @param message The deprecation warning message. - */ -export function defineDeprecatedProperty( - target: T, - deprecatedKey: string, - currentKey: keyof T & string, - message: string -): void { - Object.defineProperty(target, deprecatedKey, { - get() { - warnDeprecated(message) - return this[currentKey] - }, - set(value: unknown) { - warnDeprecated(message) - this[currentKey] = value - }, - configurable: true, - enumerable: false - }) -} diff --git a/src/lib/litegraph/src/widgets/ComboWidget.ts b/src/lib/litegraph/src/widgets/ComboWidget.ts index 38d52b31ec..56d2bf783a 100644 --- a/src/lib/litegraph/src/widgets/ComboWidget.ts +++ b/src/lib/litegraph/src/widgets/ComboWidget.ts @@ -6,7 +6,7 @@ import type { IComboWidget, IStringComboWidget } from '@/lib/litegraph/src/types/widgets' -import { warnDeprecated } from '@/lib/litegraph/src/utils/feedback' +import { dispatchLitegraphDeprecation } from '@/lib/litegraph/src/utils/feedback' import { BaseSteppedWidget } from './BaseSteppedWidget' import type { WidgetEventOptions } from './BaseWidget' @@ -129,7 +129,7 @@ export class ComboWidget // Deprecated functionality (warning as of v0.14.5) if (typeof this.options.values === 'function') { - warnDeprecated( + dispatchLitegraphDeprecation( 'Using a function for values is deprecated. Use an array of unique values instead.' ) } diff --git a/src/locales/en/main.json b/src/locales/en/main.json index ffb8625493..f787a82162 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -1046,6 +1046,15 @@ } } }, + "deprecationWarnings": { + "title": "Deprecation Warnings", + "label": "Warnings", + "emptyTitle": "All clear", + "empty": "No deprecation warnings reported in this session.", + "clearAll": "Clear all", + "suggestionLabel": "Fix:", + "occurrenceCount": "Reported {count} time | Reported {count} times" + }, "helpCenter": { "feedback": "Give Feedback", "docs": "Docs", diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index 98766d4c87..681ba55a8f 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -63,7 +63,8 @@ "name": "Require confirmation when clearing workflow" }, "Comfy_DevMode": { - "name": "Enable dev mode options (API save, etc.)" + "name": "Enable dev mode options (API save, etc.)", + "tooltip": "Enables the legacy \"Save (API Format)\" button, dev-only nodes in the node library, and a \"Deprecation Warnings\" sidebar tab that surfaces uses of removed/renamed APIs by custom nodes." }, "Comfy_DisableFloatRounding": { "name": "Disable default float widget rounding.", diff --git a/src/platform/dev/backfillServerDeprecations.test.ts b/src/platform/dev/backfillServerDeprecations.test.ts new file mode 100644 index 0000000000..69c9e490e0 --- /dev/null +++ b/src/platform/dev/backfillServerDeprecations.test.ts @@ -0,0 +1,155 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +type MockApi = { getRawLogs: ReturnType } + +const mockState = vi.hoisted<{ api: MockApi }>(() => ({ + api: { getRawLogs: undefined as never } +})) + +vi.mock('@/scripts/api', () => ({ + get api() { + return mockState.api + } +})) + +function sample(path: string): string { + return `[DEPRECATION WARNING] Detected import of deprecated legacy API: ${path}. This is likely caused by a custom node extension using outdated APIs. Please update your extensions or contact the extension author for an updated version.` +} + +describe('backfillServerDeprecations', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + vi.resetModules() + mockState.api = { getRawLogs: vi.fn() } + }) + + it('seeds pre-load deprecation messages from the raw log buffer', async () => { + mockState.api.getRawLogs.mockResolvedValue({ + entries: [ + { t: 't', m: 'unrelated startup message' }, + { t: 't', m: sample('/scripts/ui/legacy.js') } + ] + }) + + const { backfillServerDeprecations } = + await import('@/platform/dev/backfillServerDeprecations') + const { useDeprecationWarningsStore } = + await import('@/platform/dev/deprecationWarningsStore') + + await backfillServerDeprecations() + + const store = useDeprecationWarningsStore() + expect(store.warnings).toHaveLength(1) + expect(store.warnings[0].message).toBe( + 'Legacy API import: /scripts/ui/legacy.js' + ) + }) + + it('extracts paths containing internal dots without truncation', async () => { + mockState.api.getRawLogs.mockResolvedValue({ + entries: [{ t: 't', m: sample('/scripts/ui.foo.bar.js') }] + }) + + const { backfillServerDeprecations } = + await import('@/platform/dev/backfillServerDeprecations') + const { useDeprecationWarningsStore } = + await import('@/platform/dev/deprecationWarningsStore') + + await backfillServerDeprecations() + + expect(useDeprecationWarningsStore().warnings[0]?.message).toBe( + 'Legacy API import: /scripts/ui.foo.bar.js' + ) + }) + + it('falls back to the raw line (without the legacy-API suggestion) for entries that do not match the template', async () => { + mockState.api.getRawLogs.mockResolvedValue({ + entries: [{ t: 't', m: '[DEPRECATION WARNING] something unexpected' }] + }) + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const { backfillServerDeprecations } = + await import('@/platform/dev/backfillServerDeprecations') + const { useDeprecationWarningsStore } = + await import('@/platform/dev/deprecationWarningsStore') + + await backfillServerDeprecations() + warnSpy.mockRestore() + + const [warning] = useDeprecationWarningsStore().warnings + expect(warning?.message).toBe('[DEPRECATION WARNING] something unexpected') + expect(warning?.suggestion).toBeUndefined() + }) + + it('ignores non-deprecation log entries', async () => { + mockState.api.getRawLogs.mockResolvedValue({ + entries: [ + { t: 't', m: 'got prompt' }, + { t: 't', m: '[INFO] something else' } + ] + }) + + const { backfillServerDeprecations } = + await import('@/platform/dev/backfillServerDeprecations') + const { useDeprecationWarningsStore } = + await import('@/platform/dev/deprecationWarningsStore') + + await backfillServerDeprecations() + + expect(useDeprecationWarningsStore().warnings).toHaveLength(0) + }) + + it('swallows getRawLogs failures gracefully and logs the error', async () => { + const failure = new Error('401 unauthenticated') + mockState.api.getRawLogs.mockRejectedValue(failure) + + const { backfillServerDeprecations } = + await import('@/platform/dev/backfillServerDeprecations') + const { useDeprecationWarningsStore } = + await import('@/platform/dev/deprecationWarningsStore') + + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + await backfillServerDeprecations() + + expect(useDeprecationWarningsStore().warnings).toHaveLength(0) + expect(errSpy).toHaveBeenCalledExactlyOnceWith( + 'Failed to fetch initial server logs for deprecations:', + failure + ) + errSpy.mockRestore() + }) + + it('only fetches once after a successful backfill', async () => { + const api = mockState.api + api.getRawLogs.mockResolvedValue({ entries: [] }) + + const { backfillServerDeprecations } = + await import('@/platform/dev/backfillServerDeprecations') + + await backfillServerDeprecations() + await backfillServerDeprecations() + await backfillServerDeprecations() + + expect(api.getRawLogs).toHaveBeenCalledTimes(1) + }) + + it('retries on a subsequent call after a failure', async () => { + const api = mockState.api + api.getRawLogs + .mockRejectedValueOnce(new Error('transient')) + .mockResolvedValueOnce({ entries: [] }) + + const { backfillServerDeprecations } = + await import('@/platform/dev/backfillServerDeprecations') + + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + await backfillServerDeprecations() + await backfillServerDeprecations() + errSpy.mockRestore() + + expect(api.getRawLogs).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/platform/dev/backfillServerDeprecations.ts b/src/platform/dev/backfillServerDeprecations.ts new file mode 100644 index 0000000000..ae2e66d619 --- /dev/null +++ b/src/platform/dev/backfillServerDeprecations.ts @@ -0,0 +1,42 @@ +import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore' +import { api } from '@/scripts/api' + +const PREFIX = '[DEPRECATION WARNING]' +const LEGACY_API_PATH_REGEX = /:\s*(\S+?)\.\s+This is likely caused/ + +let backfilled = false + +/** + * Seed the store from the backend's raw log buffer. + */ +export async function backfillServerDeprecations(): Promise { + if (backfilled) return + try { + const logs = await api.getRawLogs() + const store = useDeprecationWarningsStore() + for (const entry of logs.entries) { + if (!entry.m.includes(PREFIX)) continue + const path = LEGACY_API_PATH_REGEX.exec(entry.m)?.[1] + if (path) { + store.report({ + message: `Legacy API import: ${path}`, + suggestion: + 'Update the extension that imports this file, or ask its author to migrate.', + source: 'server' + }) + } else { + console.warn( + 'Server deprecation log did not match legacy-API template; falling back to raw line. Update LEGACY_API_PATH_REGEX:', + entry.m + ) + store.report({ + message: entry.m.trim(), + source: 'server' + }) + } + } + backfilled = true + } catch (err) { + console.error('Failed to fetch initial server logs for deprecations:', err) + } +} diff --git a/src/platform/dev/deprecationWarningsStore.test.ts b/src/platform/dev/deprecationWarningsStore.test.ts new file mode 100644 index 0000000000..efb8aa5268 --- /dev/null +++ b/src/platform/dev/deprecationWarningsStore.test.ts @@ -0,0 +1,91 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore' + +describe('useDeprecationWarningsStore', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('appends a new warning when key is novel', () => { + const store = useDeprecationWarningsStore() + store.report({ message: 'thing is deprecated', source: 'frontend' }) + + expect(store.warnings).toHaveLength(1) + expect(store.warnings[0]).toMatchObject({ + message: 'thing is deprecated', + source: 'frontend', + count: 1 + }) + }) + + it('increments count and updates lastSeenAt on duplicate report', () => { + const store = useDeprecationWarningsStore() + store.report({ message: 'thing is deprecated', source: 'frontend' }) + const initialLastSeenAt = store.warnings[0].lastSeenAt + + vi.advanceTimersByTime(5_000) + store.report({ message: 'thing is deprecated', source: 'frontend' }) + + expect(store.warnings).toHaveLength(1) + expect(store.warnings[0].count).toBe(2) + expect(store.warnings[0].lastSeenAt).toBe(initialLastSeenAt + 5_000) + }) + + it('treats different sources as distinct entries with the same message', () => { + const store = useDeprecationWarningsStore() + store.report({ message: 'foo', source: 'a' }) + store.report({ message: 'foo', source: 'b' }) + + expect(store.warnings).toHaveLength(2) + }) + + it('exposes unseenCount that resets after markAllSeen', () => { + const store = useDeprecationWarningsStore() + expect(store.unseenCount).toBe(0) + + store.report({ message: 'a' }) + store.report({ message: 'b' }) + expect(store.unseenCount).toBe(2) + + store.markAllSeen() + expect(store.unseenCount).toBe(0) + + store.report({ message: 'c' }) + expect(store.unseenCount).toBe(1) + }) + + it('does not increase unseenCount when a duplicate is reported', () => { + const store = useDeprecationWarningsStore() + store.report({ message: 'a' }) + store.markAllSeen() + store.report({ message: 'a' }) + + expect(store.unseenCount).toBe(0) + expect(store.warnings[0].count).toBe(2) + }) + + it('clear empties warnings and resets seen state', () => { + const store = useDeprecationWarningsStore() + store.report({ message: 'a' }) + store.report({ message: 'b' }) + store.markAllSeen() + + store.clear() + + expect(store.warnings).toEqual([]) + expect(store.unseenCount).toBe(0) + + store.report({ message: 'a' }) + expect(store.warnings).toHaveLength(1) + expect(store.unseenCount).toBe(1) + }) +}) diff --git a/src/platform/dev/deprecationWarningsStore.ts b/src/platform/dev/deprecationWarningsStore.ts new file mode 100644 index 0000000000..5ac283bed8 --- /dev/null +++ b/src/platform/dev/deprecationWarningsStore.ts @@ -0,0 +1,84 @@ +import { defineStore } from 'pinia' +import { computed, reactive } from 'vue' + +export interface DeprecationWarning { + key: string + message: string + suggestion?: string + source?: string + count: number + lastSeenAt: number +} + +export interface ReportDeprecationInput { + message: string + suggestion?: string + source?: string +} + +export function deprecationKey(input: ReportDeprecationInput): string { + return `${input.source ?? ''}::${input.message}` +} + +const pendingBuffer: ReportDeprecationInput[] = [] + +export function bufferDeprecation(input: ReportDeprecationInput): void { + pendingBuffer.push(input) +} + +export const useDeprecationWarningsStore = defineStore( + 'deprecationWarnings', + () => { + const warningsByKey = reactive(new Map()) + const unseenKeys = reactive(new Set()) + + const warnings = computed(() => + Array.from(warningsByKey.values()).sort( + (a, b) => b.lastSeenAt - a.lastSeenAt + ) + ) + const unseenCount = computed(() => unseenKeys.size) + + function report(input: ReportDeprecationInput): boolean { + const key = deprecationKey(input) + const now = Date.now() + const existing = warningsByKey.get(key) + + if (existing) { + existing.count += 1 + existing.lastSeenAt = now + return false + } + + warningsByKey.set(key, { + key, + message: input.message, + suggestion: input.suggestion, + source: input.source, + count: 1, + lastSeenAt: now + }) + unseenKeys.add(key) + return true + } + + function markAllSeen(): void { + unseenKeys.clear() + } + + function clear(): void { + warningsByKey.clear() + unseenKeys.clear() + } + + for (const input of pendingBuffer.splice(0)) report(input) + + return { + warnings, + unseenCount, + report, + markAllSeen, + clear + } + } +) diff --git a/src/platform/dev/installLiteGraphDeprecationBridge.test.ts b/src/platform/dev/installLiteGraphDeprecationBridge.test.ts new file mode 100644 index 0000000000..6e257a87a9 --- /dev/null +++ b/src/platform/dev/installLiteGraphDeprecationBridge.test.ts @@ -0,0 +1,70 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +describe('installLiteGraphDeprecationBridge', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + vi.resetModules() + }) + + it('forwards LiteGraph deprecation callbacks into the store', async () => { + const { LiteGraph } = await import('@/lib/litegraph/src/litegraph') + const { installLiteGraphDeprecationBridge } = + await import('@/platform/dev/installLiteGraphDeprecationBridge') + const { useDeprecationWarningsStore } = + await import('@/platform/dev/deprecationWarningsStore') + + const baseline = LiteGraph.onDeprecationWarning.length + installLiteGraphDeprecationBridge() + expect(LiteGraph.onDeprecationWarning).toHaveLength(baseline + 1) + + const bridgeCallback = LiteGraph.onDeprecationWarning.at(-1)! + bridgeCallback('bridge-test-message') + + const store = useDeprecationWarningsStore() + expect(store.warnings).toHaveLength(1) + expect(store.warnings[0]).toMatchObject({ + message: 'bridge-test-message', + source: 'litegraph' + }) + }) + + it('only registers one forwarding callback no matter how many times install is called', async () => { + const { LiteGraph } = await import('@/lib/litegraph/src/litegraph') + const { installLiteGraphDeprecationBridge } = + await import('@/platform/dev/installLiteGraphDeprecationBridge') + + const baseline = LiteGraph.onDeprecationWarning.length + + installLiteGraphDeprecationBridge() + installLiteGraphDeprecationBridge() + installLiteGraphDeprecationBridge() + + expect(LiteGraph.onDeprecationWarning).toHaveLength(baseline + 1) + }) + + it('drains warnings buffered before pinia was active when the first LiteGraph callback fires', async () => { + setActivePinia(undefined) + + const { warnDeprecated } = await import('@/platform/dev/warnDeprecated') + warnDeprecated('early-frontend', { source: 'frontend' }) + + setActivePinia(createTestingPinia({ stubActions: false })) + + const { LiteGraph } = await import('@/lib/litegraph/src/litegraph') + const { installLiteGraphDeprecationBridge } = + await import('@/platform/dev/installLiteGraphDeprecationBridge') + const { useDeprecationWarningsStore } = + await import('@/platform/dev/deprecationWarningsStore') + + installLiteGraphDeprecationBridge() + LiteGraph.onDeprecationWarning.at(-1)!('lg-message') + + const store = useDeprecationWarningsStore() + expect(store.warnings.map((w) => w.message).sort()).toEqual([ + 'early-frontend', + 'lg-message' + ]) + }) +}) diff --git a/src/platform/dev/installLiteGraphDeprecationBridge.ts b/src/platform/dev/installLiteGraphDeprecationBridge.ts new file mode 100644 index 0000000000..b9100c3bf4 --- /dev/null +++ b/src/platform/dev/installLiteGraphDeprecationBridge.ts @@ -0,0 +1,16 @@ +import { LiteGraph } from '@/lib/litegraph/src/litegraph' +import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore' + +let installed = false + +export function installLiteGraphDeprecationBridge(): void { + if (installed) return + installed = true + + LiteGraph.onDeprecationWarning.push((message: string) => { + useDeprecationWarningsStore().report({ + message, + source: 'litegraph' + }) + }) +} diff --git a/src/platform/dev/warnDeprecated.test.ts b/src/platform/dev/warnDeprecated.test.ts new file mode 100644 index 0000000000..e57054de0f --- /dev/null +++ b/src/platform/dev/warnDeprecated.test.ts @@ -0,0 +1,154 @@ +import { createTestingPinia } from '@pinia/testing' +import { getActivePinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +describe('warnDeprecated', () => { + let warnSpy: ReturnType + + beforeEach(() => { + setActivePinia(undefined) + vi.resetModules() + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + }) + + it('formats the console message with source tag and suggestion suffix', async () => { + setActivePinia(createTestingPinia({ stubActions: false })) + const { warnDeprecated } = await import('@/platform/dev/warnDeprecated') + + warnDeprecated('use bar() instead of foo()', { + source: 'frontend', + suggestion: 'Migrate by 2.0.' + }) + + expect(warnSpy).toHaveBeenCalledExactlyOnceWith( + '[DEPRECATED:frontend] use bar() instead of foo() Migrate by 2.0.' + ) + }) + + it('omits the source tag when no source is provided', async () => { + setActivePinia(createTestingPinia({ stubActions: false })) + const { warnDeprecated } = await import('@/platform/dev/warnDeprecated') + + warnDeprecated('something is deprecated') + + expect(warnSpy).toHaveBeenCalledExactlyOnceWith( + '[DEPRECATED] something is deprecated' + ) + }) + + it('does not re-log to console on subsequent duplicate calls post-pinia', async () => { + setActivePinia(createTestingPinia({ stubActions: false })) + const { warnDeprecated } = await import('@/platform/dev/warnDeprecated') + + warnDeprecated('same', { source: 'a' }) + warnDeprecated('same', { source: 'a' }) + warnDeprecated('same', { source: 'a' }) + + expect(warnSpy).toHaveBeenCalledTimes(1) + }) + + it('buffers warnings fired before pinia is active; drains them into the store on first access', async () => { + expect(getActivePinia()).toBeUndefined() + + const { warnDeprecated } = await import('@/platform/dev/warnDeprecated') + + warnDeprecated('early one', { source: 'boot' }) + warnDeprecated('early two', { source: 'boot' }) + warnDeprecated('early one', { source: 'boot' }) + + expect(warnSpy).toHaveBeenCalledTimes(3) + expect(warnSpy).toHaveBeenNthCalledWith(1, '[DEPRECATED:boot] early one') + expect(warnSpy).toHaveBeenNthCalledWith(2, '[DEPRECATED:boot] early two') + expect(warnSpy).toHaveBeenNthCalledWith(3, '[DEPRECATED:boot] early one') + + setActivePinia(createTestingPinia({ stubActions: false })) + const { useDeprecationWarningsStore } = + await import('@/platform/dev/deprecationWarningsStore') + const store = useDeprecationWarningsStore() + + expect(store.warnings).toHaveLength(2) + const earlyOne = store.warnings.find((w) => w.message === 'early one') + expect(earlyOne?.count).toBe(2) + }) + + it('drains the pre-pinia buffer when the next warnDeprecated lands post-pinia', async () => { + const { warnDeprecated } = await import('@/platform/dev/warnDeprecated') + + warnDeprecated('buffered', { source: 'boot' }) + + setActivePinia(createTestingPinia({ stubActions: false })) + warnDeprecated('post-pinia', { source: 'late' }) + + const { useDeprecationWarningsStore } = + await import('@/platform/dev/deprecationWarningsStore') + const store = useDeprecationWarningsStore() + expect(store.warnings.map((w) => w.message).sort()).toEqual([ + 'buffered', + 'post-pinia' + ]) + }) +}) + +describe('defineDeprecatedProperty', () => { + let warnSpy: ReturnType + + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + vi.resetModules() + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + }) + + it('proxies reads to the current key and warns once', async () => { + const { defineDeprecatedProperty } = + await import('@/platform/dev/warnDeprecated') + const { useDeprecationWarningsStore } = + await import('@/platform/dev/deprecationWarningsStore') + + const target: { newName: string; oldName?: string } = { newName: 'hello' } + defineDeprecatedProperty( + target, + 'oldName', + 'newName', + 'target.oldName is deprecated.', + { source: 'unit-test', suggestion: 'Use target.newName.' } + ) + + expect(target.oldName).toBe('hello') + expect(target.oldName).toBe('hello') + + const store = useDeprecationWarningsStore() + expect(store.warnings).toHaveLength(1) + expect(store.warnings[0]).toMatchObject({ + message: 'target.oldName is deprecated.', + suggestion: 'Use target.newName.', + source: 'unit-test' + }) + }) + + it('proxies writes to the current key and warns', async () => { + const { defineDeprecatedProperty } = + await import('@/platform/dev/warnDeprecated') + const { useDeprecationWarningsStore } = + await import('@/platform/dev/deprecationWarningsStore') + + const target: { newName: string; oldName?: string } = { newName: 'a' } + defineDeprecatedProperty( + target, + 'oldName', + 'newName', + 'target.oldName is deprecated.' + ) + + target.oldName = 'b' + expect(target.newName).toBe('b') + expect(useDeprecationWarningsStore().warnings).toHaveLength(1) + }) +}) diff --git a/src/platform/dev/warnDeprecated.ts b/src/platform/dev/warnDeprecated.ts new file mode 100644 index 0000000000..78b883bcca --- /dev/null +++ b/src/platform/dev/warnDeprecated.ts @@ -0,0 +1,57 @@ +import { getActivePinia } from 'pinia' + +import type { ReportDeprecationInput } from '@/platform/dev/deprecationWarningsStore' +import { + bufferDeprecation, + useDeprecationWarningsStore +} from '@/platform/dev/deprecationWarningsStore' + +export type WarnDeprecatedOptions = Omit + +function formatConsoleMessage(input: ReportDeprecationInput): string { + const tag = input.source ? `[DEPRECATED:${input.source}]` : '[DEPRECATED]' + const suggestionSuffix = input.suggestion ? ` ${input.suggestion}` : '' + return `${tag} ${input.message}${suggestionSuffix}` +} + +export function warnDeprecated( + message: string, + options: WarnDeprecatedOptions = {} +): void { + const input: ReportDeprecationInput = { message, ...options } + + if (!getActivePinia()) { + bufferDeprecation(input) + console.warn(formatConsoleMessage(input)) + return + } + + if (useDeprecationWarningsStore().report(input)) { + console.warn(formatConsoleMessage(input)) + } +} + +/** + * Defines a deprecated property alias that proxies to a current property, + * logging a deprecation warning via {@link warnDeprecated} on first access. + */ +export function defineDeprecatedProperty( + target: T, + deprecatedKey: string, + currentKey: keyof T & string, + message: string, + options: WarnDeprecatedOptions = {} +): void { + Object.defineProperty(target, deprecatedKey, { + get() { + warnDeprecated(message, options) + return this[currentKey] + }, + set(value: unknown) { + warnDeprecated(message, options) + this[currentKey] = value + }, + configurable: true, + enumerable: false + }) +} diff --git a/src/platform/settings/constants/coreSettings.ts b/src/platform/settings/constants/coreSettings.ts index 98bbaf3268..272437c38a 100644 --- a/src/platform/settings/constants/coreSettings.ts +++ b/src/platform/settings/constants/coreSettings.ts @@ -571,6 +571,8 @@ export const CORE_SETTINGS: SettingParams[] = [ { id: 'Comfy.DevMode', name: 'Enable dev mode options (API save, etc.)', + tooltip: + 'Enables the legacy "Save (API Format)" button, dev-only nodes in the node library, and a "Deprecation Warnings" sidebar tab that surfaces uses of removed/renamed APIs by custom nodes.', type: 'boolean', defaultValue: false, onChange: (value) => { diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.ts index a295384056..c8f4052ca2 100644 --- a/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.ts +++ b/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.ts @@ -1,6 +1,6 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import { resolveNodeRootGraphId } from '@/lib/litegraph/src/litegraph' -import { defineDeprecatedProperty } from '@/lib/litegraph/src/utils/feedback' +import { defineDeprecatedProperty } from '@/platform/dev/warnDeprecated' import { bindMultilineTextareaWidget, createMultilineInputElement diff --git a/src/scripts/changeTracker.ts b/src/scripts/changeTracker.ts index 54312ea239..49eadc249e 100644 --- a/src/scripts/changeTracker.ts +++ b/src/scripts/changeTracker.ts @@ -3,6 +3,7 @@ import _ from 'es-toolkit/compat' import { assert } from '@/base/assert' import type { CanvasPointerEvent } from '@/lib/litegraph/src/litegraph' import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph' +import { warnDeprecated } from '@/platform/dev/warnDeprecated' import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore' import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema' @@ -215,17 +216,13 @@ export class ChangeTracker { /** @deprecated Use {@link captureCanvasState} instead. */ checkState() { - if (!ChangeTracker._checkStateWarned) { - ChangeTracker._checkStateWarned = true - console.warn( - 'checkState() is deprecated — use captureCanvasState() instead.' - ) - } + warnDeprecated('ChangeTracker.checkState() is deprecated.', { + suggestion: 'Call captureCanvasState() instead.', + source: 'ChangeTracker' + }) this.captureCanvasState() } - private static _checkStateWarned = false - async updateState(source: ComfyWorkflowJSON[], target: ComfyWorkflowJSON[]) { const prevState = source.pop() if (prevState) { diff --git a/src/scripts/ui.ts b/src/scripts/ui.ts index 5e00d97679..f5719abead 100644 --- a/src/scripts/ui.ts +++ b/src/scripts/ui.ts @@ -1,4 +1,5 @@ import { useRunButtonTelemetry } from '@/composables/useRunButtonTelemetry' +import { warnDeprecated } from '@/platform/dev/warnDeprecated' import { extractWorkflow } from '@/platform/remote/comfyui/jobs/fetchJobs' import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes' import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog' @@ -246,11 +247,13 @@ class ComfyList { this.element = $el('div.comfy-list') as HTMLDivElement this.element.style.display = 'none' - console.warn( - '[ComfyUI] The legacy queue/history menu is deprecated. ' + - 'Core functionality in this menu may break at any time. ' + - 'Issues and feature requests related to the legacy menu will not be addressed. ' + - 'To switch to the new menu: Settings → search "Use new menu" → change from "Disabled" to "Top".' + warnDeprecated( + 'The legacy queue/history menu is deprecated and unsupported.', + { + suggestion: + 'Switch to the new menu: Settings → search "Use new menu" → change from "Disabled" to "Top".', + source: 'ComfyUI' + } ) } diff --git a/src/scripts/ui/settings.ts b/src/scripts/ui/settings.ts index 8f1bfca161..a93e6aaf1b 100644 --- a/src/scripts/ui/settings.ts +++ b/src/scripts/ui/settings.ts @@ -1,4 +1,5 @@ import { t } from '@/i18n' +import { warnDeprecated } from '@/platform/dev/warnDeprecated' import { useSettingStore } from '@/platform/settings/settingStore' import type { SettingParams } from '@/platform/settings/types' import { useToastStore } from '@/platform/updates/common/toastStore' @@ -55,8 +56,13 @@ export class ComfySettingsDialog extends ComfyDialog { defaultValue?: Settings[K] ): Settings[K] { if (defaultValue !== undefined) { - console.warn( - `Parameter defaultValue is deprecated. The default value in settings definition will be used instead.` + warnDeprecated( + 'getSettingValue() defaultValue parameter is deprecated.', + { + suggestion: + 'Drop the argument — the default value in the setting definition will be used.', + source: 'ComfySettingsDialog' + } ) } return useSettingStore().get(id) diff --git a/src/services/litegraphService.ts b/src/services/litegraphService.ts index 0d48b21701..38bf3dc908 100644 --- a/src/services/litegraphService.ts +++ b/src/services/litegraphService.ts @@ -36,6 +36,7 @@ import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation' import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' +import { warnDeprecated } from '@/platform/dev/warnDeprecated' import { useSettingStore } from '@/platform/settings/settingStore' import { useToastStore } from '@/platform/updates/common/toastStore' import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' @@ -826,9 +827,10 @@ export const useLitegraphService = () => { * @deprecated No longer needed as we use {@link useImagePreviewWidget} */ node.prototype.setSizeForImage = function (this: LGraphNode) { - console.warn( - 'node.setSizeForImage is deprecated. Now it has no effect. Please remove the call to it.' - ) + warnDeprecated('node.setSizeForImage is deprecated and has no effect.', { + suggestion: 'Remove the call.', + source: 'litegraphService' + }) } node.prototype.onDrawBackground = function () { updatePreviews(this) diff --git a/src/stores/commandStore.ts b/src/stores/commandStore.ts index 27e032114f..49f5fb5260 100644 --- a/src/stores/commandStore.ts +++ b/src/stores/commandStore.ts @@ -88,6 +88,10 @@ export const useCommandStore = defineStore('command', () => { } } + const unregisterCommand = (id: string) => { + delete commandsById.value[id] + } + const getCommand = (command: string) => { return commandsById.value[command] } @@ -139,6 +143,7 @@ export const useCommandStore = defineStore('command', () => { getCommand, registerCommand, registerCommands, + unregisterCommand, isRegistered, loadExtensionCommands, formatKeySequence diff --git a/src/stores/nodeDefStore.test.ts b/src/stores/nodeDefStore.test.ts index 3098197e4d..118ab80125 100644 --- a/src/stores/nodeDefStore.test.ts +++ b/src/stores/nodeDefStore.test.ts @@ -1,6 +1,6 @@ import { createTestingPinia } from '@pinia/testing' import { setActivePinia } from 'pinia' -import { beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils' import { LGraphNode } from '@/lib/litegraph/src/litegraph' @@ -9,8 +9,9 @@ import { createTestSubgraph, createTestSubgraphNode } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers' +import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore' import type { ComfyNodeDef } from '@/schemas/nodeDefSchema' -import { useNodeDefStore } from '@/stores/nodeDefStore' +import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore' import type { NodeDefFilter } from '@/stores/nodeDefStore' describe('useNodeDefStore', () => { @@ -420,3 +421,65 @@ describe('useNodeDefStore', () => { }) }) }) + +describe('ComfyNodeDefImpl defaultInput deprecation', () => { + let warnSpy: ReturnType + + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + useDeprecationWarningsStore().clear() + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + }) + + it('reports a deprecation when defaultInput is set on a required input', () => { + new ComfyNodeDefImpl({ + name: 'N', + display_name: 'N', + category: 'c', + python_module: 'm', + description: '', + input: { required: { x: ['INT', { defaultInput: true }] } }, + output: [], + output_is_list: [], + output_name: [], + output_node: false, + deprecated: false, + experimental: false + }) + + const store = useDeprecationWarningsStore() + expect(store.warnings).toHaveLength(1) + expect(store.warnings[0]).toMatchObject({ + message: 'Use of defaultInput on required input m:N:x.', + source: 'nodeDef' + }) + }) + + it('reports a deprecation and migrates to forceInput on an optional input', () => { + const def = new ComfyNodeDefImpl({ + name: 'N', + display_name: 'N', + category: 'c', + python_module: 'm', + description: '', + input: { optional: { y: ['INT', { defaultInput: true }] } }, + output: [], + output_is_list: [], + output_name: [], + output_node: false, + deprecated: false, + experimental: false + }) + + const store = useDeprecationWarningsStore() + expect(store.warnings[0]).toMatchObject({ + message: 'Use of defaultInput on optional input m:N:y.', + source: 'nodeDef' + }) + expect(def.inputs.y).toMatchObject({ forceInput: true }) + }) +}) diff --git a/src/stores/nodeDefStore.ts b/src/stores/nodeDefStore.ts index 91eb436720..7ec4dd8408 100644 --- a/src/stores/nodeDefStore.ts +++ b/src/stores/nodeDefStore.ts @@ -9,6 +9,7 @@ import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConc import { resolveInputType } from '@/core/graph/widgets/dynamicTypes' import { LiteGraph } from '@/lib/litegraph/src/litegraph' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' +import { warnDeprecated } from '@/platform/dev/warnDeprecated' import { transformNodeDefV1ToV2 } from '@/schemas/nodeDef/migration' import type { ComfyNodeDef as ComfyNodeDefV2, @@ -113,8 +114,13 @@ export class ComfyNodeDefImpl for (const [name, spec] of Object.entries(def.input.required ?? {})) { const inputOptions = spec[1] if (inputOptions && inputOptions.defaultInput) { - console.warn( - `Use of defaultInput on required input ${nodeDef.python_module}:${nodeDef.name}:${name} is deprecated. Please drop the defaultInput option.` + warnDeprecated( + `Use of defaultInput on required input ${nodeDef.python_module}:${nodeDef.name}:${name}.`, + { + suggestion: + 'Drop the defaultInput option — required sockets are always present.', + source: 'nodeDef' + } ) } } @@ -124,8 +130,12 @@ export class ComfyNodeDefImpl for (const [name, spec] of Object.entries(def.input.optional ?? {})) { const inputOptions = spec[1] if (inputOptions && inputOptions.defaultInput) { - console.warn( - `Use of defaultInput on optional input ${nodeDef.python_module}:${nodeDef.name}:${name} is deprecated. Please use forceInput instead.` + warnDeprecated( + `Use of defaultInput on optional input ${nodeDef.python_module}:${nodeDef.name}:${name}.`, + { + suggestion: 'Replace with forceInput.', + source: 'nodeDef' + } ) inputOptions.forceInput = true } diff --git a/src/stores/workspace/sidebarTabStore.test.ts b/src/stores/workspace/sidebarTabStore.test.ts index cef96df72b..9de90c934c 100644 --- a/src/stores/workspace/sidebarTabStore.test.ts +++ b/src/stores/workspace/sidebarTabStore.test.ts @@ -5,12 +5,21 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore' -const { mockGetSetting, mockRegisterCommand, mockRegisterCommands } = - vi.hoisted(() => ({ - mockGetSetting: vi.fn(), - mockRegisterCommand: vi.fn(), - mockRegisterCommands: vi.fn() - })) +const { + mockGetSetting, + mockRegisterCommand, + mockRegisterCommands, + mockUnregisterCommand, + mockInstallLiteGraphBridge, + mockBackfillServerDeprecations +} = vi.hoisted(() => ({ + mockGetSetting: vi.fn(), + mockRegisterCommand: vi.fn(), + mockRegisterCommands: vi.fn(), + mockUnregisterCommand: vi.fn(), + mockInstallLiteGraphBridge: vi.fn(), + mockBackfillServerDeprecations: vi.fn().mockResolvedValue(undefined) +})) vi.mock('@/platform/settings/settingStore', () => ({ useSettingStore: () => ({ @@ -21,6 +30,7 @@ vi.mock('@/platform/settings/settingStore', () => ({ vi.mock('@/stores/commandStore', () => ({ useCommandStore: () => ({ registerCommand: mockRegisterCommand, + unregisterCommand: mockUnregisterCommand, commands: [] }) })) @@ -54,6 +64,16 @@ vi.mock('@/composables/sidebarTabs/useJobHistorySidebarTab', () => ({ }) })) +vi.mock('@/composables/sidebarTabs/useDeprecationWarningsSidebarTab', () => ({ + DEPRECATION_WARNINGS_TAB_ID: 'deprecation-warnings', + useDeprecationWarningsSidebarTab: () => ({ + id: 'deprecation-warnings', + title: 'deprecation-warnings', + type: 'vue', + component: {} + }) +})) + vi.mock('@/composables/sidebarTabs/useNodeLibrarySidebarTab', () => ({ useNodeLibrarySidebarTab: () => ({ id: 'node-library', @@ -93,12 +113,18 @@ vi.mock('@/platform/workflow/management/composables/useAppsSidebarTab', () => ({ }) })) +vi.mock('@/platform/dev/installLiteGraphDeprecationBridge', () => ({ + installLiteGraphDeprecationBridge: mockInstallLiteGraphBridge +})) + +vi.mock('@/platform/dev/backfillServerDeprecations', () => ({ + backfillServerDeprecations: mockBackfillServerDeprecations +})) + describe('useSidebarTabStore', () => { beforeEach(() => { setActivePinia(createTestingPinia({ stubActions: false })) - mockGetSetting.mockReset() - mockRegisterCommand.mockClear() - mockRegisterCommands.mockClear() + vi.clearAllMocks() }) it('registers the job history tab when QPO V2 is enabled', () => { @@ -160,4 +186,105 @@ describe('useSidebarTabStore', () => { ]) expect(mockRegisterCommand).toHaveBeenCalledTimes(6) }) + + it('registers the deprecation warnings tab when DevMode is enabled at init', () => { + mockGetSetting.mockImplementation((key: string) => + key === 'Comfy.DevMode' ? true : undefined + ) + + const store = useSidebarTabStore() + store.registerCoreSidebarTabs() + + expect(store.sidebarTabs.map((tab) => tab.id)).toContain( + 'deprecation-warnings' + ) + }) + + it('does not register the deprecation warnings tab when DevMode is disabled', () => { + mockGetSetting.mockImplementation(() => false) + + const store = useSidebarTabStore() + store.registerCoreSidebarTabs() + + expect(store.sidebarTabs.map((tab) => tab.id)).not.toContain( + 'deprecation-warnings' + ) + }) + + it('registers and unregisters the deprecation warnings tab when DevMode is toggled', async () => { + const devMode = ref(false) + mockGetSetting.mockImplementation((key: string) => + key === 'Comfy.DevMode' ? devMode.value : undefined + ) + + const store = useSidebarTabStore() + store.registerCoreSidebarTabs() + + expect(store.sidebarTabs.map((tab) => tab.id)).not.toContain( + 'deprecation-warnings' + ) + + devMode.value = true + await nextTick() + expect(store.sidebarTabs.map((tab) => tab.id)).toContain( + 'deprecation-warnings' + ) + + devMode.value = false + await nextTick() + expect(store.sidebarTabs.map((tab) => tab.id)).not.toContain( + 'deprecation-warnings' + ) + }) + + it('unregisters the toggle command when DevMode flips off so re-enabling does not warn', async () => { + const devMode = ref(false) + mockGetSetting.mockImplementation((key: string) => + key === 'Comfy.DevMode' ? devMode.value : undefined + ) + + const store = useSidebarTabStore() + store.registerCoreSidebarTabs() + + devMode.value = true + await nextTick() + devMode.value = false + await nextTick() + devMode.value = true + await nextTick() + + expect(store.sidebarTabs.map((tab) => tab.id)).toContain( + 'deprecation-warnings' + ) + expect(mockUnregisterCommand).toHaveBeenCalledWith( + 'Workspace.ToggleSidebarTab.deprecation-warnings' + ) + const deprecationRegistrations = mockRegisterCommand.mock.calls.filter( + ([cmd]) => cmd.id === 'Workspace.ToggleSidebarTab.deprecation-warnings' + ) + expect(deprecationRegistrations).toHaveLength(2) + }) + + it('installs the LiteGraph bridge and backfills server logs at boot regardless of DevMode', () => { + mockGetSetting.mockImplementation(() => false) + + useSidebarTabStore().registerCoreSidebarTabs() + + expect(mockInstallLiteGraphBridge).toHaveBeenCalledTimes(1) + expect(mockBackfillServerDeprecations).toHaveBeenCalledTimes(1) + }) + + it('does not re-install the bridge or re-backfill when DevMode flips on later', async () => { + const devMode = ref(false) + mockGetSetting.mockImplementation((key: string) => + key === 'Comfy.DevMode' ? devMode.value : undefined + ) + + useSidebarTabStore().registerCoreSidebarTabs() + devMode.value = true + await nextTick() + + expect(mockInstallLiteGraphBridge).toHaveBeenCalledTimes(1) + expect(mockBackfillServerDeprecations).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/stores/workspace/sidebarTabStore.ts b/src/stores/workspace/sidebarTabStore.ts index 662d967544..0b7218155f 100644 --- a/src/stores/workspace/sidebarTabStore.ts +++ b/src/stores/workspace/sidebarTabStore.ts @@ -2,10 +2,16 @@ import { defineStore } from 'pinia' import { computed, ref, watch } from 'vue' import { useAssetsSidebarTab } from '@/composables/sidebarTabs/useAssetsSidebarTab' +import { + DEPRECATION_WARNINGS_TAB_ID, + useDeprecationWarningsSidebarTab +} from '@/composables/sidebarTabs/useDeprecationWarningsSidebarTab' import { useJobHistorySidebarTab } from '@/composables/sidebarTabs/useJobHistorySidebarTab' import { useModelLibrarySidebarTab } from '@/composables/sidebarTabs/useModelLibrarySidebarTab' import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab' import { t, te } from '@/i18n' +import { installLiteGraphDeprecationBridge } from '@/platform/dev/installLiteGraphDeprecationBridge' +import { backfillServerDeprecations } from '@/platform/dev/backfillServerDeprecations' import { useSettingStore } from '@/platform/settings/settingStore' import { useAppsSidebarTab } from '@/platform/workflow/management/composables/useAppsSidebarTab' import { useWorkflowsSidebarTab } from '@/platform/workflow/management/composables/useWorkflowsSidebarTab' @@ -53,7 +59,8 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => { 'model-library': 'sideToolbar.modelLibrary', workflows: 'sideToolbar.workflows', assets: 'sideToolbar.assets', - 'job-history': 'queue.jobHistory' + 'job-history': 'queue.jobHistory', + [DEPRECATION_WARNINGS_TAB_ID]: 'deprecationWarnings.title' } const key = menubarLabelKeys[tab.id] @@ -106,6 +113,7 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => { if (activeSidebarTabId.value === id) { activeSidebarTabId.value = null } + useCommandStore().unregisterCommand(`Workspace.ToggleSidebarTab.${id}`) } } @@ -114,6 +122,7 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => { */ const registerCoreSidebarTabs = () => { const settingStore = useSettingStore() + const jobHistoryTabId = 'job-history' const syncJobHistoryTab = (enabled: boolean) => { const hasJobHistoryTab = sidebarTabs.value.some( @@ -125,13 +134,33 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => { unregisterSidebarTab(jobHistoryTabId) } } - syncJobHistoryTab(settingStore.get('Comfy.Queue.QPOV2')) watch( () => settingStore.get('Comfy.Queue.QPOV2'), (enabled) => syncJobHistoryTab(enabled) ) + // Capture warnings regardless of DevMode so a user toggling it on later + // sees the full session history. The sidebar tab itself stays gated. + installLiteGraphDeprecationBridge() + void backfillServerDeprecations() + + const syncDeprecationWarningsTab = (enabled: boolean) => { + const has = sidebarTabs.value.some( + (tab) => tab.id === DEPRECATION_WARNINGS_TAB_ID + ) + if (enabled && !has) { + registerSidebarTab(useDeprecationWarningsSidebarTab()) + } else if (!enabled && has) { + unregisterSidebarTab(DEPRECATION_WARNINGS_TAB_ID) + } + } + syncDeprecationWarningsTab(settingStore.get('Comfy.DevMode')) + watch( + () => settingStore.get('Comfy.DevMode'), + (enabled) => syncDeprecationWarningsTab(enabled) + ) + registerSidebarTab(useAssetsSidebarTab()) registerSidebarTab(useNodeLibrarySidebarTab()) registerSidebarTab(useModelLibrarySidebarTab()) diff --git a/src/types/extensionTypes.ts b/src/types/extensionTypes.ts index 67fff5333d..1514d1da68 100644 --- a/src/types/extensionTypes.ts +++ b/src/types/extensionTypes.ts @@ -12,6 +12,8 @@ interface BaseSidebarTabExtension { iconBadge?: string | (() => string | null) tooltip?: string label?: string + /** Render in the top or bottom-anchored group. Defaults to `'top'`. */ + placement?: 'top' | 'bottom' } interface BaseBottomPanelExtension { diff --git a/src/utils/numberUtil.test.ts b/src/utils/numberUtil.test.ts index c115ac5e83..e076340076 100644 --- a/src/utils/numberUtil.test.ts +++ b/src/utils/numberUtil.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest' -import { clampPercentInt, formatPercent0, formatUsdCents } from './numberUtil' +import { + clampPercentInt, + formatBadgeCount, + formatPercent0, + formatUsdCents +} from './numberUtil' describe('clampPercentInt', () => { it('clamps undefined to 0', () => { @@ -69,3 +74,22 @@ describe('formatUsdCents', () => { expect(formatUsdCents('en-US', 200000)).toBe('$2,000') }) }) + +describe('formatBadgeCount', () => { + it('returns the count as a string when at or below the default cap', () => { + expect(formatBadgeCount(0)).toBe('0') + expect(formatBadgeCount(1)).toBe('1') + expect(formatBadgeCount(99)).toBe('99') + }) + + it('returns "99+" when the count exceeds the default cap', () => { + expect(formatBadgeCount(100)).toBe('99+') + expect(formatBadgeCount(1000)).toBe('99+') + }) + + it('respects a custom max', () => { + expect(formatBadgeCount(9, 9)).toBe('9') + expect(formatBadgeCount(10, 9)).toBe('9+') + expect(formatBadgeCount(42, 9)).toBe('9+') + }) +}) diff --git a/src/utils/numberUtil.ts b/src/utils/numberUtil.ts index 10d9cd7963..0899ae5964 100644 --- a/src/utils/numberUtil.ts +++ b/src/utils/numberUtil.ts @@ -44,3 +44,7 @@ export const formatUsdCents = (locale: string, cents: number): string => { maximumFractionDigits: hasFractionalCents ? 2 : 0 }).format(cents / 100) } + +export function formatBadgeCount(count: number, max = 99): string { + return count > max ? `${max}+` : count.toString() +} diff --git a/src/utils/relativeTime.test.ts b/src/utils/relativeTime.test.ts new file mode 100644 index 0000000000..8b131fdbcd --- /dev/null +++ b/src/utils/relativeTime.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest' + +import { formatRelativeTime } from './relativeTime' + +const MINUTE = 60_000 +const HOUR = 60 * MINUTE +const DAY = 24 * HOUR +const WEEK = 7 * DAY +const MONTH = 30 * DAY +const YEAR = 365 * DAY + +function t(key: string, named?: { count: number }): string { + return named ? `${key}:${named.count}` : key +} + +describe('formatRelativeTime', () => { + it('returns "now" when below one minute', () => { + expect(formatRelativeTime(t, 0)).toBe('g.relativeTime.now') + expect(formatRelativeTime(t, MINUTE - 1)).toBe('g.relativeTime.now') + }) + + it('picks the largest unit that fits', () => { + expect(formatRelativeTime(t, MINUTE)).toBe('g.relativeTime.minutesAgo:1') + expect(formatRelativeTime(t, 59 * MINUTE)).toBe( + 'g.relativeTime.minutesAgo:59' + ) + expect(formatRelativeTime(t, HOUR)).toBe('g.relativeTime.hoursAgo:1') + expect(formatRelativeTime(t, 23 * HOUR)).toBe('g.relativeTime.hoursAgo:23') + expect(formatRelativeTime(t, DAY)).toBe('g.relativeTime.daysAgo:1') + expect(formatRelativeTime(t, WEEK)).toBe('g.relativeTime.weeksAgo:1') + expect(formatRelativeTime(t, MONTH)).toBe('g.relativeTime.monthsAgo:1') + expect(formatRelativeTime(t, YEAR)).toBe('g.relativeTime.yearsAgo:1') + }) + + it('passes the integer count to the translator', () => { + const spy = vi.fn(t) + formatRelativeTime(spy, 5 * MINUTE + 30 * 1000) + expect(spy).toHaveBeenCalledExactlyOnceWith('g.relativeTime.minutesAgo', { + count: 5 + }) + }) +}) diff --git a/src/utils/relativeTime.ts b/src/utils/relativeTime.ts new file mode 100644 index 0000000000..80b66dafae --- /dev/null +++ b/src/utils/relativeTime.ts @@ -0,0 +1,25 @@ +const MINUTE_MS = 60_000 +const HOUR_MS = 60 * MINUTE_MS +const DAY_MS = 24 * HOUR_MS +const WEEK_MS = 7 * DAY_MS +const MONTH_MS = 30 * DAY_MS +const YEAR_MS = 365 * DAY_MS + +const UNITS: readonly [number, string][] = [ + [YEAR_MS, 'yearsAgo'], + [MONTH_MS, 'monthsAgo'], + [WEEK_MS, 'weeksAgo'], + [DAY_MS, 'daysAgo'], + [HOUR_MS, 'hoursAgo'], + [MINUTE_MS, 'minutesAgo'] +] + +type Translator = (key: string, named?: { count: number }) => string + +export function formatRelativeTime(t: Translator, diffMs: number): string { + for (const [ms, suffix] of UNITS) { + const value = Math.floor(diffMs / ms) + if (value > 0) return t(`g.relativeTime.${suffix}`, { count: value }) + } + return t('g.relativeTime.now') +}