Compare commits

..

3 Commits

Author SHA1 Message Date
GitHub Action
de2a41cfb0 [automated] Apply ESLint and Oxfmt fixes 2026-03-28 08:00:26 +00:00
bymyself
719d2d9b0b fix: cast InputSpec test data to satisfy Zod-validated types 2026-03-28 00:57:30 -07:00
bymyself
04c10981d2 test: add unit tests for commandStore, extensionStore, widgetStore (STORE-04)
commandStore (18 tests): registerCommand, registerCommands, getCommand,
execute with metadata/errorHandler, isRegistered, loadExtensionCommands,
ComfyCommandImpl label/icon/menubarLabel resolution

extensionStore (16 tests): registerExtension with validation,
enable/disable lifecycle, always-disabled hardcoded extensions,
enabledExtensions filter, isExtensionReadOnly, inactive disabled tracking,
core extension capture and third-party detection

widgetStore (9 tests): core widget availability, custom widget registration,
core/custom precedence, inputIsWidget for v1 and v2 InputSpec formats
2026-03-27 22:03:59 -07:00
3 changed files with 460 additions and 0 deletions

View File

@@ -0,0 +1,211 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCommandStore } from '@/stores/commandStore'
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
wrapWithErrorHandlingAsync:
(fn: () => Promise<void>, errorHandler?: (e: unknown) => void) =>
async () => {
try {
await fn()
} catch (e) {
if (errorHandler) errorHandler(e)
else throw e
}
}
})
}))
vi.mock('@/platform/keybindings/keybindingStore', () => ({
useKeybindingStore: () => ({
getKeybindingByCommandId: () => null
})
}))
describe('commandStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
describe('registerCommand', () => {
it('registers a command by id', () => {
const store = useCommandStore()
store.registerCommand({
id: 'test.command',
function: vi.fn()
})
expect(store.isRegistered('test.command')).toBe(true)
})
it('warns on duplicate registration', () => {
const store = useCommandStore()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
store.registerCommand({ id: 'dup', function: vi.fn() })
store.registerCommand({ id: 'dup', function: vi.fn() })
expect(warnSpy).toHaveBeenCalledWith('Command dup already registered')
warnSpy.mockRestore()
})
})
describe('registerCommands', () => {
it('registers multiple commands at once', () => {
const store = useCommandStore()
store.registerCommands([
{ id: 'cmd.a', function: vi.fn() },
{ id: 'cmd.b', function: vi.fn() }
])
expect(store.isRegistered('cmd.a')).toBe(true)
expect(store.isRegistered('cmd.b')).toBe(true)
})
})
describe('getCommand', () => {
it('returns the registered command', () => {
const store = useCommandStore()
const fn = vi.fn()
store.registerCommand({ id: 'get.test', function: fn, label: 'Test' })
const cmd = store.getCommand('get.test')
expect(cmd).toBeDefined()
expect(cmd?.label).toBe('Test')
})
it('returns undefined for unregistered command', () => {
const store = useCommandStore()
expect(store.getCommand('nonexistent')).toBeUndefined()
})
})
describe('commands getter', () => {
it('returns all registered commands as an array', () => {
const store = useCommandStore()
store.registerCommand({ id: 'a', function: vi.fn() })
store.registerCommand({ id: 'b', function: vi.fn() })
expect(store.commands).toHaveLength(2)
})
})
describe('execute', () => {
it('executes a registered command', async () => {
const store = useCommandStore()
const fn = vi.fn()
store.registerCommand({ id: 'exec.test', function: fn })
await store.execute('exec.test')
expect(fn).toHaveBeenCalled()
})
it('throws for unregistered command', async () => {
const store = useCommandStore()
await expect(store.execute('missing')).rejects.toThrow(
'Command missing not found'
)
})
it('passes metadata to the command function', async () => {
const store = useCommandStore()
const fn = vi.fn()
store.registerCommand({ id: 'meta.test', function: fn })
await store.execute('meta.test', { metadata: { source: 'keyboard' } })
expect(fn).toHaveBeenCalledWith({ source: 'keyboard' })
})
it('calls errorHandler on failure', async () => {
const store = useCommandStore()
const error = new Error('fail')
store.registerCommand({
id: 'err.test',
function: () => {
throw error
}
})
const handler = vi.fn()
await store.execute('err.test', { errorHandler: handler })
expect(handler).toHaveBeenCalledWith(error)
})
})
describe('isRegistered', () => {
it('returns false for unregistered command', () => {
const store = useCommandStore()
expect(store.isRegistered('nope')).toBe(false)
})
})
describe('loadExtensionCommands', () => {
it('registers commands from an extension', () => {
const store = useCommandStore()
store.loadExtensionCommands({
name: 'test-ext',
commands: [
{ id: 'ext.cmd1', function: vi.fn(), label: 'Cmd 1' },
{ id: 'ext.cmd2', function: vi.fn(), label: 'Cmd 2' }
]
})
expect(store.isRegistered('ext.cmd1')).toBe(true)
expect(store.isRegistered('ext.cmd2')).toBe(true)
})
it('skips extensions without commands', () => {
const store = useCommandStore()
store.loadExtensionCommands({ name: 'no-commands' })
expect(store.commands).toHaveLength(0)
})
})
describe('ComfyCommandImpl', () => {
it('resolves label as string', () => {
const store = useCommandStore()
store.registerCommand({
id: 'label.str',
function: vi.fn(),
label: 'Static'
})
expect(store.getCommand('label.str')?.label).toBe('Static')
})
it('resolves label as function', () => {
const store = useCommandStore()
store.registerCommand({
id: 'label.fn',
function: vi.fn(),
label: () => 'Dynamic'
})
expect(store.getCommand('label.fn')?.label).toBe('Dynamic')
})
it('resolves icon as function', () => {
const store = useCommandStore()
store.registerCommand({
id: 'icon.fn',
function: vi.fn(),
icon: () => 'pi pi-check'
})
expect(store.getCommand('icon.fn')?.icon).toBe('pi pi-check')
})
it('uses label as default menubarLabel', () => {
const store = useCommandStore()
store.registerCommand({
id: 'mbl.default',
function: vi.fn(),
label: 'My Label'
})
expect(store.getCommand('mbl.default')?.menubarLabel).toBe('My Label')
})
it('uses explicit menubarLabel over label', () => {
const store = useCommandStore()
store.registerCommand({
id: 'mbl.explicit',
function: vi.fn(),
label: 'Label',
menubarLabel: 'Menu Label'
})
expect(store.getCommand('mbl.explicit')?.menubarLabel).toBe('Menu Label')
})
})
})

