mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 01:07:56 +00:00
Compare commits
15 Commits
feat/parti
...
pysssss/de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c20157537 | ||
|
|
83ea8d4e8a | ||
|
|
e5f242ed96 | ||
|
|
20156460a6 | ||
|
|
0c874dcd2f | ||
|
|
0628aed16c | ||
|
|
e6e0c0bdd4 | ||
|
|
09e03be1d4 | ||
|
|
c1dae8bad5 | ||
|
|
e149ca0dd2 | ||
|
|
ce103c8e46 | ||
|
|
d08c0e02da | ||
|
|
bf78f8718d | ||
|
|
34f08ac479 | ||
|
|
315fd0bc39 |
@@ -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,31 @@ 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.waitForFunction(() => !!window.__reportDeprecation)
|
||||
await this.page.evaluate((m: string) => {
|
||||
window.__reportDeprecation!(m)
|
||||
}, message)
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
108
browser_tests/tests/sidebar/deprecationWarnings.spec.ts
Normal file
108
browser_tests/tests/sidebar/deprecationWarnings.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
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)
|
||||
expect(
|
||||
await comfyPage.page.evaluate(() => !!window.__reportDeprecation)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
// Warnings arriving while the panel is open must not resurface the badge.
|
||||
const liveMessage = `e2e-badge-live-${Date.now()} is deprecated.`
|
||||
await tab.emitDeprecation(liveMessage)
|
||||
await expect(tab.rowFor(liveMessage)).toBeVisible()
|
||||
|
||||
await tab.close()
|
||||
await expect(tab.panel).toBeHidden()
|
||||
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+')
|
||||
})
|
||||
})
|
||||
})
|
||||
3
browser_tests/types/globals.d.ts
vendored
3
browser_tests/types/globals.d.ts
vendored
@@ -42,6 +42,9 @@ declare global {
|
||||
__capturedMessages?: CapturedMessages
|
||||
__appReadiness?: AppReadiness
|
||||
|
||||
/** For use in tests to inject a synthetic deprecation warning. */
|
||||
__reportDeprecation?: (message: string) => void
|
||||
|
||||
/**
|
||||
* WebSocket store used by test fixtures for mocking WebSocket connections.
|
||||
* @see browser_tests/fixtures/ws.ts
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
|
||||
"format:check": "oxfmt --check",
|
||||
"format": "oxfmt --write",
|
||||
"gen:deprecations": "tsx scripts/generate-deprecations-doc.ts",
|
||||
"json-schema": "tsx scripts/generate-json-schema.ts",
|
||||
"knip:no-cache": "knip",
|
||||
"knip": "knip --cache",
|
||||
|
||||
81
scripts/generate-deprecations-doc.test.ts
Normal file
81
scripts/generate-deprecations-doc.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { DeprecationEntry } from '../src/platform/dev/deprecations'
|
||||
import { renderDeprecationsMdx } from './generate-deprecations-doc'
|
||||
|
||||
const SAMPLE: Record<string, DeprecationEntry> = {
|
||||
'b.thing': {
|
||||
source: 'beta',
|
||||
message: 'thing is deprecated.',
|
||||
suggestion: 'Use otherThing.',
|
||||
since: '1.0',
|
||||
removeBy: '2.0',
|
||||
docsUrl: 'https://docs.example/thing'
|
||||
},
|
||||
'a.first': {
|
||||
source: 'alpha',
|
||||
message: 'first is gone.'
|
||||
},
|
||||
'a.second': {
|
||||
source: 'alpha',
|
||||
message: 'second is gone | really.'
|
||||
}
|
||||
}
|
||||
|
||||
describe('renderDeprecationsMdx', () => {
|
||||
const mdx = renderDeprecationsMdx(SAMPLE)
|
||||
|
||||
it('emits Mintlify frontmatter', () => {
|
||||
expect(mdx.startsWith('---\ntitle: "Deprecations"')).toBe(true)
|
||||
})
|
||||
|
||||
it('includes an intro explaining the deprecation policy before the tables', () => {
|
||||
expect(mdx).toContain('Whilst we try to keep the frontend API stable')
|
||||
expect(mdx.indexOf('Whilst we try')).toBeLessThan(mdx.indexOf('## alpha'))
|
||||
})
|
||||
|
||||
it('documents the dev-mode-gated Deprecation Warnings panel', () => {
|
||||
expect(mdx).toContain('## Deprecation Warnings panel')
|
||||
expect(mdx).toContain('dev mode')
|
||||
})
|
||||
|
||||
it('groups entries by source, sorted alphabetically', () => {
|
||||
expect(mdx.indexOf('## alpha')).toBeLessThan(mdx.indexOf('## beta'))
|
||||
})
|
||||
|
||||
it('renders a row per entry with replacement, version and docs columns', () => {
|
||||
expect(mdx).toContain(
|
||||
'| thing is deprecated. | Use otherThing. | 1.0 | 2.0 | [Guide](https://docs.example/thing) |'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses a dash for missing optional fields', () => {
|
||||
expect(mdx).toContain('| first is gone. | - | - | - | - |')
|
||||
})
|
||||
|
||||
it('escapes pipe characters so the table is not broken', () => {
|
||||
expect(mdx).toContain('second is gone \\| really.')
|
||||
})
|
||||
|
||||
it('omits optional columns when no entry anywhere has a value', () => {
|
||||
const minimal = renderDeprecationsMdx({
|
||||
'a.first': { source: 'alpha', message: 'first is gone.' },
|
||||
'a.second': { source: 'alpha', message: 'second is gone.' }
|
||||
})
|
||||
|
||||
expect(minimal).toContain('| Deprecated | Replacement |')
|
||||
expect(minimal).not.toContain('Since')
|
||||
expect(minimal).not.toContain('Removed in')
|
||||
expect(minimal).not.toContain('Docs')
|
||||
})
|
||||
|
||||
it('keeps a column when at least one entry in any table has a value', () => {
|
||||
const mixed = renderDeprecationsMdx({
|
||||
'a.first': { source: 'alpha', message: 'first is gone.' },
|
||||
'b.second': { source: 'beta', message: 'second is gone.', since: '1.0' }
|
||||
})
|
||||
|
||||
expect(mixed).toContain('| Deprecated | Replacement | Since |')
|
||||
expect(mixed).not.toContain('Removed in')
|
||||
})
|
||||
})
|
||||
119
scripts/generate-deprecations-doc.ts
Normal file
119
scripts/generate-deprecations-doc.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
import type { DeprecationEntry } from '../src/platform/dev/deprecations'
|
||||
import { DEPRECATIONS } from '../src/platform/dev/deprecations'
|
||||
|
||||
const OUTPUT_PATH = './docs/deprecations.mdx'
|
||||
|
||||
const FRONTMATTER = `---
|
||||
title: "Deprecations"
|
||||
description: "Deprecated frontend APIs and their replacements."
|
||||
---`
|
||||
|
||||
const INTRO = `Whilst we try to keep the frontend API stable and backwards compatible, there are times where functionality must change - to fix bugs, improve performance, or make room for new features.
|
||||
|
||||
When that happens we deprecate the old API rather than removing it outright: it keeps working until the removal date, but logs a warning pointing you at the replacement. This page lists the currently deprecated APIs so you can migrate your custom nodes and extensions ahead of their removal.
|
||||
|
||||
## Deprecation Warnings panel
|
||||
|
||||
To see which deprecated APIs are actually being used in your current session, enable the **"Enable dev mode options"** setting (Settings → search "dev mode"). A **Deprecation Warnings** panel then appears in the main sidebar, listing each warning reported this session - with the suggested replacement and how many times it occurred.
|
||||
|
||||
Alternatively, every deprecation is logged to the browser DevTools console as it occurs, prefixed with a \`[DEPRECATED]\` tag.`
|
||||
|
||||
interface Column {
|
||||
header: string
|
||||
cell: (entry: DeprecationEntry) => string | undefined
|
||||
}
|
||||
|
||||
const REQUIRED_COLUMNS: Column[] = [
|
||||
{ header: 'Deprecated', cell: (entry) => entry.message },
|
||||
{ header: 'Replacement', cell: (entry) => entry.suggestion }
|
||||
]
|
||||
|
||||
const OPTIONAL_COLUMNS: Column[] = [
|
||||
{ header: 'Since', cell: (entry) => entry.since },
|
||||
{ header: 'Removed in', cell: (entry) => entry.removeBy },
|
||||
{
|
||||
header: 'Docs',
|
||||
cell: (entry) => (entry.docsUrl ? `[Guide](${entry.docsUrl})` : undefined)
|
||||
}
|
||||
]
|
||||
|
||||
function escapeCell(value: string | undefined): string {
|
||||
if (!value) return '-'
|
||||
return value.replace(/\|/g, '\\|').replace(/\n/g, ' ')
|
||||
}
|
||||
|
||||
function activeColumns(entries: DeprecationEntry[]): Column[] {
|
||||
return [
|
||||
...REQUIRED_COLUMNS,
|
||||
...OPTIONAL_COLUMNS.filter((column) =>
|
||||
entries.some((entry) => column.cell(entry))
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
function groupBySource(
|
||||
deprecations: Record<string, DeprecationEntry>
|
||||
): Map<string, DeprecationEntry[]> {
|
||||
const groups = new Map<string, DeprecationEntry[]>()
|
||||
for (const entry of Object.values(deprecations)) {
|
||||
const group = groups.get(entry.source) ?? []
|
||||
group.push(entry)
|
||||
groups.set(entry.source, group)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
function renderRow(cells: string[]): string {
|
||||
return `| ${cells.join(' | ')} |`
|
||||
}
|
||||
|
||||
function renderSection(
|
||||
source: string,
|
||||
entries: DeprecationEntry[],
|
||||
columns: Column[]
|
||||
): string {
|
||||
const rows = entries.map((entry) =>
|
||||
renderRow(columns.map((column) => escapeCell(column.cell(entry))))
|
||||
)
|
||||
return [
|
||||
`## ${source}`,
|
||||
'',
|
||||
renderRow(columns.map((column) => column.header)),
|
||||
renderRow(columns.map(() => '---')),
|
||||
...rows
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function renderDeprecationsMdx(
|
||||
deprecations: Record<string, DeprecationEntry>
|
||||
): string {
|
||||
const columns = activeColumns(Object.values(deprecations))
|
||||
const sections = [...groupBySource(deprecations).entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([source, entries]) => renderSection(source, entries, columns))
|
||||
|
||||
return [
|
||||
FRONTMATTER,
|
||||
'',
|
||||
'{/* Generated by scripts/generate-deprecations-doc.ts. Do not edit by hand. */}',
|
||||
'',
|
||||
INTRO,
|
||||
'',
|
||||
sections.join('\n\n'),
|
||||
''
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const mdx = renderDeprecationsMdx(DEPRECATIONS)
|
||||
fs.writeFileSync(path.resolve(OUTPUT_PATH), mdx)
|
||||
}
|
||||
|
||||
const invokedDirectly =
|
||||
process.argv[1] &&
|
||||
fileURLToPath(import.meta.url) === path.resolve(process.argv[1])
|
||||
if (invokedDirectly) main()
|
||||
@@ -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,
|
||||
@@ -491,29 +482,9 @@ 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')
|
||||
if (!dateString) return t('helpCenter.missingDate')
|
||||
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)
|
||||
|
||||
/**
|
||||
|
||||
333
src/components/sidebar/tabs/DeprecationWarningsTab.test.ts
Normal file
333
src/components/sidebar/tabs/DeprecationWarningsTab.test.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { cleanup, render, screen, waitFor } 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('surfaces the extension and detail as distinct fields from the message', () => {
|
||||
const store = useDeprecationWarningsStore()
|
||||
store.report({
|
||||
message: 'Use of defaultInput on a required input.',
|
||||
source: 'nodeDef',
|
||||
extension: 'custom_nodes.devtools',
|
||||
detail: 'DevToolsNode.int_input'
|
||||
})
|
||||
|
||||
renderTab()
|
||||
|
||||
screen.getByText('Use of defaultInput on a required input.')
|
||||
screen.getByText('nodeDef')
|
||||
screen.getByText('custom_nodes.devtools')
|
||||
screen.getByText('DevToolsNode.int_input')
|
||||
})
|
||||
|
||||
it('shows the extension filter only when more than one extension is present', () => {
|
||||
const store = useDeprecationWarningsStore()
|
||||
store.report({ message: 'only ext.', source: 's', extension: 'ext.alpha' })
|
||||
|
||||
const single = renderTab()
|
||||
expect(
|
||||
screen.queryByRole('button', {
|
||||
name: 'deprecationWarnings.filterByExtension'
|
||||
})
|
||||
).toBeNull()
|
||||
single.unmount()
|
||||
|
||||
store.report({
|
||||
message: 'second ext.',
|
||||
source: 's',
|
||||
extension: 'ext.bravo'
|
||||
})
|
||||
renderTab()
|
||||
screen.getByRole('button', {
|
||||
name: 'deprecationWarnings.filterByExtension'
|
||||
})
|
||||
})
|
||||
|
||||
it('filters the list to the selected extension', async () => {
|
||||
const store = useDeprecationWarningsStore()
|
||||
store.report({
|
||||
message: 'from alpha',
|
||||
source: 'nodeDef',
|
||||
extension: 'ext.alpha'
|
||||
})
|
||||
store.report({
|
||||
message: 'from bravo',
|
||||
source: 'nodeDef',
|
||||
extension: 'ext.bravo'
|
||||
})
|
||||
|
||||
const { user } = renderTab()
|
||||
screen.getByText('from alpha')
|
||||
screen.getByText('from bravo')
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'deprecationWarnings.filterByExtension'
|
||||
})
|
||||
)
|
||||
await user.click(await screen.findByRole('option', { name: 'ext.alpha' }))
|
||||
|
||||
screen.getByText('from alpha')
|
||||
expect(screen.queryByText('from bravo')).toBeNull()
|
||||
})
|
||||
|
||||
it('filters to warnings without an extension via the Unknown option', async () => {
|
||||
const store = useDeprecationWarningsStore()
|
||||
store.report({
|
||||
message: 'has ext',
|
||||
source: 'nodeDef',
|
||||
extension: 'ext.alpha'
|
||||
})
|
||||
store.report({ message: 'no ext', source: 'litegraph' })
|
||||
|
||||
const { user } = renderTab()
|
||||
screen.getByText('has ext')
|
||||
screen.getByText('no ext')
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'deprecationWarnings.filterByExtension'
|
||||
})
|
||||
)
|
||||
await user.click(
|
||||
await screen.findByRole('option', {
|
||||
name: 'deprecationWarnings.unknownExtension'
|
||||
})
|
||||
)
|
||||
|
||||
screen.getByText('no ext')
|
||||
expect(screen.queryByText('has ext')).toBeNull()
|
||||
})
|
||||
|
||||
it('clears the extension filter when warnings are cleared', async () => {
|
||||
const store = useDeprecationWarningsStore()
|
||||
store.report({
|
||||
message: 'from alpha',
|
||||
source: 'nodeDef',
|
||||
extension: 'ext.alpha'
|
||||
})
|
||||
store.report({
|
||||
message: 'from bravo',
|
||||
source: 'nodeDef',
|
||||
extension: 'ext.bravo'
|
||||
})
|
||||
|
||||
const { user } = renderTab()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'deprecationWarnings.filterByExtension'
|
||||
})
|
||||
)
|
||||
await user.click(await screen.findByRole('option', { name: 'ext.bravo' }))
|
||||
expect(screen.queryByText('from alpha')).toBeNull()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'deprecationWarnings.clearAll' })
|
||||
)
|
||||
store.report({
|
||||
message: 'fresh alpha',
|
||||
source: 'nodeDef',
|
||||
extension: 'ext.alpha'
|
||||
})
|
||||
|
||||
await screen.findByText('fresh alpha')
|
||||
expect(screen.queryByText('deprecationWarnings.noMatches')).toBeNull()
|
||||
})
|
||||
|
||||
it('drops a stale extension filter when its warnings are removed', async () => {
|
||||
const store = useDeprecationWarningsStore()
|
||||
store.report({
|
||||
message: 'from alpha',
|
||||
source: 'nodeDef',
|
||||
extension: 'ext.alpha'
|
||||
})
|
||||
store.report({
|
||||
message: 'from bravo',
|
||||
source: 'nodeDef',
|
||||
extension: 'ext.bravo'
|
||||
})
|
||||
|
||||
const { user } = renderTab()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'deprecationWarnings.filterByExtension'
|
||||
})
|
||||
)
|
||||
await user.click(await screen.findByRole('option', { name: 'ext.bravo' }))
|
||||
expect(screen.queryByText('from alpha')).toBeNull()
|
||||
|
||||
for (const warning of store.warnings.filter(
|
||||
(w) => w.extension === 'ext.bravo'
|
||||
)) {
|
||||
store.remove(warning.key)
|
||||
}
|
||||
|
||||
await screen.findByText('from alpha')
|
||||
expect(screen.queryByText('deprecationWarnings.noMatches')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders a migration-guide link only when the warning has a docsUrl', () => {
|
||||
const store = useDeprecationWarningsStore()
|
||||
store.report({ message: 'no link here.' })
|
||||
store.report({
|
||||
message: 'has a guide.',
|
||||
docsUrl: 'https://docs.example/guide'
|
||||
})
|
||||
|
||||
renderTab()
|
||||
|
||||
const links = screen.getAllByRole('link', {
|
||||
name: 'deprecationWarnings.learnMore'
|
||||
})
|
||||
expect(links).toHaveLength(1)
|
||||
expect(links[0]).toHaveAttribute('href', 'https://docs.example/guide')
|
||||
expect(links[0]).toHaveAttribute('target', '_blank')
|
||||
expect(links[0]).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
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('marks warnings seen as they arrive while the panel is open', async () => {
|
||||
const store = useDeprecationWarningsStore()
|
||||
store.report({ message: 'before mount' })
|
||||
renderTab()
|
||||
|
||||
store.report({ message: 'arrived while open' })
|
||||
await screen.findByText('arrived while open')
|
||||
|
||||
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('removes a single warning via its per-warning clear action', async () => {
|
||||
const store = useDeprecationWarningsStore()
|
||||
store.report({ message: 'keep me' })
|
||||
store.report({ message: 'remove me' })
|
||||
|
||||
renderTab()
|
||||
|
||||
const menus = screen.getAllByRole('button', {
|
||||
name: 'deprecationWarnings.moreOptions'
|
||||
})
|
||||
// reka's Popover trigger doesn't toggle through userEvent's synthesized
|
||||
// events under happy-dom, so drive the menu with native clicks.
|
||||
// Warnings render newest-first, so the first menu targets 'remove me'.
|
||||
menus[0].click()
|
||||
;(await screen.findByText('deprecationWarnings.clear')).click()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('remove me')).toBeNull()
|
||||
})
|
||||
screen.getByText('keep me')
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
244
src/components/sidebar/tabs/DeprecationWarningsTab.vue
Normal file
244
src/components/sidebar/tabs/DeprecationWarningsTab.vue
Normal file
@@ -0,0 +1,244 @@
|
||||
<template>
|
||||
<SidebarTabTemplate
|
||||
data-testid="deprecation-warnings-sidebar"
|
||||
:title="t('deprecationWarnings.title')"
|
||||
>
|
||||
<template #tool-buttons>
|
||||
<Button
|
||||
v-if="store.warnings.length > 0"
|
||||
variant="muted-textonly"
|
||||
size="md"
|
||||
class="text-sm"
|
||||
@click="clearWarnings"
|
||||
>
|
||||
<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')"
|
||||
/>
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="extensionOptions.length > 1"
|
||||
class="flex justify-end px-3 pt-2"
|
||||
>
|
||||
<MultiSelect
|
||||
v-model="selectedExtensions"
|
||||
:options="extensionOptions"
|
||||
:label="t('deprecationWarnings.filterByExtension')"
|
||||
size="lg"
|
||||
show-search-box
|
||||
show-clear-button
|
||||
class="w-fit"
|
||||
/>
|
||||
</div>
|
||||
<NoResultsPlaceholder
|
||||
v-if="filteredWarnings.length === 0"
|
||||
icon="pi pi-filter-slash"
|
||||
:title="t('deprecationWarnings.noMatchesTitle')"
|
||||
:message="t('deprecationWarnings.noMatches')"
|
||||
/>
|
||||
<ul
|
||||
v-else
|
||||
class="mt-2 flex flex-col gap-2 px-3 pb-2"
|
||||
data-testid="deprecation-warnings-list"
|
||||
>
|
||||
<li
|
||||
v-for="warning in filteredWarnings"
|
||||
:key="warning.key"
|
||||
class="relative flex flex-col gap-2 rounded-md border border-interface-stroke p-4"
|
||||
>
|
||||
<div class="absolute top-2 right-2">
|
||||
<Popover :show-arrow="false" :entries="warningMenuItems(warning)">
|
||||
<template #button>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="md"
|
||||
:aria-label="t('deprecationWarnings.moreOptions')"
|
||||
>
|
||||
<i class="icon-[lucide--more-horizontal] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
<div class="flex items-start justify-between gap-2 pr-8">
|
||||
<div
|
||||
class="min-w-0 flex-1 text-sm wrap-break-word text-text-primary"
|
||||
>
|
||||
{{ warning.message }}
|
||||
</div>
|
||||
<div
|
||||
class="flex shrink-0 items-center gap-2 text-sm text-text-secondary"
|
||||
>
|
||||
<span
|
||||
:title="new Date(warning.lastSeenAt).toLocaleString(locale)"
|
||||
>
|
||||
{{
|
||||
formatRelativeTime(t, Math.max(0, now - warning.lastSeenAt))
|
||||
}}
|
||||
</span>
|
||||
<Badge
|
||||
v-if="warning.count > 1"
|
||||
v-bind="occurrenceAttrs(warning.count)"
|
||||
data-testid="deprecation-warning-badge"
|
||||
severity="contrast"
|
||||
variant="circle"
|
||||
:label="formatBadgeCount(warning.count, 9)"
|
||||
class="size-auto min-w-5 px-1.5 py-0.5 text-xs font-medium"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="metaParts(warning).length"
|
||||
class="flex flex-wrap items-center gap-1.5 font-mono text-xs wrap-break-word text-text-secondary"
|
||||
>
|
||||
<template v-for="(part, i) in metaParts(warning)" :key="i">
|
||||
<span v-if="i > 0" aria-hidden="true" class="opacity-50"
|
||||
>|</span
|
||||
>
|
||||
<span>{{ part }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-if="warning.suggestion || warning.docsUrl"
|
||||
class="my-1 flex flex-col gap-2 rounded-md bg-node-component-surface p-3 text-sm"
|
||||
>
|
||||
<div v-if="warning.suggestion" class="wrap-break-word">
|
||||
<span class="font-medium text-text-primary">{{
|
||||
t('deprecationWarnings.suggestionLabel')
|
||||
}}</span>
|
||||
<span class="ml-1 text-text-secondary">{{
|
||||
warning.suggestion
|
||||
}}</span>
|
||||
</div>
|
||||
<Button
|
||||
v-if="warning.docsUrl"
|
||||
as="a"
|
||||
variant="link"
|
||||
size="sm"
|
||||
:href="warning.docsUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="w-fit gap-2 px-0 text-sm underline"
|
||||
>
|
||||
<i class="icon-[lucide--book-open] size-4" />
|
||||
<span>{{ t('deprecationWarnings.learnMore') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</template>
|
||||
</SidebarTabTemplate>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useIntervalFn } from '@vueuse/core'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Badge from '@/components/common/Badge.vue'
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import MultiSelect from '@/components/ui/multi-select/MultiSelect.vue'
|
||||
import type { SelectOption } from '@/components/ui/select/types'
|
||||
import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore'
|
||||
import { formatBadgeCount } from '@/utils/numberUtil'
|
||||
import { formatRelativeTime } from '@/utils/relativeTime'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const store = useDeprecationWarningsStore()
|
||||
|
||||
const UNKNOWN_EXTENSION = '__unknown__'
|
||||
|
||||
const selectedExtensions = ref<SelectOption[]>([])
|
||||
|
||||
const extensionOptions = computed<SelectOption[]>(() => {
|
||||
const names = new Set<string>()
|
||||
let hasUnknown = false
|
||||
for (const warning of store.warnings) {
|
||||
if (warning.extension) names.add(warning.extension)
|
||||
else hasUnknown = true
|
||||
}
|
||||
const options = [...names].sort().map((name) => ({ name, value: name }))
|
||||
if (hasUnknown) {
|
||||
options.push({
|
||||
name: t('deprecationWarnings.unknownExtension'),
|
||||
value: UNKNOWN_EXTENSION
|
||||
})
|
||||
}
|
||||
return options
|
||||
})
|
||||
|
||||
// Prune selections whose option disappeared (e.g. its warnings were removed),
|
||||
// otherwise a stale filter keeps hiding warnings once the control is hidden.
|
||||
watch(extensionOptions, (options) => {
|
||||
const valid = new Set(options.map((option) => option.value))
|
||||
selectedExtensions.value = selectedExtensions.value.filter((option) =>
|
||||
valid.has(option.value)
|
||||
)
|
||||
})
|
||||
|
||||
const filteredWarnings = computed(() => {
|
||||
if (selectedExtensions.value.length === 0) return store.warnings
|
||||
const selected = new Set(selectedExtensions.value.map((o) => o.value))
|
||||
return store.warnings.filter((warning) =>
|
||||
warning.extension != null
|
||||
? selected.has(warning.extension)
|
||||
: selected.has(UNKNOWN_EXTENSION)
|
||||
)
|
||||
})
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
function metaParts(warning: {
|
||||
extension?: string
|
||||
source?: string
|
||||
detail?: string
|
||||
}): string[] {
|
||||
return [warning.extension, warning.source, warning.detail].filter(
|
||||
(part): part is string => !!part
|
||||
)
|
||||
}
|
||||
|
||||
function clearWarnings() {
|
||||
store.clear()
|
||||
selectedExtensions.value = []
|
||||
}
|
||||
|
||||
function warningMenuItems(warning: { key: string }): MenuItem[] {
|
||||
return [
|
||||
{
|
||||
label: t('deprecationWarnings.clear'),
|
||||
icon: 'icon-[lucide--trash-2] size-4',
|
||||
command: () => store.remove(warning.key)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// watch, not watchEffect: markAllSeen mutates the watched source, and a
|
||||
// watchEffect's recursion guard drops the subscription after a mount-time
|
||||
// clear, leaving warnings that arrive while the panel is open unseen forever.
|
||||
watch(
|
||||
() => store.unseenCount,
|
||||
(count) => {
|
||||
if (count > 0) store.markAllSeen()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
@@ -93,6 +93,30 @@ describe('MultiSelect', () => {
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('closes when an outside pointerdown is stopped from propagating (e.g. canvas)', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Mimics the litegraph canvas, which stops pointerdown from bubbling to the
|
||||
// document — reka's bubble-phase outside-dismiss would never fire.
|
||||
const canvasLike = document.body.appendChild(document.createElement('div'))
|
||||
canvasLike.addEventListener('pointerdown', (e) => e.stopPropagation())
|
||||
|
||||
const { unmount } = renderInParent()
|
||||
|
||||
const trigger = screen.getByRole('button')
|
||||
await user.click(trigger)
|
||||
await nextTick()
|
||||
expect(trigger).toHaveAttribute('data-state', 'open')
|
||||
|
||||
canvasLike.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
expect(trigger).toHaveAttribute('data-state', 'closed')
|
||||
|
||||
canvasLike.remove()
|
||||
unmount()
|
||||
})
|
||||
|
||||
describe('Escape key propagation', () => {
|
||||
it('stops Escape from propagating to parent when popover is open', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
|
||||
<ComboboxPortal>
|
||||
<ComboboxContent
|
||||
ref="contentRef"
|
||||
position="popper"
|
||||
:side-offset="8"
|
||||
align="start"
|
||||
@@ -136,6 +137,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useCurrentElement } from '@vueuse/core'
|
||||
import { useFuse } from '@vueuse/integrations/useFuse'
|
||||
import type { UseFuseOptions } from '@vueuse/integrations/useFuse'
|
||||
import type { FocusOutsideEvent } from 'reka-ui'
|
||||
@@ -166,6 +168,7 @@ import {
|
||||
stopEscapeToDocument
|
||||
} from '@/components/ui/select/select.variants'
|
||||
import type { SelectOption } from '@/components/ui/select/types'
|
||||
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
|
||||
import { usePopoverSizing } from '@/composables/usePopoverSizing'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
@@ -221,6 +224,19 @@ const { t } = useI18n()
|
||||
const isOpen = ref(false)
|
||||
const selectedCount = computed(() => selectedItems.value.length)
|
||||
|
||||
const rootEl = useCurrentElement<HTMLElement>()
|
||||
const contentRef = ref<{ $el?: HTMLElement } | null>(null)
|
||||
|
||||
useDismissableOverlay({
|
||||
isOpen,
|
||||
getOverlayEl: () => contentRef.value?.$el ?? null,
|
||||
getTriggerEl: () =>
|
||||
rootEl.value instanceof HTMLElement ? rootEl.value : null,
|
||||
onDismiss: () => {
|
||||
isOpen.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function onContentKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
stopEscapeToDocument(event)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,9 +36,9 @@ describe('Context Menu Extension Name in Warnings', () => {
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
const warningMessage = warnSpy.mock.calls[0][0]
|
||||
|
||||
expect(warningMessage).toContain('[DEPRECATED]')
|
||||
expect(warningMessage).toContain('getCanvasMenuOptions')
|
||||
expect(warningMessage).toContain('"MyCustomExtension"')
|
||||
expect(warningMessage).toContain('[DEPRECATED:litegraph]')
|
||||
expect(warningMessage).toContain('detail: getCanvasMenuOptions')
|
||||
expect(warningMessage).toContain('extension: MyCustomExtension')
|
||||
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
@@ -67,9 +67,9 @@ describe('Context Menu Extension Name in Warnings', () => {
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
const warningMessage = warnSpy.mock.calls[0][0]
|
||||
|
||||
expect(warningMessage).toContain('[DEPRECATED]')
|
||||
expect(warningMessage).toContain('getNodeMenuOptions')
|
||||
expect(warningMessage).toContain('"AnotherExtension"')
|
||||
expect(warningMessage).toContain('[DEPRECATED:litegraph]')
|
||||
expect(warningMessage).toContain('detail: getNodeMenuOptions')
|
||||
expect(warningMessage).toContain('extension: AnotherExtension')
|
||||
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
@@ -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,9 @@ 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('maskEditor.openMaskEditor')
|
||||
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,12 @@ 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('widgetInputs.convertToInput')
|
||||
return node.inputs.find((slot) => slot.widget?.name === widget.name)
|
||||
}
|
||||
|
||||
@@ -517,9 +515,7 @@ 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('widgetInputs.convertWidgetToInput')
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
SubgraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
|
||||
import { DEPRECATIONS } from '@/platform/dev/deprecations'
|
||||
import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore'
|
||||
import type { UUID } from '@/utils/uuid'
|
||||
import { zeroUuid } from '@/utils/uuid'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
@@ -552,13 +554,16 @@ describe('Subgraph Definition Garbage Collection', () => {
|
||||
})
|
||||
|
||||
describe('beforeChange deprecated onBeforeChange shim', () => {
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
LiteGraph.onDeprecationWarning = []
|
||||
LiteGraph.alwaysRepeatWarnings = true
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
useDeprecationWarningsStore().clear()
|
||||
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
LiteGraph.alwaysRepeatWarnings = false
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('still invokes a listener assigned to onBeforeChange', () => {
|
||||
@@ -572,28 +577,25 @@ describe('beforeChange deprecated onBeforeChange shim', () => {
|
||||
expect(onBeforeChange).toHaveBeenCalledWith(graph, node)
|
||||
})
|
||||
|
||||
it('warns that onBeforeChange is deprecated when used', () => {
|
||||
it('reports the onBeforeChange deprecation when used', () => {
|
||||
const graph = new LGraph()
|
||||
const deprecationCallback = vi.fn()
|
||||
LiteGraph.onDeprecationWarning = [deprecationCallback]
|
||||
graph.onBeforeChange = vi.fn()
|
||||
|
||||
graph.beforeChange()
|
||||
|
||||
expect(deprecationCallback).toHaveBeenCalledWith(
|
||||
expect.stringContaining('LGraph.onBeforeChange is deprecated'),
|
||||
undefined
|
||||
expect(useDeprecationWarningsStore().warnings).toContainEqual(
|
||||
expect.objectContaining({
|
||||
message: DEPRECATIONS['litegraph.onBeforeChange'].message
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not warn when no listener is assigned', () => {
|
||||
it('does not report when no listener is assigned', () => {
|
||||
const graph = new LGraph()
|
||||
const deprecationCallback = vi.fn()
|
||||
LiteGraph.onDeprecationWarning = [deprecationCallback]
|
||||
|
||||
graph.beforeChange()
|
||||
|
||||
expect(deprecationCallback).not.toHaveBeenCalled()
|
||||
expect(useDeprecationWarningsStore().warnings).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ import {
|
||||
createBounds,
|
||||
snapPoint
|
||||
} from './measure'
|
||||
import { warnDeprecated } from './utils/feedback'
|
||||
import { warnDeprecated } from '@/platform/dev/warnDeprecated'
|
||||
import { SubgraphInput } from './subgraph/SubgraphInput'
|
||||
import { SubgraphInputNode } from './subgraph/SubgraphInputNode'
|
||||
import { SubgraphOutput } from './subgraph/SubgraphOutput'
|
||||
@@ -1382,9 +1382,7 @@ export class LGraph
|
||||
// used for undo, called before any change is made to the graph
|
||||
beforeChange(info?: LGraphNode): void {
|
||||
if (this.onBeforeChange) {
|
||||
warnDeprecated(
|
||||
'LGraph.onBeforeChange is deprecated and will be removed in a future version. Assign a listener to LGraphCanvas.onBeforeChange instead.'
|
||||
)
|
||||
warnDeprecated('litegraph.onBeforeChange')
|
||||
this.onBeforeChange(this, info)
|
||||
}
|
||||
this.canvasAction((c) => c.onBeforeChange?.(this))
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import type { SlotPositionContext } from '@/renderer/core/canvas/litegraph/slotCalculations'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import { warnDeprecated } from '@/platform/dev/warnDeprecated'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { UNASSIGNED_NODE_ID, toNodeId, serializeNodeId } from '@/types/nodeId'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
@@ -95,7 +96,6 @@ import type {
|
||||
TWidgetValue
|
||||
} from './types/widgets'
|
||||
import { findFreeSlotOfType } from './utils/collections'
|
||||
import { warnDeprecated } from './utils/feedback'
|
||||
import { distributeSpace } from './utils/spaceDistribution'
|
||||
import { truncateText } from './utils/textUtils'
|
||||
import { BaseWidget } from './widgets/BaseWidget'
|
||||
@@ -3521,9 +3521,7 @@ export class LGraphNode
|
||||
* @deprecated Use {@link LGraphCanvas.pointer} instead.
|
||||
*/
|
||||
captureInput(v: boolean): void {
|
||||
warnDeprecated(
|
||||
'[DEPRECATED] captureInput will be removed in a future version. Please use LGraphCanvas.pointer (CanvasPointer) instead.'
|
||||
)
|
||||
warnDeprecated('litegraph.captureInput')
|
||||
if (!this.graph || !this.graph.list_of_graphcanvas) return
|
||||
|
||||
const list = this.graph.list_of_graphcanvas
|
||||
|
||||
@@ -28,8 +28,15 @@ import {
|
||||
RenderShape,
|
||||
TitleMode
|
||||
} from './types/globalEnums'
|
||||
import { warnDeprecated } from '@/platform/dev/warnDeprecated'
|
||||
import { createUuidv4 } from '@/utils/uuid'
|
||||
|
||||
/** Never read; unfrozen so legacy `.push()` callers do not throw. */
|
||||
const inertDeprecationListeners: ((
|
||||
message: string,
|
||||
source?: object
|
||||
) => void)[] = []
|
||||
|
||||
/**
|
||||
* The Global Scope. It contains all the registered node classes.
|
||||
*/
|
||||
@@ -273,18 +280,31 @@ export class LiteGraphGlobal {
|
||||
context_menu_scaling = false
|
||||
|
||||
/**
|
||||
* Debugging flag. Repeats deprecation warnings every time they are reported.
|
||||
* May impact performance.
|
||||
* @deprecated Removed; has no effect. Deprecation warnings are now surfaced in
|
||||
* the Dev Mode "Deprecation Warnings" sidebar panel rather than dispatched to
|
||||
* listeners. Retained so existing `.push()` callers do not crash; the array is
|
||||
* never read.
|
||||
*/
|
||||
alwaysRepeatWarnings: boolean = false
|
||||
get onDeprecationWarning(): ((message: string, source?: object) => void)[] {
|
||||
warnDeprecated('litegraph.onDeprecationWarning')
|
||||
return inertDeprecationListeners
|
||||
}
|
||||
|
||||
/**
|
||||
* Array of callbacks to execute when Litegraph first reports a deprecated API being used.
|
||||
* @see alwaysRepeatWarnings By default, will not repeat identical messages.
|
||||
*/
|
||||
onDeprecationWarning: ((message: string, source?: object) => void)[] = [
|
||||
console.warn
|
||||
]
|
||||
set onDeprecationWarning(
|
||||
_value: ((message: string, source?: object) => void)[]
|
||||
) {
|
||||
warnDeprecated('litegraph.onDeprecationWarning')
|
||||
}
|
||||
|
||||
/** @deprecated Removed; has no effect. */
|
||||
get alwaysRepeatWarnings(): boolean {
|
||||
warnDeprecated('litegraph.alwaysRepeatWarnings')
|
||||
return false
|
||||
}
|
||||
|
||||
set alwaysRepeatWarnings(_value: boolean) {
|
||||
warnDeprecated('litegraph.alwaysRepeatWarnings')
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Removed; has no effect.
|
||||
|
||||
@@ -140,7 +140,6 @@ LiteGraphGlobal {
|
||||
"allow_multi_output_for_events": true,
|
||||
"allow_scripts": false,
|
||||
"alt_drag_do_clone_nodes": false,
|
||||
"alwaysRepeatWarnings": false,
|
||||
"alwaysSnapToGrid": undefined,
|
||||
"auto_load_slot_types": false,
|
||||
"canvasNavigationMode": "legacy",
|
||||
@@ -167,9 +166,6 @@ LiteGraphGlobal {
|
||||
"node_box_coloured_when_on": false,
|
||||
"node_images_path": "",
|
||||
"node_types_by_file_extension": {},
|
||||
"onDeprecationWarning": [
|
||||
[Function],
|
||||
],
|
||||
"overlapBounding": [Function],
|
||||
"pointerevents_method": "pointer",
|
||||
"proxy": null,
|
||||
|
||||
@@ -64,12 +64,12 @@ describe('contextMenuCompat', () => {
|
||||
|
||||
// Should have logged a warning with extension name
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[DEPRECATED]'),
|
||||
expect.stringContaining('[DEPRECATED'),
|
||||
expect.any(String),
|
||||
expect.any(String)
|
||||
)
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"Test Extension"'),
|
||||
expect.stringContaining('extension: Test Extension'),
|
||||
expect.any(String),
|
||||
expect.any(String)
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { LGraphCanvas } from './LGraphCanvas'
|
||||
import type { IContextMenuValue } from './interfaces'
|
||||
import { warnDeprecated } from '@/platform/dev/warnDeprecated'
|
||||
|
||||
/**
|
||||
* Simple compatibility layer for legacy getCanvasMenuOptions and getNodeMenuOptions monkey patches.
|
||||
@@ -86,13 +87,10 @@ class LegacyMenuCompat {
|
||||
if (!this.hasWarned.has(fnKey) && this.currentExtension) {
|
||||
this.hasWarned.add(fnKey)
|
||||
|
||||
console.warn(
|
||||
`%c[DEPRECATED]%c Monkey-patching ${methodName as string} is deprecated. (Extension: "${this.currentExtension}")\n` +
|
||||
`Please use the new context menu API instead.\n\n` +
|
||||
`See: https://docs.comfy.org/custom-nodes/js/context-menu-migration`,
|
||||
'color: orange; font-weight: bold',
|
||||
'color: inherit'
|
||||
)
|
||||
warnDeprecated('litegraph.contextMenuMonkeyPatch', {
|
||||
extension: this.currentExtension ?? undefined,
|
||||
detail: methodName as string
|
||||
})
|
||||
}
|
||||
currentImpl = newImpl
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { clamp } from 'es-toolkit/compat'
|
||||
import { describe, expect } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
LiteGraphGlobal,
|
||||
@@ -27,6 +27,35 @@ describe('Litegraph module', () => {
|
||||
expect(clamp(-1.124, 13, 24)).toStrictEqual(13)
|
||||
expect(clamp(Infinity, 18, 29)).toStrictEqual(29)
|
||||
})
|
||||
|
||||
describe('removed-API compatibility shims', () => {
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
test('onDeprecationWarning remains a usable no-op for old extensions', () => {
|
||||
expect(() => LiteGraph.onDeprecationWarning.push(() => {})).not.toThrow()
|
||||
expect(LiteGraph.onDeprecationWarning).toBe(
|
||||
LiteGraph.onDeprecationWarning
|
||||
)
|
||||
expect(() => {
|
||||
LiteGraph.onDeprecationWarning = [() => {}]
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
test('alwaysRepeatWarnings remains a usable no-op for old extensions', () => {
|
||||
expect(() => {
|
||||
LiteGraph.alwaysRepeatWarnings = true
|
||||
}).not.toThrow()
|
||||
expect(LiteGraph.alwaysRepeatWarnings).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Import order dependency', () => {
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
/** Guard against unbound allocation. */
|
||||
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.
|
||||
* @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 {
|
||||
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)
|
||||
}
|
||||
|
||||
for (const callback of LiteGraph.onDeprecationWarning) {
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { ComboWidget } from '@/lib/litegraph/src/widgets/ComboWidget'
|
||||
import { DEPRECATIONS } from '@/platform/dev/deprecations'
|
||||
|
||||
const { LGraphCanvas } = await vi.importActual<typeof LGraphCanvasModule>(
|
||||
'@/lib/litegraph/src/LGraphCanvas'
|
||||
@@ -557,9 +558,7 @@ describe('ComboWidget', () => {
|
||||
})
|
||||
|
||||
it('should warn when using deprecated function values', () => {
|
||||
const deprecationCallback = vi.fn()
|
||||
const originalCallbacks = LiteGraph.onDeprecationWarning
|
||||
LiteGraph.onDeprecationWarning = [deprecationCallback]
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const valuesFn = vi.fn().mockReturnValue(['a', 'b', 'c'])
|
||||
widget = new ComboWidget(
|
||||
@@ -583,12 +582,15 @@ describe('ComboWidget', () => {
|
||||
|
||||
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
||||
|
||||
expect(deprecationCallback).toHaveBeenCalledWith(
|
||||
'Using a function for values is deprecated. Use an array of unique values instead.',
|
||||
undefined
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
DEPRECATIONS['litegraph.comboValuesFunction'].message
|
||||
),
|
||||
expect.any(String),
|
||||
expect.any(String)
|
||||
)
|
||||
|
||||
LiteGraph.onDeprecationWarning = originalCallbacks
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
IComboWidget,
|
||||
IStringComboWidget
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { warnDeprecated } from '@/lib/litegraph/src/utils/feedback'
|
||||
import { warnDeprecated } from '@/platform/dev/warnDeprecated'
|
||||
|
||||
import { BaseSteppedWidget } from './BaseSteppedWidget'
|
||||
import type { WidgetEventOptions } from './BaseWidget'
|
||||
@@ -129,9 +129,7 @@ export class ComboWidget
|
||||
|
||||
// Deprecated functionality (warning as of v0.14.5)
|
||||
if (typeof this.options.values === 'function') {
|
||||
warnDeprecated(
|
||||
'Using a function for values is deprecated. Use an array of unique values instead.'
|
||||
)
|
||||
warnDeprecated('litegraph.comboValuesFunction')
|
||||
}
|
||||
|
||||
// Determine if clicked on left/right arrows
|
||||
|
||||
@@ -1046,6 +1046,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"deprecationWarnings": {
|
||||
"title": "Deprecation Warnings",
|
||||
"label": "Warnings",
|
||||
"emptyTitle": "All clear",
|
||||
"empty": "No deprecation warnings reported in this session.",
|
||||
"clear": "Clear",
|
||||
"clearAll": "Clear all",
|
||||
"moreOptions": "More options",
|
||||
"suggestionLabel": "Fix:",
|
||||
"learnMore": "Migration guide",
|
||||
"filterByExtension": "Extension",
|
||||
"unknownExtension": "Unknown",
|
||||
"noMatchesTitle": "No matches",
|
||||
"noMatches": "No warnings match the selected filters.",
|
||||
"occurrenceCount": "Reported {count} time | Reported {count} times"
|
||||
},
|
||||
"helpCenter": {
|
||||
"feedback": "Give Feedback",
|
||||
"docs": "Docs",
|
||||
@@ -1067,7 +1083,8 @@
|
||||
"updateComfyUIStartedDetail": "ComfyUI update has been queued. Please wait...",
|
||||
"updateComfyUISuccess": "Update Complete",
|
||||
"updateComfyUISuccessDetail": "ComfyUI has been updated. Rebooting...",
|
||||
"updateComfyUIFailed": "Failed to update ComfyUI. Please try again."
|
||||
"updateComfyUIFailed": "Failed to update ComfyUI. Please try again.",
|
||||
"missingDate": "Date unavailable"
|
||||
},
|
||||
"releaseToast": {
|
||||
"newVersionAvailable": "New update is out!",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
171
src/platform/dev/backfillServerDeprecations.test.ts
Normal file
171
src/platform/dev/backfillServerDeprecations.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
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('dedupes concurrent callers to a single fetch', async () => {
|
||||
const api = mockState.api
|
||||
api.getRawLogs.mockResolvedValue({ entries: [] })
|
||||
|
||||
const { backfillServerDeprecations } =
|
||||
await import('@/platform/dev/backfillServerDeprecations')
|
||||
|
||||
await Promise.all([
|
||||
backfillServerDeprecations(),
|
||||
backfillServerDeprecations(),
|
||||
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)
|
||||
})
|
||||
})
|
||||
54
src/platform/dev/backfillServerDeprecations.ts
Normal file
54
src/platform/dev/backfillServerDeprecations.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
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
|
||||
let backfillInProgress: Promise<void> | null = null
|
||||
|
||||
/**
|
||||
* Seed the store from the backend's raw log buffer.
|
||||
*/
|
||||
export async function backfillServerDeprecations(): Promise<void> {
|
||||
if (backfilled) return
|
||||
if (backfillInProgress) return backfillInProgress
|
||||
|
||||
backfillInProgress = (async () => {
|
||||
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
|
||||
)
|
||||
} finally {
|
||||
backfillInProgress = null
|
||||
}
|
||||
})()
|
||||
|
||||
return backfillInProgress
|
||||
}
|
||||
121
src/platform/dev/deprecationWarningsStore.test.ts
Normal file
121
src/platform/dev/deprecationWarningsStore.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
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('orders warnings with the most recently seen first', () => {
|
||||
const store = useDeprecationWarningsStore()
|
||||
store.report({ message: 'first' })
|
||||
store.report({ message: 'second' })
|
||||
store.report({ message: 'third' })
|
||||
|
||||
vi.advanceTimersByTime(1_000)
|
||||
store.report({ message: 'first' })
|
||||
|
||||
expect(store.warnings.map((w) => w.message)).toEqual([
|
||||
'first',
|
||||
'third',
|
||||
'second'
|
||||
])
|
||||
})
|
||||
|
||||
it('stops tracking new keys once the cap is reached but keeps counting known ones', () => {
|
||||
const store = useDeprecationWarningsStore()
|
||||
for (let i = 0; i < 10_001; i++) {
|
||||
store.report({ message: `dep-${i}` })
|
||||
}
|
||||
|
||||
expect(store.warnings).toHaveLength(10_000)
|
||||
|
||||
const beforeRepeat = store.warnings.length
|
||||
store.report({ message: 'dep-0' })
|
||||
expect(store.warnings).toHaveLength(beforeRepeat)
|
||||
expect(store.warnings.find((w) => w.message === 'dep-0')?.count).toBe(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)
|
||||
})
|
||||
})
|
||||
132
src/platform/dev/deprecationWarningsStore.ts
Normal file
132
src/platform/dev/deprecationWarningsStore.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, reactive } from 'vue'
|
||||
|
||||
interface DeprecationWarning {
|
||||
key: string
|
||||
message: string
|
||||
suggestion?: string
|
||||
/** Subsystem that reported the deprecation, e.g. 'nodeDef'. */
|
||||
source?: string
|
||||
/** Extension or node pack that triggered it, e.g. 'custom_nodes.devtools'. */
|
||||
extension?: string
|
||||
/** Specific locator within the source, e.g. an affected node/input or method. */
|
||||
detail?: string
|
||||
docsUrl?: string
|
||||
count: number
|
||||
lastSeenAt: number
|
||||
}
|
||||
|
||||
export interface ReportDeprecationInput {
|
||||
message: string
|
||||
suggestion?: string
|
||||
source?: string
|
||||
extension?: string
|
||||
detail?: string
|
||||
docsUrl?: string
|
||||
}
|
||||
|
||||
/** Guard against unbounded growth from high-cardinality reports. */
|
||||
const MAX_TRACKED_DEPRECATIONS = 10_000
|
||||
|
||||
function deprecationKey(input: ReportDeprecationInput): string {
|
||||
return JSON.stringify([
|
||||
input.source ?? '',
|
||||
input.message,
|
||||
input.extension ?? '',
|
||||
input.detail ?? ''
|
||||
])
|
||||
}
|
||||
|
||||
const pendingBuffer = new Map<
|
||||
string,
|
||||
{ input: ReportDeprecationInput; count: number }
|
||||
>()
|
||||
|
||||
/**
|
||||
* Buffers a deprecation reported before Pinia is active, deduped by key and
|
||||
* capped. Returns true the first time a key is buffered, so callers log to
|
||||
* the console once per unique deprecation.
|
||||
*/
|
||||
export function bufferDeprecation(input: ReportDeprecationInput): boolean {
|
||||
const key = deprecationKey(input)
|
||||
const buffered = pendingBuffer.get(key)
|
||||
if (buffered) {
|
||||
buffered.count += 1
|
||||
return false
|
||||
}
|
||||
if (pendingBuffer.size >= MAX_TRACKED_DEPRECATIONS) return false
|
||||
pendingBuffer.set(key, { input, count: 1 })
|
||||
return true
|
||||
}
|
||||
|
||||
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()).reverse()
|
||||
)
|
||||
const unseenCount = computed(() => unseenKeys.size)
|
||||
|
||||
function report(
|
||||
input: ReportDeprecationInput,
|
||||
occurrences: number = 1
|
||||
): boolean {
|
||||
const key = deprecationKey(input)
|
||||
const now = Date.now()
|
||||
const existing = warningsByKey.get(key)
|
||||
|
||||
if (existing) {
|
||||
existing.count += occurrences
|
||||
existing.lastSeenAt = now
|
||||
warningsByKey.delete(key)
|
||||
warningsByKey.set(key, existing)
|
||||
return false
|
||||
}
|
||||
|
||||
if (warningsByKey.size >= MAX_TRACKED_DEPRECATIONS) return false
|
||||
|
||||
warningsByKey.set(key, {
|
||||
key,
|
||||
message: input.message,
|
||||
suggestion: input.suggestion,
|
||||
source: input.source,
|
||||
extension: input.extension,
|
||||
detail: input.detail,
|
||||
docsUrl: input.docsUrl,
|
||||
count: occurrences,
|
||||
lastSeenAt: now
|
||||
})
|
||||
unseenKeys.add(key)
|
||||
return true
|
||||
}
|
||||
|
||||
function markAllSeen(): void {
|
||||
unseenKeys.clear()
|
||||
}
|
||||
|
||||
function clear(): void {
|
||||
warningsByKey.clear()
|
||||
unseenKeys.clear()
|
||||
}
|
||||
|
||||
function remove(key: string): void {
|
||||
warningsByKey.delete(key)
|
||||
unseenKeys.delete(key)
|
||||
}
|
||||
|
||||
for (const { input, count } of pendingBuffer.values()) report(input, count)
|
||||
pendingBuffer.clear()
|
||||
|
||||
return {
|
||||
warnings,
|
||||
unseenCount,
|
||||
report,
|
||||
markAllSeen,
|
||||
clear,
|
||||
remove
|
||||
}
|
||||
}
|
||||
)
|
||||
55
src/platform/dev/deprecations.test.ts
Normal file
55
src/platform/dev/deprecations.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { formatDeprecationConsole } from '@/platform/dev/deprecations'
|
||||
|
||||
describe('formatDeprecationConsole', () => {
|
||||
it('emits a bold, color-styled [DEPRECATED] tag', () => {
|
||||
const [text, tagStyle, restStyle] = formatDeprecationConsole({
|
||||
source: 'widget',
|
||||
message: 'foo is deprecated.'
|
||||
})
|
||||
|
||||
expect(text).toBe('%c[DEPRECATED:widget]%c foo is deprecated.')
|
||||
expect(tagStyle).toBe('color: orange; font-weight: bold')
|
||||
expect(restStyle).toBe('color: inherit')
|
||||
})
|
||||
|
||||
it('omits the source from the tag when none is given', () => {
|
||||
const [text] = formatDeprecationConsole({ message: 'foo is deprecated.' })
|
||||
|
||||
expect(text).toBe('%c[DEPRECATED]%c foo is deprecated.')
|
||||
})
|
||||
|
||||
it('adds labelled lines for each present field in a uniform order', () => {
|
||||
const [text] = formatDeprecationConsole({
|
||||
source: 'litegraph',
|
||||
message: 'foo is deprecated.',
|
||||
extension: 'Comfy.Clipspace',
|
||||
detail: 'getCanvasMenuOptions',
|
||||
suggestion: 'Use bar instead.',
|
||||
docsUrl: 'https://docs.example/guide'
|
||||
})
|
||||
|
||||
expect(text).toBe(
|
||||
[
|
||||
'%c[DEPRECATED:litegraph]%c foo is deprecated.',
|
||||
' extension: Comfy.Clipspace',
|
||||
' detail: getCanvasMenuOptions',
|
||||
' fix: Use bar instead.',
|
||||
' docs: https://docs.example/guide'
|
||||
].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
it('omits absent fields', () => {
|
||||
const [text] = formatDeprecationConsole({
|
||||
source: 'ChangeTracker',
|
||||
message: 'checkState() is deprecated.',
|
||||
suggestion: 'Use captureCanvasState().'
|
||||
})
|
||||
|
||||
expect(text).toBe(
|
||||
'%c[DEPRECATED:ChangeTracker]%c checkState() is deprecated.\n fix: Use captureCanvasState().'
|
||||
)
|
||||
})
|
||||
})
|
||||
143
src/platform/dev/deprecations.ts
Normal file
143
src/platform/dev/deprecations.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
export interface DeprecationEntry {
|
||||
/** Category/origin shown in the console tag and docs, e.g. 'widgetInputs'. */
|
||||
source: string
|
||||
/** Static description of what is deprecated. */
|
||||
message: string
|
||||
/** What to do instead. */
|
||||
suggestion?: string
|
||||
/** Version the deprecation was introduced, e.g. '1.40'. */
|
||||
since?: string
|
||||
/** Version the deprecated API is expected to be removed, e.g. '2.0'. */
|
||||
removeBy?: string
|
||||
/** Link to a migration guide or docs page for this deprecation. */
|
||||
docsUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for every known frontend deprecation, used both at
|
||||
* runtime (call sites reference entries by id via `warnDeprecated`) and
|
||||
* statically (the docs generator in `scripts/generate-deprecations-doc.ts`).
|
||||
*/
|
||||
export const DEPRECATIONS = {
|
||||
'nodeDef.defaultInputRequired': {
|
||||
source: 'nodeDef',
|
||||
message: 'Use of defaultInput on a required input.',
|
||||
suggestion:
|
||||
'Drop the defaultInput option — required sockets are always present.'
|
||||
},
|
||||
'nodeDef.defaultInputOptional': {
|
||||
source: 'nodeDef',
|
||||
message: 'Use of defaultInput on an optional input.',
|
||||
suggestion: 'Replace with forceInput.'
|
||||
},
|
||||
'litegraphService.setSizeForImage': {
|
||||
source: 'litegraphService',
|
||||
message: 'node.setSizeForImage is deprecated and has no effect.',
|
||||
suggestion: 'Remove the call.'
|
||||
},
|
||||
'maskEditor.openMaskEditor': {
|
||||
source: 'MaskEditor',
|
||||
message: 'ComfyApp.open_maskeditor is deprecated.',
|
||||
suggestion:
|
||||
'Migrate to the command system or direct node context menu integration.'
|
||||
},
|
||||
'widgetInputs.convertToInput': {
|
||||
source: 'widgetInputs',
|
||||
message: 'convertToInput is no longer necessary.',
|
||||
suggestion:
|
||||
'Remove the call — widgets and sockets now co-exist on each input.'
|
||||
},
|
||||
'widgetInputs.convertWidgetToInput': {
|
||||
source: 'widgetInputs',
|
||||
message: 'convertWidgetToInput is no longer necessary.',
|
||||
suggestion:
|
||||
'Remove the call — widgets and sockets now co-exist on each input.'
|
||||
},
|
||||
'changeTracker.checkState': {
|
||||
source: 'ChangeTracker',
|
||||
message: 'ChangeTracker.checkState() is deprecated.',
|
||||
suggestion: 'Call captureCanvasState() instead.'
|
||||
},
|
||||
'comfyUI.legacyQueueMenu': {
|
||||
source: 'ComfyUI',
|
||||
message: '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".'
|
||||
},
|
||||
'comfySettings.getSettingValueDefault': {
|
||||
source: 'ComfySettingsDialog',
|
||||
message: 'getSettingValue() defaultValue parameter is deprecated.',
|
||||
suggestion:
|
||||
'Drop the argument — the default value in the setting definition will be used.'
|
||||
},
|
||||
'widget.inputEl': {
|
||||
source: 'widget',
|
||||
message: 'widget.inputEl is deprecated.',
|
||||
suggestion: 'Use widget.element instead (renamed in PR #8594).'
|
||||
},
|
||||
'litegraph.comboValuesFunction': {
|
||||
source: 'litegraph',
|
||||
message: 'Using a function for combo values is deprecated.',
|
||||
suggestion: 'Use an array of unique values instead.'
|
||||
},
|
||||
'litegraph.captureInput': {
|
||||
source: 'litegraph',
|
||||
message: 'captureInput will be removed in a future version.',
|
||||
suggestion: 'Use LGraphCanvas.pointer (CanvasPointer) instead.'
|
||||
},
|
||||
'litegraph.contextMenuMonkeyPatch': {
|
||||
source: 'litegraph',
|
||||
message: 'Monkey-patching context menu methods is deprecated.',
|
||||
suggestion: 'Use the new context menu API instead.',
|
||||
docsUrl: 'https://docs.comfy.org/custom-nodes/js/context-menu-migration'
|
||||
},
|
||||
'litegraph.onDeprecationWarning': {
|
||||
source: 'litegraph',
|
||||
message:
|
||||
'LiteGraph.onDeprecationWarning is no longer used and has no effect.',
|
||||
suggestion:
|
||||
'Remove the listener — there is no programmatic replacement; deprecation warnings now appear in the Dev Mode "Deprecation Warnings" sidebar panel.'
|
||||
},
|
||||
'litegraph.onBeforeChange': {
|
||||
source: 'litegraph',
|
||||
message:
|
||||
'LGraph.onBeforeChange is deprecated and will be removed in a future version.',
|
||||
suggestion: 'Assign a listener to LGraphCanvas.onBeforeChange instead.'
|
||||
},
|
||||
'litegraph.alwaysRepeatWarnings': {
|
||||
source: 'litegraph',
|
||||
message:
|
||||
'LiteGraph.alwaysRepeatWarnings is no longer used and has no effect.',
|
||||
suggestion:
|
||||
'Remove the assignment — deprecation warnings are always deduplicated and tracked with occurrence counts in the Dev Mode "Deprecation Warnings" sidebar panel.'
|
||||
}
|
||||
} satisfies Record<string, DeprecationEntry>
|
||||
|
||||
export type DeprecationId = keyof typeof DEPRECATIONS
|
||||
|
||||
/**
|
||||
* Builds the styled `console.warn` arguments shared by every deprecation
|
||||
* warning: a bold `[DEPRECATED:<source>]` tag and message, followed by labelled
|
||||
* lines for whichever fields are present, in a uniform order. Returns the args
|
||||
* tuple to spread into `console.warn`.
|
||||
*/
|
||||
export function formatDeprecationConsole(input: {
|
||||
source?: string
|
||||
message: string
|
||||
suggestion?: string
|
||||
extension?: string
|
||||
detail?: string
|
||||
docsUrl?: string
|
||||
}): [string, string, string] {
|
||||
const tag = input.source ? `[DEPRECATED:${input.source}]` : '[DEPRECATED]'
|
||||
const lines = [`%c${tag}%c ${input.message}`]
|
||||
if (input.extension) lines.push(` extension: ${input.extension}`)
|
||||
if (input.detail) lines.push(` detail: ${input.detail}`)
|
||||
if (input.suggestion) lines.push(` fix: ${input.suggestion}`)
|
||||
if (input.docsUrl) lines.push(` docs: ${input.docsUrl}`)
|
||||
return [
|
||||
lines.join('\n'),
|
||||
'color: orange; font-weight: bold',
|
||||
'color: inherit'
|
||||
]
|
||||
}
|
||||
192
src/platform/dev/warnDeprecated.test.ts
Normal file
192
src/platform/dev/warnDeprecated.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { getActivePinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
DEPRECATIONS,
|
||||
formatDeprecationConsole
|
||||
} from '@/platform/dev/deprecations'
|
||||
|
||||
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 the registry source tag, message and suggestion', async () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
const { warnDeprecated } = await import('@/platform/dev/warnDeprecated')
|
||||
|
||||
warnDeprecated('widgetInputs.convertToInput')
|
||||
|
||||
const entry = DEPRECATIONS['widgetInputs.convertToInput']
|
||||
expect(warnSpy).toHaveBeenCalledExactlyOnceWith(
|
||||
...formatDeprecationConsole({
|
||||
source: entry.source,
|
||||
message: entry.message,
|
||||
suggestion: entry.suggestion
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards extension and detail as structured fields, not baked into the message', async () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
const { warnDeprecated } = await import('@/platform/dev/warnDeprecated')
|
||||
|
||||
warnDeprecated('nodeDef.defaultInputRequired', {
|
||||
extension: 'custom_nodes.devtools',
|
||||
detail: 'NodeA.seed'
|
||||
})
|
||||
|
||||
const entry = DEPRECATIONS['nodeDef.defaultInputRequired']
|
||||
expect(warnSpy).toHaveBeenCalledExactlyOnceWith(
|
||||
...formatDeprecationConsole({
|
||||
source: entry.source,
|
||||
message: entry.message,
|
||||
suggestion: entry.suggestion,
|
||||
extension: 'custom_nodes.devtools',
|
||||
detail: 'NodeA.seed'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
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('changeTracker.checkState')
|
||||
warnDeprecated('changeTracker.checkState')
|
||||
warnDeprecated('changeTracker.checkState')
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps distinct detail as distinct entries so every affected target stays visible', async () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
const { warnDeprecated } = await import('@/platform/dev/warnDeprecated')
|
||||
const { useDeprecationWarningsStore } =
|
||||
await import('@/platform/dev/deprecationWarningsStore')
|
||||
|
||||
warnDeprecated('nodeDef.defaultInputRequired', { detail: 'NodeA.seed' })
|
||||
warnDeprecated('nodeDef.defaultInputRequired', { detail: 'NodeB.seed' })
|
||||
warnDeprecated('nodeDef.defaultInputRequired', { detail: 'NodeA.seed' })
|
||||
|
||||
const store = useDeprecationWarningsStore()
|
||||
expect(store.warnings).toHaveLength(2)
|
||||
expect(warnSpy).toHaveBeenCalledTimes(2)
|
||||
const nodeA = store.warnings.find((w) => w.detail === 'NodeA.seed')
|
||||
expect(nodeA?.count).toBe(2)
|
||||
})
|
||||
|
||||
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('changeTracker.checkState')
|
||||
warnDeprecated('maskEditor.openMaskEditor')
|
||||
warnDeprecated('changeTracker.checkState')
|
||||
|
||||
// Console logs once per unique deprecation even before pinia is active —
|
||||
// the repeated checkState warning must not log twice.
|
||||
expect(warnSpy).toHaveBeenCalledTimes(2)
|
||||
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
const { useDeprecationWarningsStore } =
|
||||
await import('@/platform/dev/deprecationWarningsStore')
|
||||
const store = useDeprecationWarningsStore()
|
||||
|
||||
expect(store.warnings).toHaveLength(2)
|
||||
const checkState = store.warnings.find((w) =>
|
||||
w.message.includes('checkState')
|
||||
)
|
||||
expect(checkState?.count).toBe(2)
|
||||
})
|
||||
|
||||
it('caps the pre-pinia buffer so a hot loop cannot grow it unboundedly', async () => {
|
||||
expect(getActivePinia()).toBeUndefined()
|
||||
|
||||
const { warnDeprecated } = await import('@/platform/dev/warnDeprecated')
|
||||
|
||||
for (let i = 0; i < 10_001; i++) {
|
||||
warnDeprecated('changeTracker.checkState', { detail: `call-${i}` })
|
||||
}
|
||||
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
const { useDeprecationWarningsStore } =
|
||||
await import('@/platform/dev/deprecationWarningsStore')
|
||||
|
||||
expect(useDeprecationWarningsStore().warnings).toHaveLength(10_000)
|
||||
})
|
||||
|
||||
it('drains the pre-pinia buffer when the next warnDeprecated lands post-pinia', async () => {
|
||||
const { warnDeprecated } = await import('@/platform/dev/warnDeprecated')
|
||||
|
||||
warnDeprecated('changeTracker.checkState')
|
||||
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
warnDeprecated('maskEditor.openMaskEditor')
|
||||
|
||||
const { useDeprecationWarningsStore } =
|
||||
await import('@/platform/dev/deprecationWarningsStore')
|
||||
const store = useDeprecationWarningsStore()
|
||||
expect(new Set(store.warnings.map((w) => w.source))).toEqual(
|
||||
new Set([
|
||||
DEPRECATIONS['maskEditor.openMaskEditor'].source,
|
||||
DEPRECATIONS['changeTracker.checkState'].source
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
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 with the registry entry', async () => {
|
||||
const { defineDeprecatedProperty } =
|
||||
await import('@/platform/dev/warnDeprecated')
|
||||
const { useDeprecationWarningsStore } =
|
||||
await import('@/platform/dev/deprecationWarningsStore')
|
||||
|
||||
const target: { element: string; inputEl?: string } = { element: 'hello' }
|
||||
defineDeprecatedProperty(target, 'inputEl', 'element', 'widget.inputEl')
|
||||
|
||||
expect(target.inputEl).toBe('hello')
|
||||
expect(target.inputEl).toBe('hello')
|
||||
|
||||
const store = useDeprecationWarningsStore()
|
||||
expect(store.warnings).toHaveLength(1)
|
||||
expect(store.warnings[0]).toMatchObject(DEPRECATIONS['widget.inputEl'])
|
||||
})
|
||||
|
||||
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: { element: string; inputEl?: string } = { element: 'a' }
|
||||
defineDeprecatedProperty(target, 'inputEl', 'element', 'widget.inputEl')
|
||||
|
||||
target.inputEl = 'b'
|
||||
expect(target.element).toBe('b')
|
||||
expect(useDeprecationWarningsStore().warnings).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
79
src/platform/dev/warnDeprecated.ts
Normal file
79
src/platform/dev/warnDeprecated.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { getActivePinia } from 'pinia'
|
||||
|
||||
import type {
|
||||
DeprecationEntry,
|
||||
DeprecationId
|
||||
} from '@/platform/dev/deprecations'
|
||||
import {
|
||||
DEPRECATIONS,
|
||||
formatDeprecationConsole
|
||||
} from '@/platform/dev/deprecations'
|
||||
import type { ReportDeprecationInput } from '@/platform/dev/deprecationWarningsStore'
|
||||
import {
|
||||
bufferDeprecation,
|
||||
useDeprecationWarningsStore
|
||||
} from '@/platform/dev/deprecationWarningsStore'
|
||||
|
||||
interface WarnDeprecatedOptions {
|
||||
/** Extension or node pack that triggered the deprecation, surfaced as a tag. */
|
||||
extension?: string
|
||||
/** Specific locator within the source, e.g. an affected node/input or method. */
|
||||
detail?: string
|
||||
}
|
||||
|
||||
function resolveDeprecation(
|
||||
id: DeprecationId,
|
||||
options: WarnDeprecatedOptions
|
||||
): ReportDeprecationInput {
|
||||
const entry: DeprecationEntry = DEPRECATIONS[id]
|
||||
return {
|
||||
message: entry.message,
|
||||
suggestion: entry.suggestion,
|
||||
source: entry.source,
|
||||
docsUrl: entry.docsUrl,
|
||||
extension: options.extension,
|
||||
detail: options.detail
|
||||
}
|
||||
}
|
||||
|
||||
export function warnDeprecated(
|
||||
id: DeprecationId,
|
||||
options: WarnDeprecatedOptions = {}
|
||||
): void {
|
||||
const input = resolveDeprecation(id, options)
|
||||
|
||||
if (!getActivePinia()) {
|
||||
if (bufferDeprecation(input)) {
|
||||
console.warn(...formatDeprecationConsole(input))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (useDeprecationWarningsStore().report(input)) {
|
||||
console.warn(...formatDeprecationConsole(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,
|
||||
id: DeprecationId
|
||||
): void {
|
||||
Object.defineProperty(target, deprecatedKey, {
|
||||
get() {
|
||||
warnDeprecated(id)
|
||||
return this[currentKey]
|
||||
},
|
||||
set(value: unknown) {
|
||||
warnDeprecated(id)
|
||||
this[currentKey] = value
|
||||
},
|
||||
configurable: true,
|
||||
enumerable: false
|
||||
})
|
||||
}
|
||||
30
src/platform/settings/constants/coreSettings.test.ts
Normal file
30
src/platform/settings/constants/coreSettings.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
|
||||
import { DEPRECATIONS } from '@/platform/dev/deprecations'
|
||||
import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore'
|
||||
|
||||
describe('Comfy.UseNewMenu setting', () => {
|
||||
const setting = CORE_SETTINGS.find((s) => s.id === 'Comfy.UseNewMenu')
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('reports the legacy-menu deprecation when set to Disabled', () => {
|
||||
setting?.onChange?.('Disabled')
|
||||
|
||||
const warnings = useDeprecationWarningsStore().warnings
|
||||
expect(warnings).toHaveLength(1)
|
||||
expect(warnings[0]).toMatchObject(DEPRECATIONS['comfyUI.legacyQueueMenu'])
|
||||
})
|
||||
|
||||
it('does not report a deprecation when the new menu is active', () => {
|
||||
setting?.onChange?.('Top')
|
||||
|
||||
expect(useDeprecationWarningsStore().warnings).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
getDefaultLocale,
|
||||
SUPPORTED_LOCALE_OPTIONS
|
||||
} from '@/locales/localeConfig'
|
||||
import { warnDeprecated } from '@/platform/dev/warnDeprecated'
|
||||
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { SettingParams } from '@/platform/settings/types'
|
||||
@@ -571,6 +572,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) => {
|
||||
@@ -612,6 +615,9 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
type: 'combo',
|
||||
options: ['Disabled', 'Top'],
|
||||
tooltip: 'Enable the redesigned top menu bar.',
|
||||
onChange: (value) => {
|
||||
if (value === 'Disabled') warnDeprecated('comfyUI.legacyQueueMenu')
|
||||
},
|
||||
migrateDeprecatedValue: (val: unknown) => {
|
||||
const value = val as string
|
||||
// Floating is now supported by dragging the docked actionbar off.
|
||||
|
||||
@@ -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
|
||||
@@ -45,12 +45,7 @@ function addMultilineWidget(
|
||||
widget.element = inputEl
|
||||
|
||||
/** @deprecated Use {@link widget.element} instead (renamed in PR #8594). */
|
||||
defineDeprecatedProperty(
|
||||
widget,
|
||||
'inputEl',
|
||||
'element',
|
||||
'widget.inputEl is deprecated. Use widget.element instead.'
|
||||
)
|
||||
defineDeprecatedProperty(widget, 'inputEl', 'element', 'widget.inputEl')
|
||||
widget.options.minNodeSize = [400, 200]
|
||||
|
||||
bindMultilineTextareaWidget(widget, inputEl)
|
||||
|
||||
@@ -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,10 @@ 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')
|
||||
this.captureCanvasState()
|
||||
}
|
||||
|
||||
private static _checkStateWarned = false
|
||||
|
||||
async updateState(source: ComfyWorkflowJSON[], target: ComfyWorkflowJSON[]) {
|
||||
const prevState = source.pop()
|
||||
if (prevState) {
|
||||
|
||||
@@ -245,13 +245,6 @@ class ComfyList {
|
||||
this._reverse = reverse || false
|
||||
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".'
|
||||
)
|
||||
}
|
||||
|
||||
get visible() {
|
||||
|
||||
@@ -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,9 +56,7 @@ 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('comfySettings.getSettingValueDefault')
|
||||
}
|
||||
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,7 @@ 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('litegraphService.setSizeForImage')
|
||||
}
|
||||
node.prototype.onDrawBackground = function () {
|
||||
updatePreviews(this)
|
||||
|
||||
@@ -120,6 +120,32 @@ describe('commandStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('unregisterCommand', () => {
|
||||
it('removes a registered command', () => {
|
||||
const store = useCommandStore()
|
||||
store.registerCommand({ id: 'temp.cmd', function: vi.fn() })
|
||||
expect(store.isRegistered('temp.cmd')).toBe(true)
|
||||
|
||||
store.unregisterCommand('temp.cmd')
|
||||
|
||||
expect(store.isRegistered('temp.cmd')).toBe(false)
|
||||
expect(store.getCommand('temp.cmd')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('allows re-registering after unregister without a duplicate warning', () => {
|
||||
const store = useCommandStore()
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
store.registerCommand({ id: 'temp.cmd', function: vi.fn() })
|
||||
store.unregisterCommand('temp.cmd')
|
||||
|
||||
store.registerCommand({ id: 'temp.cmd', function: vi.fn() })
|
||||
|
||||
expect(store.isRegistered('temp.cmd')).toBe(true)
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
warn.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadExtensionCommands', () => {
|
||||
it('registers commands from an extension', () => {
|
||||
const store = useCommandStore()
|
||||
|
||||
@@ -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,67 @@ 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({
|
||||
source: 'nodeDef',
|
||||
extension: 'm',
|
||||
detail: 'N.x'
|
||||
})
|
||||
})
|
||||
|
||||
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({
|
||||
source: 'nodeDef',
|
||||
extension: 'm',
|
||||
detail: 'N.y'
|
||||
})
|
||||
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,9 +114,10 @@ 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('nodeDef.defaultInputRequired', {
|
||||
extension: nodeDef.python_module,
|
||||
detail: `${nodeDef.name}.${name}`
|
||||
})
|
||||
}
|
||||
}
|
||||
// For optional inputs, defaultInput is used to distinguish the null state.
|
||||
@@ -124,9 +126,10 @@ 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('nodeDef.defaultInputOptional', {
|
||||
extension: nodeDef.python_module,
|
||||
detail: `${nodeDef.name}.${name}`
|
||||
})
|
||||
inputOptions.forceInput = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,17 @@ 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
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetSetting: vi.fn(),
|
||||
mockRegisterCommand: vi.fn(),
|
||||
mockRegisterCommands: vi.fn(),
|
||||
mockUnregisterCommand: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
@@ -21,6 +26,7 @@ vi.mock('@/platform/settings/settingStore', () => ({
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({
|
||||
registerCommand: mockRegisterCommand,
|
||||
unregisterCommand: mockUnregisterCommand,
|
||||
commands: []
|
||||
})
|
||||
}))
|
||||
@@ -54,6 +60,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',
|
||||
@@ -96,9 +112,7 @@ vi.mock('@/platform/workflow/management/composables/useAppsSidebarTab', () => ({
|
||||
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 +174,82 @@ 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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,10 @@ 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'
|
||||
@@ -53,7 +57,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 +111,7 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
|
||||
if (activeSidebarTabId.value === id) {
|
||||
activeSidebarTabId.value = null
|
||||
}
|
||||
useCommandStore().unregisterCommand(`Workspace.ToggleSidebarTab.${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +120,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 +132,28 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
|
||||
unregisterSidebarTab(jobHistoryTabId)
|
||||
}
|
||||
}
|
||||
|
||||
syncJobHistoryTab(settingStore.get('Comfy.Queue.QPOV2'))
|
||||
watch(
|
||||
() => settingStore.get('Comfy.Queue.QPOV2'),
|
||||
(enabled) => syncJobHistoryTab(enabled)
|
||||
)
|
||||
|
||||
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 {
|
||||
|
||||
@@ -100,5 +100,8 @@ declare global {
|
||||
__appReadiness?: AppReadiness
|
||||
|
||||
__comfyDesktop2?: ComfyDesktop2Bridge
|
||||
|
||||
/** Injects a synthetic deprecation warning; only installed in dev mode. */
|
||||
__reportDeprecation?: (message: string) => void
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
44
src/utils/relativeTime.test.ts
Normal file
44
src/utils/relativeTime.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { formatRelativeTime } from './relativeTime'
|
||||
|
||||
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
|
||||
|
||||
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_MS - 1)).toBe('g.relativeTime.now')
|
||||
})
|
||||
|
||||
it('picks the largest unit that fits', () => {
|
||||
expect(formatRelativeTime(t, MINUTE_MS)).toBe('g.relativeTime.minutesAgo:1')
|
||||
expect(formatRelativeTime(t, 59 * MINUTE_MS)).toBe(
|
||||
'g.relativeTime.minutesAgo:59'
|
||||
)
|
||||
expect(formatRelativeTime(t, HOUR_MS)).toBe('g.relativeTime.hoursAgo:1')
|
||||
expect(formatRelativeTime(t, 23 * HOUR_MS)).toBe(
|
||||
'g.relativeTime.hoursAgo:23'
|
||||
)
|
||||
expect(formatRelativeTime(t, DAY_MS)).toBe('g.relativeTime.daysAgo:1')
|
||||
expect(formatRelativeTime(t, WEEK_MS)).toBe('g.relativeTime.weeksAgo:1')
|
||||
expect(formatRelativeTime(t, MONTH_MS)).toBe('g.relativeTime.monthsAgo:1')
|
||||
expect(formatRelativeTime(t, YEAR_MS)).toBe('g.relativeTime.yearsAgo:1')
|
||||
})
|
||||
|
||||
it('passes the integer count to the translator', () => {
|
||||
const spy = vi.fn(t)
|
||||
formatRelativeTime(spy, 5 * MINUTE_MS + 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')
|
||||
}
|
||||
@@ -65,6 +65,8 @@ import { setActiveLocale } from '@/i18n'
|
||||
import AssetExportProgressDialog from '@/platform/assets/components/AssetExportProgressDialog.vue'
|
||||
import ModelImportProgressDialog from '@/platform/assets/components/ModelImportProgressDialog.vue'
|
||||
import DesktopCloudNotificationController from '@/platform/cloud/notification/components/DesktopCloudNotificationController.vue'
|
||||
import { backfillServerDeprecations } from '@/platform/dev/backfillServerDeprecations'
|
||||
import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore'
|
||||
import { isCloud, isDesktop } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
@@ -229,6 +231,19 @@ useSidebarTabStore().registerCoreSidebarTabs()
|
||||
void useBottomPanelStore().registerCoreBottomPanelTabs()
|
||||
|
||||
useQueuePolling()
|
||||
|
||||
// The server retains its log buffer, so enabling DevMode later still backfills.
|
||||
watchEffect(() => {
|
||||
if (settingStore.get('Comfy.DevMode')) {
|
||||
void backfillServerDeprecations()
|
||||
window.__reportDeprecation = (message) => {
|
||||
useDeprecationWarningsStore().report({ message })
|
||||
}
|
||||
} else {
|
||||
delete window.__reportDeprecation
|
||||
}
|
||||
})
|
||||
|
||||
const queuePendingTaskCountStore = useQueuePendingTaskCountStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user