Skip to content

Commit c5b1830

Browse files
christian-byrneactions-userampcode-comDrJKL
authored
test: add unit tests for commandStore, extensionStore, widgetStore (STORE-04) (#10647)
## Summary Adds 43 unit tests covering three priority Pinia stores that previously had zero test coverage. ### commandStore (18 tests) - `registerCommand` / `registerCommands` — single and batch registration, duplicate warning - `getCommand` — retrieval and undefined for missing - `execute` — successful execution, metadata passing, error handler delegation, missing command error - `isRegistered` — presence check - `loadExtensionCommands` — extension command registration with source, skip when no commands - `ComfyCommandImpl` — label/icon/tooltip resolution (string vs function), menubarLabel defaulting ### extensionStore (16 tests) - `registerExtension` — name validation, duplicate detection, disabled extension warning - `isExtensionEnabled` / `loadDisabledExtensionNames` — enable/disable lifecycle - Always-disabled hardcoded extensions (pysssss.Locking, pysssss.SnapToGrid, pysssss.FaviconStatus, KJNodes.browserstatus) - `enabledExtensions` — computed filter - `isExtensionReadOnly` — hardcoded list check - `inactiveDisabledExtensionNames` — ghost extension tracking - Core extension capture and `hasThirdPartyExtensions` detection ### widgetStore (9 tests) - Core widget availability via `ComfyWidgets` - Custom widget registration and core/custom precedence - `inputIsWidget` for both v1 array and v2 object InputSpec formats ## Part of Test Coverage Q2 Overhaul — Phase 5 (Unit & Component Tests) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-10647-test-add-unit-tests-for-commandStore-extensionStore-widgetStore-STORE-04-3316d73d365081e0b4f6ce913130e489) by [Unito](https://www.unito.io) --------- Co-authored-by: GitHub Action <action@github.com> Co-authored-by: Amp <amp@ampcode.com> Co-authored-by: Alexander Brown <drjkl@comfy.org> Co-authored-by: Alexander Brown <448862+DrJKL@users.noreply.github.com>
1 parent 5872885 commit c5b1830

3 files changed

Lines changed: 425 additions & 0 deletions

File tree

src/stores/commandStore.test.ts

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { createTestingPinia } from '@pinia/testing'
2+
import { setActivePinia } from 'pinia'
3+
import { beforeEach, describe, expect, it, vi } from 'vitest'
4+
5+
import { useCommandStore } from '@/stores/commandStore'
6+
7+
vi.mock('@/composables/useErrorHandling', () => ({
8+
useErrorHandling: () => ({
9+
wrapWithErrorHandlingAsync:
10+
(fn: () => Promise<void>, errorHandler?: (e: unknown) => void) =>
11+
async () => {
12+
try {
13+
await fn()
14+
} catch (e) {
15+
if (errorHandler) errorHandler(e)
16+
else throw e
17+
}
18+
}
19+
})
20+
}))
21+
22+
vi.mock('@/platform/keybindings/keybindingStore', () => ({
23+
useKeybindingStore: () => ({
24+
getKeybindingByCommandId: () => null
25+
})
26+
}))
27+
28+
describe('commandStore', () => {
29+
beforeEach(() => {
30+
setActivePinia(createTestingPinia({ stubActions: false }))
31+
})
32+
33+
describe('registerCommand', () => {
34+
it('registers a command by id', () => {
35+
const store = useCommandStore()
36+
store.registerCommand({
37+
id: 'test.command',
38+
function: vi.fn()
39+
})
40+
expect(store.isRegistered('test.command')).toBe(true)
41+
})
42+
43+
it('warns on duplicate registration and overwrites with new function', async () => {
44+
const store = useCommandStore()
45+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
46+
47+
const originalFn = vi.fn()
48+
const replacementFn = vi.fn()
49+
store.registerCommand({ id: 'dup', function: originalFn })
50+
store.registerCommand({ id: 'dup', function: replacementFn })
51+
52+
expect(warnSpy).toHaveBeenCalledWith('Command dup already registered')
53+
warnSpy.mockRestore()
54+
55+
await store.getCommand('dup')?.function()
56+
expect(replacementFn).toHaveBeenCalled()
57+
expect(originalFn).not.toHaveBeenCalled()
58+
})
59+
})
60+
61+
describe('getCommand', () => {
62+
it('returns the registered command', () => {
63+
const store = useCommandStore()
64+
const fn = vi.fn()
65+
store.registerCommand({ id: 'get.test', function: fn, label: 'Test' })
66+
const cmd = store.getCommand('get.test')
67+
expect(cmd).toBeDefined()
68+
expect(cmd?.label).toBe('Test')
69+
})
70+
71+
it('returns undefined for unregistered command', () => {
72+
const store = useCommandStore()
73+
expect(store.getCommand('nonexistent')).toBeUndefined()
74+
})
75+
})
76+
77+
describe('execute', () => {
78+
it('executes a registered command', async () => {
79+
const store = useCommandStore()
80+
const fn = vi.fn()
81+
store.registerCommand({ id: 'exec.test', function: fn })
82+
await store.execute('exec.test')
83+
expect(fn).toHaveBeenCalled()
84+
})
85+
86+
it('throws for unregistered command', async () => {
87+
const store = useCommandStore()
88+
await expect(store.execute('missing')).rejects.toThrow(
89+
'Command missing not found'
90+
)
91+
})
92+
93+
it('passes metadata to the command function', async () => {
94+
const store = useCommandStore()
95+
const fn = vi.fn()
96+
store.registerCommand({ id: 'meta.test', function: fn })
97+
await store.execute('meta.test', { metadata: { source: 'keyboard' } })
98+
expect(fn).toHaveBeenCalledWith({ source: 'keyboard' })
99+
})
100+
101+
it('calls errorHandler on failure', async () => {
102+
const store = useCommandStore()
103+
const error = new Error('fail')
104+
store.registerCommand({
105+
id: 'err.test',
106+
function: () => {
107+
throw error
108+
}
109+
})
110+
const handler = vi.fn()
111+
await store.execute('err.test', { errorHandler: handler })
112+
expect(handler).toHaveBeenCalledWith(error)
113+
})
114+
})
115+
116+
describe('isRegistered', () => {
117+
it('returns false for unregistered command', () => {
118+
const store = useCommandStore()
119+
expect(store.isRegistered('nope')).toBe(false)
120+
})
121+
})
122+
123+
describe('loadExtensionCommands', () => {
124+
it('registers commands from an extension', () => {
125+
const store = useCommandStore()
126+
store.loadExtensionCommands({
127+
name: 'test-ext',
128+
commands: [
129+
{ id: 'ext.cmd1', function: vi.fn(), label: 'Cmd 1' },
130+
{ id: 'ext.cmd2', function: vi.fn(), label: 'Cmd 2' }
131+
]
132+
})
133+
expect(store.isRegistered('ext.cmd1')).toBe(true)
134+
expect(store.isRegistered('ext.cmd2')).toBe(true)
135+
expect(store.getCommand('ext.cmd1')?.source).toBe('test-ext')
136+
expect(store.getCommand('ext.cmd2')?.source).toBe('test-ext')
137+
})
138+
139+
it('skips extensions without commands', () => {
140+
const store = useCommandStore()
141+
store.loadExtensionCommands({ name: 'no-commands' })
142+
expect(store.commands).toHaveLength(0)
143+
})
144+
})
145+
146+
describe('getCommand resolves dynamic properties', () => {
147+
it('resolves label as function', () => {
148+
const store = useCommandStore()
149+
store.registerCommand({
150+
id: 'label.fn',
151+
function: vi.fn(),
152+
label: () => 'Dynamic'
153+
})
154+
expect(store.getCommand('label.fn')?.label).toBe('Dynamic')
155+
})
156+
157+
it('resolves tooltip as function', () => {
158+
const store = useCommandStore()
159+
store.registerCommand({
160+
id: 'tip.fn',
161+
function: vi.fn(),
162+
tooltip: () => 'Dynamic tip'
163+
})
164+
expect(store.getCommand('tip.fn')?.tooltip).toBe('Dynamic tip')
165+
})
166+
167+
it('uses explicit menubarLabel over label', () => {
168+
const store = useCommandStore()
169+
store.registerCommand({
170+
id: 'mbl.explicit',
171+
function: vi.fn(),
172+
label: 'Label',
173+
menubarLabel: 'Menu Label'
174+
})
175+
expect(store.getCommand('mbl.explicit')?.menubarLabel).toBe('Menu Label')
176+
})
177+
178+
it('falls back menubarLabel to label', () => {
179+
const store = useCommandStore()
180+
store.registerCommand({
181+
id: 'mbl.default',
182+
function: vi.fn(),
183+
label: 'My Label'
184+
})
185+
expect(store.getCommand('mbl.default')?.menubarLabel).toBe('My Label')
186+
})
187+
})
188+
189+
describe('formatKeySequence', () => {
190+
it('returns empty string when command has no keybinding', () => {
191+
const store = useCommandStore()
192+
store.registerCommand({ id: 'no.kb', function: vi.fn() })
193+
const cmd = store.getCommand('no.kb')!
194+
expect(store.formatKeySequence(cmd)).toBe('')
195+
})
196+
})
197+
})

src/stores/extensionStore.test.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { createTestingPinia } from '@pinia/testing'
2+
import { setActivePinia } from 'pinia'
3+
import { beforeEach, describe, expect, it, vi } from 'vitest'
4+
5+
import { useExtensionStore } from '@/stores/extensionStore'
6+
7+
describe('extensionStore', () => {
8+
beforeEach(() => {
9+
setActivePinia(createTestingPinia({ stubActions: false }))
10+
})
11+
12+
describe('registerExtension', () => {
13+
it('registers an extension by name', () => {
14+
const store = useExtensionStore()
15+
store.registerExtension({ name: 'test.ext' })
16+
expect(store.isExtensionInstalled('test.ext')).toBe(true)
17+
})
18+
19+
it('throws for extension without name', () => {
20+
const store = useExtensionStore()
21+
expect(() => store.registerExtension({ name: '' })).toThrow(
22+
"Extensions must have a 'name' property."
23+
)
24+
})
25+
26+
it('throws for duplicate registration', () => {
27+
const store = useExtensionStore()
28+
store.registerExtension({ name: 'dup' })
29+
expect(() => store.registerExtension({ name: 'dup' })).toThrow(
30+
"Extension named 'dup' already registered."
31+
)
32+
})
33+
34+
it('warns when registering a disabled extension but still installs it', () => {
35+
const store = useExtensionStore()
36+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
37+
try {
38+
store.loadDisabledExtensionNames(['disabled.ext'])
39+
store.registerExtension({ name: 'disabled.ext' })
40+
expect(warnSpy).toHaveBeenCalledWith(
41+
'Extension disabled.ext is disabled.'
42+
)
43+
expect(store.isExtensionInstalled('disabled.ext')).toBe(true)
44+
expect(store.isExtensionEnabled('disabled.ext')).toBe(false)
45+
} finally {
46+
warnSpy.mockRestore()
47+
}
48+
})
49+
})
50+
51+
describe('isExtensionInstalled', () => {
52+
it('returns false for uninstalled extension', () => {
53+
const store = useExtensionStore()
54+
expect(store.isExtensionInstalled('missing')).toBe(false)
55+
})
56+
})
57+
58+
describe('isExtensionEnabled / loadDisabledExtensionNames', () => {
59+
it('all extensions are enabled by default', () => {
60+
const store = useExtensionStore()
61+
store.registerExtension({ name: 'fresh' })
62+
expect(store.isExtensionEnabled('fresh')).toBe(true)
63+
})
64+
65+
it('disables extensions from provided list', () => {
66+
const store = useExtensionStore()
67+
store.loadDisabledExtensionNames(['off.ext'])
68+
store.registerExtension({ name: 'off.ext' })
69+
expect(store.isExtensionEnabled('off.ext')).toBe(false)
70+
})
71+
72+
it('always disables hardcoded extensions', () => {
73+
const store = useExtensionStore()
74+
store.loadDisabledExtensionNames([])
75+
store.registerExtension({ name: 'pysssss.Locking' })
76+
store.registerExtension({ name: 'regular.ext' })
77+
78+
expect(store.isExtensionEnabled('pysssss.Locking')).toBe(false)
79+
expect(store.isExtensionEnabled('pysssss.SnapToGrid')).toBe(false)
80+
expect(store.isExtensionEnabled('pysssss.FaviconStatus')).toBe(false)
81+
expect(store.isExtensionEnabled('KJNodes.browserstatus')).toBe(false)
82+
expect(store.isExtensionEnabled('regular.ext')).toBe(true)
83+
})
84+
})
85+
86+
describe('enabledExtensions', () => {
87+
it('filters out disabled extensions', () => {
88+
const store = useExtensionStore()
89+
store.loadDisabledExtensionNames(['ext.off'])
90+
store.registerExtension({ name: 'ext.on' })
91+
store.registerExtension({ name: 'ext.off' })
92+
93+
const enabled = store.enabledExtensions
94+
expect(enabled).toHaveLength(1)
95+
expect(enabled[0].name).toBe('ext.on')
96+
})
97+
})
98+
99+
describe('isExtensionReadOnly', () => {
100+
it('returns true for always-disabled extensions', () => {
101+
const store = useExtensionStore()
102+
expect(store.isExtensionReadOnly('pysssss.Locking')).toBe(true)
103+
})
104+
105+
it('returns false for normal extensions', () => {
106+
const store = useExtensionStore()
107+
expect(store.isExtensionReadOnly('some.custom.ext')).toBe(false)
108+
})
109+
})
110+
111+
describe('inactiveDisabledExtensionNames', () => {
112+
it('returns disabled names not currently installed', () => {
113+
const store = useExtensionStore()
114+
store.loadDisabledExtensionNames(['ghost.ext', 'installed.ext'])
115+
store.registerExtension({ name: 'installed.ext' })
116+
117+
expect(store.inactiveDisabledExtensionNames).toContain('ghost.ext')
118+
expect(store.inactiveDisabledExtensionNames).not.toContain(
119+
'installed.ext'
120+
)
121+
})
122+
})
123+
124+
describe('core extensions', () => {
125+
it('captures current extensions as core', () => {
126+
const store = useExtensionStore()
127+
store.registerExtension({ name: 'core.a' })
128+
store.registerExtension({ name: 'core.b' })
129+
store.captureCoreExtensions()
130+
131+
expect(store.isCoreExtension('core.a')).toBe(true)
132+
expect(store.isCoreExtension('core.b')).toBe(true)
133+
})
134+
135+
it('identifies third-party extensions registered after capture', () => {
136+
const store = useExtensionStore()
137+
store.registerExtension({ name: 'core.x' })
138+
store.captureCoreExtensions()
139+
140+
expect(store.hasThirdPartyExtensions).toBe(false)
141+
142+
store.registerExtension({ name: 'third.party' })
143+
expect(store.hasThirdPartyExtensions).toBe(true)
144+
})
145+
146+
it('returns false for isCoreExtension before capture', () => {
147+
const store = useExtensionStore()
148+
store.registerExtension({ name: 'ext.pre' })
149+
expect(store.isCoreExtension('ext.pre')).toBe(false)
150+
})
151+
})
152+
})

0 commit comments

Comments
 (0)