View File

@@ -0,0 +1,154 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useExtensionStore } from '@/stores/extensionStore'
describe('extensionStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
describe('registerExtension', () => {
it('registers an extension by name', () => {
const store = useExtensionStore()
store.registerExtension({ name: 'test.ext' })
expect(store.isExtensionInstalled('test.ext')).toBe(true)
})
it('throws for extension without name', () => {
const store = useExtensionStore()
expect(() => store.registerExtension({ name: '' })).toThrow(
"Extensions must have a 'name' property."
)
})
it('throws for duplicate registration', () => {
const store = useExtensionStore()
store.registerExtension({ name: 'dup' })
expect(() => store.registerExtension({ name: 'dup' })).toThrow(
"Extension named 'dup' already registered."
)
})
it('warns when registering a disabled extension', () => {
const store = useExtensionStore()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
store.loadDisabledExtensionNames(['disabled.ext'])
store.registerExtension({ name: 'disabled.ext' })
expect(warnSpy).toHaveBeenCalledWith(
'Extension disabled.ext is disabled.'
)
warnSpy.mockRestore()
})
})
describe('extensions getter', () => {
it('returns all registered extensions', () => {
const store = useExtensionStore()
store.registerExtension({ name: 'ext.a' })
store.registerExtension({ name: 'ext.b' })
expect(store.extensions).toHaveLength(2)
})
})
describe('isExtensionInstalled', () => {
it('returns false for uninstalled extension', () => {
const store = useExtensionStore()
expect(store.isExtensionInstalled('missing')).toBe(false)
})
})
describe('isExtensionEnabled / loadDisabledExtensionNames', () => {
it('all extensions are enabled by default', () => {
const store = useExtensionStore()
store.registerExtension({ name: 'fresh' })
expect(store.isExtensionEnabled('fresh')).toBe(true)
})
it('disables extensions from provided list', () => {
const store = useExtensionStore()
store.loadDisabledExtensionNames(['off.ext'])
store.registerExtension({ name: 'off.ext' })
expect(store.isExtensionEnabled('off.ext')).toBe(false)
})
it('always disables hardcoded extensions', () => {
const store = useExtensionStore()
store.loadDisabledExtensionNames([])
expect(store.isExtensionEnabled('pysssss.Locking')).toBe(false)
expect(store.isExtensionEnabled('pysssss.SnapToGrid')).toBe(false)
expect(store.isExtensionEnabled('pysssss.FaviconStatus')).toBe(false)
expect(store.isExtensionEnabled('KJNodes.browserstatus')).toBe(false)
})
})
describe('enabledExtensions', () => {
it('filters out disabled extensions', () => {
const store = useExtensionStore()
store.loadDisabledExtensionNames(['ext.off'])
store.registerExtension({ name: 'ext.on' })
store.registerExtension({ name: 'ext.off' })
const enabled = store.enabledExtensions
expect(enabled).toHaveLength(1)
expect(enabled[0].name).toBe('ext.on')
})
})
describe('isExtensionReadOnly', () => {
it('returns true for always-disabled extensions', () => {
const store = useExtensionStore()
expect(store.isExtensionReadOnly('pysssss.Locking')).toBe(true)
})
it('returns false for normal extensions', () => {
const store = useExtensionStore()
expect(store.isExtensionReadOnly('some.custom.ext')).toBe(false)
})
})
describe('inactiveDisabledExtensionNames', () => {
it('returns disabled names not currently installed', () => {
const store = useExtensionStore()
store.loadDisabledExtensionNames(['ghost.ext', 'installed.ext'])
store.registerExtension({ name: 'installed.ext' })
expect(store.inactiveDisabledExtensionNames).toContain('ghost.ext')
expect(store.inactiveDisabledExtensionNames).not.toContain(
'installed.ext'
)
})
})
describe('core extensions', () => {
it('captures current extensions as core', () => {
const store = useExtensionStore()
store.registerExtension({ name: 'core.a' })
store.registerExtension({ name: 'core.b' })
store.captureCoreExtensions()
expect(store.isCoreExtension('core.a')).toBe(true)
expect(store.isCoreExtension('core.b')).toBe(true)
})
it('identifies third-party extensions registered after capture', () => {
const store = useExtensionStore()
store.registerExtension({ name: 'core.x' })
store.captureCoreExtensions()
expect(store.hasThirdPartyExtensions).toBe(false)
store.registerExtension({ name: 'third.party' })
expect(store.hasThirdPartyExtensions).toBe(true)
})
it('returns false for isCoreExtension before capture', () => {
const store = useExtensionStore()
store.registerExtension({ name: 'ext.pre' })
expect(store.isCoreExtension('ext.pre')).toBe(false)
})
})
})

