mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-15 03:37:48 +00:00
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
This commit is contained in:
@@ -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<void> {
|
||||
await this.page.evaluate((m: string) => {
|
||||
window.LiteGraph!.onDeprecationWarning.forEach((cb) => cb(m))
|
||||
}, message)
|
||||
}
|
||||
}
|
||||
|
||||
96
browser_tests/tests/sidebar/deprecationWarnings.spec.ts
Normal file
96
browser_tests/tests/sidebar/deprecationWarnings.spec.ts
Normal file
@@ -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+')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<div ref="topToolbarRef" :class="groupClasses">
|
||||
<ComfyMenuButton />
|
||||
<SidebarIcon
|
||||
v-for="tab in tabs"
|
||||
v-for="tab in topTabs"
|
||||
:key="tab.id"
|
||||
:icon="tab.icon"
|
||||
:icon-badge="tab.iconBadge"
|
||||
@@ -37,6 +37,19 @@
|
||||
</div>
|
||||
|
||||
<div ref="bottomToolbarRef" class="mt-auto" :class="groupClasses">
|
||||
<SidebarIcon
|
||||
v-for="tab in bottomTabs"
|
||||
:key="tab.id"
|
||||
:icon="tab.icon"
|
||||
:icon-badge="tab.iconBadge"
|
||||
:tooltip="tab.tooltip"
|
||||
:tooltip-suffix="getTabTooltipSuffix(tab)"
|
||||
:label="tab.label || tab.title"
|
||||
:is-small="isSmall"
|
||||
:selected="tab.id === selectedTab?.id"
|
||||
:class="tab.id + '-tab-button'"
|
||||
@click="onTabClick(tab)"
|
||||
/>
|
||||
<SidebarLogoutIcon
|
||||
v-if="userStore.isMultiUserServer"
|
||||
:is-small="isSmall"
|
||||
@@ -121,6 +134,12 @@ const isConnected = computed(
|
||||
)
|
||||
|
||||
const tabs = computed(() => 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)
|
||||
|
||||
/**
|
||||
|
||||
116
src/components/sidebar/tabs/DeprecationWarningsTab.test.ts
Normal file
116
src/components/sidebar/tabs/DeprecationWarningsTab.test.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
99
src/components/sidebar/tabs/DeprecationWarningsTab.vue
Normal file
99
src/components/sidebar/tabs/DeprecationWarningsTab.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<SidebarTabTemplate
|
||||
data-testid="deprecation-warnings-sidebar"
|
||||
:title="t('deprecationWarnings.title')"
|
||||
>
|
||||
<template #tool-buttons>
|
||||
<Button
|
||||
v-if="store.warnings.length > 0"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
@click="store.clear()"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
<span>{{ t('deprecationWarnings.clearAll') }}</span>
|
||||
</Button>
|
||||
</template>
|
||||
<template #body>
|
||||
<NoResultsPlaceholder
|
||||
v-if="store.warnings.length === 0"
|
||||
icon="pi pi-check-circle"
|
||||
:title="t('deprecationWarnings.emptyTitle')"
|
||||
:message="t('deprecationWarnings.empty')"
|
||||
/>
|
||||
<ul
|
||||
v-else
|
||||
class="flex flex-col gap-2 px-3 py-2"
|
||||
data-testid="deprecation-warnings-list"
|
||||
>
|
||||
<li
|
||||
v-for="warning in store.warnings"
|
||||
:key="warning.key"
|
||||
class="flex flex-col gap-0.5 rounded-sm border border-interface-stroke p-4"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div
|
||||
class="min-w-0 flex-1 text-base wrap-break-word text-text-primary"
|
||||
>
|
||||
{{ warning.message }}
|
||||
</div>
|
||||
<span
|
||||
v-if="warning.count > 1"
|
||||
v-bind="occurrenceAttrs(warning.count)"
|
||||
data-testid="deprecation-warning-badge"
|
||||
class="inline-flex min-w-5 shrink-0 items-center justify-center rounded-full bg-primary-background px-1.5 py-0.5 text-xs font-medium text-base-foreground"
|
||||
>
|
||||
{{ formatBadgeCount(warning.count, 9) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="warning.suggestion"
|
||||
class="my-1 flex flex-wrap gap-x-1 rounded-sm bg-node-component-surface px-2 py-1.5 text-sm wrap-break-word"
|
||||
>
|
||||
<span class="font-medium text-text-primary">
|
||||
{{ t('deprecationWarnings.suggestionLabel') }}
|
||||
</span>
|
||||
<span class="text-text-secondary">{{ warning.suggestion }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-text-secondary">
|
||||
<span v-if="warning.source">{{ warning.source }}</span>
|
||||
<span v-if="warning.source" aria-hidden="true">·</span>
|
||||
<span :title="new Date(warning.lastSeenAt).toLocaleString(locale)">
|
||||
{{ formatRelativeTime(t, Math.max(0, now - warning.lastSeenAt)) }}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</SidebarTabTemplate>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useIntervalFn } from '@vueuse/core'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore'
|
||||
import { formatBadgeCount } from '@/utils/numberUtil'
|
||||
import { formatRelativeTime } from '@/utils/relativeTime'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const store = useDeprecationWarningsStore()
|
||||
|
||||
const now = ref(Date.now())
|
||||
useIntervalFn(() => {
|
||||
now.value = Date.now()
|
||||
}, 30_000)
|
||||
|
||||
function occurrenceAttrs(count: number) {
|
||||
const text = t('deprecationWarnings.occurrenceCount', count)
|
||||
return { 'aria-label': text, title: text }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
store.markAllSeen()
|
||||
})
|
||||
</script>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
74
src/extensions/core/widgetInputs.deprecation.test.ts
Normal file
74
src/extensions/core/widgetInputs.deprecation.test.ts
Normal file
@@ -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<typeof vi.spyOn>
|
||||
|
||||
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'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,19 +5,23 @@ const UNIQUE_MESSAGE_LIMIT = 10_000
|
||||
const sentWarnings: Set<string> = 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<T>(
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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.'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
155
src/platform/dev/backfillServerDeprecations.test.ts
Normal file
155
src/platform/dev/backfillServerDeprecations.test.ts
Normal file
@@ -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<typeof vi.fn> }
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
42
src/platform/dev/backfillServerDeprecations.ts
Normal file
42
src/platform/dev/backfillServerDeprecations.ts
Normal file
@@ -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<void> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
91
src/platform/dev/deprecationWarningsStore.test.ts
Normal file
91
src/platform/dev/deprecationWarningsStore.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
84
src/platform/dev/deprecationWarningsStore.ts
Normal file
84
src/platform/dev/deprecationWarningsStore.ts
Normal file
@@ -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<string, DeprecationWarning>())
|
||||
const unseenKeys = reactive(new Set<string>())
|
||||
|
||||
const warnings = computed<DeprecationWarning[]>(() =>
|
||||
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
|
||||
}
|
||||
}
|
||||
)
|
||||
70
src/platform/dev/installLiteGraphDeprecationBridge.test.ts
Normal file
70
src/platform/dev/installLiteGraphDeprecationBridge.test.ts
Normal file
@@ -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'
|
||||
])
|
||||
})
|
||||
})
|
||||
16
src/platform/dev/installLiteGraphDeprecationBridge.ts
Normal file
16
src/platform/dev/installLiteGraphDeprecationBridge.ts
Normal file
@@ -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'
|
||||
})
|
||||
})
|
||||
}
|
||||
154
src/platform/dev/warnDeprecated.test.ts
Normal file
154
src/platform/dev/warnDeprecated.test.ts
Normal file
@@ -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<typeof vi.spyOn>
|
||||
|
||||
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<typeof vi.spyOn>
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
57
src/platform/dev/warnDeprecated.ts
Normal file
57
src/platform/dev/warnDeprecated.ts
Normal file
@@ -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<ReportDeprecationInput, 'message'>
|
||||
|
||||
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<T>(
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<HTMLDialogElement> {
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof vi.spyOn>
|
||||
|
||||
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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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+')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
42
src/utils/relativeTime.test.ts
Normal file
42
src/utils/relativeTime.test.ts
Normal file
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
25
src/utils/relativeTime.ts
Normal file
25
src/utils/relativeTime.ts
Normal file
@@ -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')
|
||||
}
|
||||
Reference in New Issue
Block a user