View File

@@ -0,0 +1,95 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { InputSpec as InputSpecV1 } from '@/schemas/nodeDefSchema'
import { useWidgetStore } from '@/stores/widgetStore'
/** Cast shorthand — the mock bypasses Zod validation, so we only need the shape `inputIsWidget` reads. */
const v1 = (spec: unknown) => spec as InputSpecV1
const v2 = (spec: unknown) => spec as InputSpecV2
vi.mock('@/scripts/widgets', () => ({
ComfyWidgets: {
INT: vi.fn(),
FLOAT: vi.fn(),
STRING: vi.fn(),
BOOLEAN: vi.fn(),
COMBO: vi.fn()
}
}))
vi.mock('@/schemas/nodeDefSchema', () => ({
getInputSpecType: (spec: unknown[]) => spec[0]
}))
describe('widgetStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
describe('widgets getter', () => {
it('includes core widgets', () => {
const store = useWidgetStore()
expect(store.widgets.has('INT')).toBe(true)
expect(store.widgets.has('FLOAT')).toBe(true)
expect(store.widgets.has('STRING')).toBe(true)
})
it('includes custom widgets after registration', () => {
const store = useWidgetStore()
const customFn = vi.fn()
store.registerCustomWidgets({ CUSTOM_TYPE: customFn })
expect(store.widgets.has('CUSTOM_TYPE')).toBe(true)
})
it('core widgets take precedence over custom widgets with same key', () => {
const store = useWidgetStore()
const override = vi.fn()
store.registerCustomWidgets({ INT: override })
// Core widgets are spread last, so they win
expect(store.widgets.get('INT')).not.toBe(override)
})
})
describe('registerCustomWidgets', () => {
it('registers multiple custom widgets', () => {
const store = useWidgetStore()
store.registerCustomWidgets({
TYPE_A: vi.fn(),
TYPE_B: vi.fn()
})
expect(store.widgets.has('TYPE_A')).toBe(true)
expect(store.widgets.has('TYPE_B')).toBe(true)
})
})
describe('inputIsWidget', () => {
it('returns true for known widget type (v1 spec)', () => {
const store = useWidgetStore()
expect(store.inputIsWidget(v1(['INT', {}]))).toBe(true)
})
it('returns false for unknown type (v1 spec)', () => {
const store = useWidgetStore()
expect(store.inputIsWidget(v1(['UNKNOWN_TYPE', {}]))).toBe(false)
})
it('returns true for v2 spec with known type', () => {
const store = useWidgetStore()
expect(store.inputIsWidget(v2({ type: 'STRING' }))).toBe(true)
})
it('returns false for v2 spec with unknown type', () => {
const store = useWidgetStore()
expect(store.inputIsWidget(v2({ type: 'LATENT' }))).toBe(false)
})
it('returns true for custom registered type', () => {
const store = useWidgetStore()
store.registerCustomWidgets({ MY_WIDGET: vi.fn() })
expect(store.inputIsWidget(v2({ type: 'MY_WIDGET' }))).toBe(true)
})
})
})