mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-03 13:48:49 +00:00
Compare commits
1 Commits
shihchi/co
...
shihchi/re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff09f65c93 |
@@ -1,26 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useActionBarButtonStore } from '@/stores/actionBarButtonStore'
|
||||
import { useExtensionStore } from '@/stores/extensionStore'
|
||||
|
||||
describe('actionBarButtonStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('collects action bar buttons from registered extensions', () => {
|
||||
const extensionStore = useExtensionStore()
|
||||
const onClick = vi.fn()
|
||||
extensionStore.registerExtension({
|
||||
name: 'buttons',
|
||||
actionBarButtons: [{ icon: 'icon-[lucide--plus]', onClick }]
|
||||
})
|
||||
extensionStore.registerExtension({ name: 'plain' })
|
||||
|
||||
const store = useActionBarButtonStore()
|
||||
|
||||
expect(store.buttons).toEqual([{ icon: 'icon-[lucide--plus]', onClick }])
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { nextTick, reactive } from 'vue'
|
||||
import { nextTick } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
@@ -56,13 +56,9 @@ vi.mock('@/utils/litegraphUtil', async (importOriginal) => ({
|
||||
resolveNode: mockResolveNode
|
||||
}))
|
||||
|
||||
const mockCanvas = vi.hoisted(() => ({
|
||||
state: undefined as { readOnly: boolean } | undefined
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
getCanvas: () => ({ state: mockCanvas.state })
|
||||
getCanvas: () => ({ read_only: false })
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -166,7 +162,6 @@ describe('appModeStore', () => {
|
||||
ChangeTracker.isLoadingGraph = false
|
||||
mockResolveNode.mockReturnValue(undefined)
|
||||
mockSettings.reset()
|
||||
mockCanvas.state = undefined
|
||||
vi.mocked(app.rootGraph).nodes = [{ id: toNodeId(1) } as LGraphNode]
|
||||
workflowStore = useWorkflowStore()
|
||||
store = useAppModeStore()
|
||||
@@ -370,83 +365,6 @@ describe('appModeStore', () => {
|
||||
expect(store.selectedInputs).toEqual([[entityPrompt, 'prompt']])
|
||||
})
|
||||
|
||||
it('keeps canonical entity ids when the node still exists', () => {
|
||||
const node1 = nodeWithWidgets(1, [])
|
||||
vi.mocked(app.rootGraph).nodes = [node1]
|
||||
vi.mocked(app.rootGraph).getNodeById = vi.fn((id) =>
|
||||
id === toNodeId(1) ? node1 : null
|
||||
)
|
||||
|
||||
store.loadSelections({
|
||||
inputs: [[entityPrompt, 'prompt']]
|
||||
})
|
||||
|
||||
expect(store.selectedInputs).toEqual([[entityPrompt, 'prompt']])
|
||||
})
|
||||
|
||||
it('drops canonical entity ids when their node is gone', () => {
|
||||
vi.mocked(app.rootGraph).nodes = []
|
||||
vi.mocked(app.rootGraph).getNodeById = vi.fn(() => null)
|
||||
|
||||
store.loadSelections({
|
||||
inputs: [[entityPrompt, 'prompt']]
|
||||
})
|
||||
|
||||
expect(store.selectedInputs).toEqual([])
|
||||
})
|
||||
|
||||
it('drops locator inputs when the widget does not resolve', () => {
|
||||
const hostLocator = `${rootGraphId}:5`
|
||||
const hostNode = fromAny<LGraphNode, unknown>({
|
||||
id: 5,
|
||||
isSubgraphNode: () => false,
|
||||
widgets: [{ name: 'other' }]
|
||||
})
|
||||
vi.mocked(app.rootGraph).nodes = [hostNode]
|
||||
vi.mocked(app.rootGraph).getNodeById = vi.fn((id) =>
|
||||
id === toNodeId(5) ? hostNode : null
|
||||
)
|
||||
|
||||
store.loadSelections({
|
||||
inputs: [[hostLocator, 'prompt']]
|
||||
})
|
||||
|
||||
expect(store.selectedInputs).toEqual([])
|
||||
})
|
||||
|
||||
it('drops malformed legacy input ids', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
vi.mocked(app.rootGraph).nodes = []
|
||||
|
||||
store.loadSelections({
|
||||
inputs: [[fromAny<SerializedNodeId, unknown>(null), 'prompt']]
|
||||
})
|
||||
|
||||
expect(store.selectedInputs).toEqual([])
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('legacy selectedInput tuple'),
|
||||
expect.objectContaining({ storedId: null, widgetName: 'prompt' })
|
||||
)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('drops direct node inputs when the widget is missing', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const node1 = nodeWithWidgets(1, [])
|
||||
vi.mocked(app.rootGraph).nodes = [node1]
|
||||
vi.mocked(app.rootGraph).getNodeById = vi.fn((id) =>
|
||||
id === toNodeId(1) ? node1 : null
|
||||
)
|
||||
|
||||
store.loadSelections({
|
||||
inputs: [[1, 'prompt']]
|
||||
})
|
||||
|
||||
expect(store.selectedInputs).toEqual([])
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('drops legacy entries whose widget no longer exists', () => {
|
||||
const node1 = nodeWithWidgets(1, ['prompt'])
|
||||
vi.mocked(app.rootGraph).nodes = [node1]
|
||||
@@ -481,32 +399,6 @@ describe('appModeStore', () => {
|
||||
expect(store.selectedOutputs).toEqual([toNodeId(1)])
|
||||
})
|
||||
|
||||
it('drops malformed output ids on load', () => {
|
||||
store.loadSelections({
|
||||
outputs: [fromAny<SerializedNodeId, unknown>('')]
|
||||
})
|
||||
|
||||
expect(store.selectedOutputs).toEqual([])
|
||||
})
|
||||
|
||||
it('drops legacy subgraph input slots without widget ids', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const hostNode = Object.assign(Object.create(SubgraphNode.prototype), {
|
||||
id: 5,
|
||||
inputs: [{ name: 'Prompt' }]
|
||||
})
|
||||
vi.mocked(app.rootGraph).nodes = [hostNode]
|
||||
vi.mocked(app.rootGraph).getNodeById = vi.fn(() => null)
|
||||
|
||||
store.loadSelections({
|
||||
inputs: [[1, 'prompt']]
|
||||
})
|
||||
|
||||
expect(store.selectedInputs).toEqual([])
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('reloads selections on configured event', async () => {
|
||||
const node1 = nodeWithWidgets(1, ['seed'])
|
||||
|
||||
@@ -589,7 +481,7 @@ describe('appModeStore', () => {
|
||||
expect(
|
||||
store.pruneLinearData({
|
||||
inputs: [[1, 'seed']],
|
||||
outputs: [toNodeId(1), fromAny<SerializedNodeId, unknown>('')]
|
||||
outputs: [toNodeId(1)]
|
||||
})
|
||||
).toEqual({
|
||||
inputs: [[1, 'seed']],
|
||||
@@ -749,17 +641,6 @@ describe('appModeStore', () => {
|
||||
expect(originalRootGraph.extra.linearData).toEqual(dataBefore)
|
||||
})
|
||||
|
||||
it('does not write while graph loading is in progress', async () => {
|
||||
workflowStore.activeWorkflow = createBuilderWorkflow()
|
||||
ChangeTracker.isLoadingGraph = true
|
||||
await nextTick()
|
||||
|
||||
store.selectedOutputs.push(toNodeId(1))
|
||||
await nextTick()
|
||||
|
||||
expect(app.rootGraph.extra.linearData).toBeUndefined()
|
||||
})
|
||||
|
||||
it('calls captureCanvasState when input is selected', async () => {
|
||||
const workflow = createBuilderWorkflow()
|
||||
workflowStore.activeWorkflow = workflow
|
||||
@@ -874,24 +755,6 @@ describe('appModeStore', () => {
|
||||
|
||||
expect(store.selectedInputs).toEqual([[promptEntity, 'prompt']])
|
||||
})
|
||||
|
||||
it('ignores widgets without ids', () => {
|
||||
store.selectedInputs.push(['g:1:prompt' as WidgetId, 'prompt'])
|
||||
|
||||
store.removeSelectedInput(fromAny<IBaseWidget, unknown>({}))
|
||||
|
||||
expect(store.selectedInputs).toEqual([['g:1:prompt', 'prompt']])
|
||||
})
|
||||
|
||||
it('ignores missing input ids', () => {
|
||||
store.selectedInputs.push(['g:1:prompt' as WidgetId, 'prompt'])
|
||||
|
||||
store.removeSelectedInput(
|
||||
fromAny<IBaseWidget, unknown>({ widgetId: 'g:2:prompt' })
|
||||
)
|
||||
|
||||
expect(store.selectedInputs).toEqual([['g:1:prompt', 'prompt']])
|
||||
})
|
||||
})
|
||||
|
||||
describe('autoEnableVueNodes', () => {
|
||||
@@ -956,47 +819,6 @@ describe('appModeStore', () => {
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('does not enable Vue nodes after leaving select mode', async () => {
|
||||
mockSettings.store['Comfy.VueNodes.Enabled'] = false
|
||||
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
|
||||
|
||||
store.enterBuilder()
|
||||
await nextTick()
|
||||
mockSettings.set.mockClear()
|
||||
store.exitBuilder()
|
||||
await nextTick()
|
||||
|
||||
expect(mockSettings.set).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('read only canvas sync', () => {
|
||||
it('keeps canvas read-only while in select mode', async () => {
|
||||
mockCanvas.state = reactive({ readOnly: false })
|
||||
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
|
||||
|
||||
store.enterBuilder()
|
||||
await nextTick()
|
||||
mockCanvas.state.readOnly = false
|
||||
await nextTick()
|
||||
|
||||
expect(mockCanvas.state.readOnly).toBe(true)
|
||||
})
|
||||
|
||||
it('stops enforcing read-only after leaving select mode', async () => {
|
||||
mockCanvas.state = reactive({ readOnly: false })
|
||||
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
|
||||
|
||||
store.enterBuilder()
|
||||
await nextTick()
|
||||
store.exitBuilder()
|
||||
await nextTick()
|
||||
mockCanvas.state.readOnly = false
|
||||
await nextTick()
|
||||
|
||||
expect(mockCanvas.state.readOnly).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('legacy selectedInput tuple migration', () => {
|
||||
@@ -1085,121 +907,6 @@ describe('appModeStore', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('drops direct root-node widgets that cannot produce an entity id', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const sourceNodeId = 42
|
||||
const sourceWidgetName = 'text'
|
||||
const rootNode = fromAny<LGraphNode, unknown>({
|
||||
id: sourceNodeId,
|
||||
widgets: [{ name: sourceWidgetName }]
|
||||
})
|
||||
vi.mocked(app.rootGraph).id = rootGraphId
|
||||
vi.mocked(app.rootGraph).nodes = [rootNode]
|
||||
vi.mocked(app.rootGraph).getNodeById = vi.fn(
|
||||
(id: SerializedNodeId | null | undefined) =>
|
||||
id == sourceNodeId ? rootNode : null
|
||||
)
|
||||
|
||||
const result = store.pruneLinearData({
|
||||
inputs: [[sourceNodeId, sourceWidgetName, { height: 120 }]],
|
||||
outputs: []
|
||||
})
|
||||
|
||||
expect(result.inputs).toEqual([])
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('legacy selectedInput tuple'),
|
||||
expect.objectContaining({
|
||||
storedId: sourceNodeId,
|
||||
widgetName: sourceWidgetName
|
||||
})
|
||||
)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('drops promoted inputs whose source target no longer matches', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const subgraphInputName = 'Prompt'
|
||||
const sourceWidgetName = 'text'
|
||||
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: subgraphInputName, type: 'STRING' }]
|
||||
})
|
||||
const interior = new LGraphNodeClass('Interior')
|
||||
const interiorInput = interior.addInput(subgraphInputName, 'STRING')
|
||||
interior.addWidget('string', sourceWidgetName, '', () => undefined)
|
||||
interiorInput.widget = { name: sourceWidgetName }
|
||||
subgraph.add(interior)
|
||||
subgraph.inputNode.slots[0].connect(interiorInput, interior)
|
||||
|
||||
const host = createTestSubgraphNode(subgraph, { id: 5 })
|
||||
const rootGraph = host.graph as LGraph
|
||||
rootGraph.add(host)
|
||||
host._internalConfigureAfterSlots()
|
||||
|
||||
vi.mocked(app.rootGraph).id = rootGraph.id
|
||||
vi.mocked(app.rootGraph).nodes = rootGraph.nodes
|
||||
vi.mocked(app.rootGraph).getNodeById = vi.fn((id) =>
|
||||
rootGraph.getNodeById(id)
|
||||
)
|
||||
|
||||
const result = store.pruneLinearData({
|
||||
inputs: [[interior.id, 'other-widget', { height: 120 }]],
|
||||
outputs: []
|
||||
})
|
||||
|
||||
expect(result.inputs).toEqual([])
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('drops legacy inputs when multiple promoted inputs match', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const subgraphInputName = 'Prompt'
|
||||
const sourceWidgetName = 'text'
|
||||
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: subgraphInputName, type: 'STRING' }]
|
||||
})
|
||||
const interior = new LGraphNodeClass('Interior')
|
||||
const interiorInput = interior.addInput(subgraphInputName, 'STRING')
|
||||
interior.addWidget('string', sourceWidgetName, '', () => undefined)
|
||||
interiorInput.widget = { name: sourceWidgetName }
|
||||
subgraph.add(interior)
|
||||
subgraph.inputNode.slots[0].connect(interiorInput, interior)
|
||||
|
||||
const firstHost = createTestSubgraphNode(subgraph, { id: 5 })
|
||||
const rootGraph = firstHost.graph as LGraph
|
||||
const secondHost = createTestSubgraphNode(subgraph, {
|
||||
id: 6,
|
||||
parentGraph: rootGraph
|
||||
})
|
||||
rootGraph.add(firstHost)
|
||||
rootGraph.add(secondHost)
|
||||
firstHost._internalConfigureAfterSlots()
|
||||
secondHost._internalConfigureAfterSlots()
|
||||
|
||||
vi.mocked(app.rootGraph).id = rootGraph.id
|
||||
vi.mocked(app.rootGraph).nodes = rootGraph.nodes
|
||||
vi.mocked(app.rootGraph).getNodeById = vi.fn((id) =>
|
||||
rootGraph.getNodeById(id)
|
||||
)
|
||||
|
||||
const result = store.pruneLinearData({
|
||||
inputs: [[interior.id, sourceWidgetName, { height: 120 }]],
|
||||
outputs: []
|
||||
})
|
||||
|
||||
expect(result.inputs).toEqual([])
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('ambiguous legacy selectedInput tuple'),
|
||||
expect.objectContaining({
|
||||
storedId: interior.id,
|
||||
widgetName: sourceWidgetName
|
||||
})
|
||||
)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('warns and drops a tuple whose target widget no longer resolves', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
vi.mocked(app.rootGraph).id = rootGraphId
|
||||
|
||||
@@ -90,7 +90,6 @@ vi.mock('firebase/auth', async (importOriginal) => {
|
||||
onAuthStateChanged: vi.fn(),
|
||||
onIdTokenChanged: vi.fn(),
|
||||
signInWithPopup: vi.fn(),
|
||||
sendPasswordResetEmail: vi.fn(),
|
||||
GoogleAuthProvider: class {
|
||||
addScope = vi.fn()
|
||||
setCustomParameters = vi.fn()
|
||||
@@ -100,8 +99,7 @@ vi.mock('firebase/auth', async (importOriginal) => {
|
||||
setCustomParameters = vi.fn()
|
||||
},
|
||||
getAdditionalUserInfo: vi.fn(),
|
||||
setPersistence: vi.fn().mockResolvedValue(undefined),
|
||||
updatePassword: vi.fn()
|
||||
setPersistence: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -129,18 +127,6 @@ vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const mockWorkspaceAuthStore = vi.hoisted(() => ({
|
||||
unifiedToken: null as string | null,
|
||||
clearWorkspaceContext: vi.fn(),
|
||||
mintAtLogin: vi.fn(),
|
||||
getWorkspaceAuthHeader: vi.fn(),
|
||||
getWorkspaceToken: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/workspaceAuthStore', () => ({
|
||||
useWorkspaceAuthStore: () => mockWorkspaceAuthStore
|
||||
}))
|
||||
|
||||
// Mock apiKeyAuthStore
|
||||
const mockApiKeyGetAuthHeader = vi.fn().mockReturnValue(null)
|
||||
vi.mock('@/stores/apiKeyAuthStore', () => ({
|
||||
@@ -177,9 +163,6 @@ describe('useAuthStore', () => {
|
||||
|
||||
mockFeatureFlags.teamWorkspacesEnabled = false
|
||||
mockFeatureFlags.unifiedCloudAuthEnabled = false
|
||||
mockWorkspaceAuthStore.unifiedToken = null
|
||||
mockWorkspaceAuthStore.getWorkspaceAuthHeader.mockReturnValue(null)
|
||||
mockWorkspaceAuthStore.getWorkspaceToken.mockReturnValue(undefined)
|
||||
|
||||
// Setup dialog service mock
|
||||
vi.mocked(useDialogService, { partial: true }).mockReturnValue({
|
||||
@@ -292,11 +275,6 @@ describe('useAuthStore', () => {
|
||||
store.notifyTokenRefreshed()
|
||||
expect(store.tokenRefreshTrigger).toBe(1)
|
||||
})
|
||||
|
||||
it('ignores null ID token events', () => {
|
||||
idTokenCallback?.(null)
|
||||
expect(store.tokenRefreshTrigger).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('should initialize with the current user', () => {
|
||||
@@ -314,24 +292,6 @@ describe('useAuthStore', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('mints workspace auth on cloud login and clears it on logout state', () => {
|
||||
expect(mockWorkspaceAuthStore.mintAtLogin).toHaveBeenCalledOnce()
|
||||
|
||||
authStateCallback(null)
|
||||
|
||||
expect(mockWorkspaceAuthStore.clearWorkspaceContext).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not mint workspace auth outside cloud', () => {
|
||||
mockWorkspaceAuthStore.mintAtLogin.mockClear()
|
||||
mockDistributionTypes.isCloud = false
|
||||
|
||||
authStateCallback(mockUser)
|
||||
|
||||
expect(mockWorkspaceAuthStore.mintAtLogin).not.toHaveBeenCalled()
|
||||
mockDistributionTypes.isCloud = true
|
||||
})
|
||||
|
||||
it('should properly clean up error state between operations', async () => {
|
||||
// First, cause an error
|
||||
const mockError = new Error('Invalid password')
|
||||
@@ -389,30 +349,6 @@ describe('useAuthStore', () => {
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('tracks login when Firebase returns no email', async () => {
|
||||
const userWithoutEmail = { ...mockUser, email: null }
|
||||
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue({
|
||||
user: userWithoutEmail
|
||||
} as Partial<UserCredential> as UserCredential)
|
||||
|
||||
await store.login('test@example.com', 'password')
|
||||
|
||||
expect(mockTrackAuth).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ email: undefined })
|
||||
)
|
||||
})
|
||||
|
||||
it('fails customer creation when the signed-in user has no token yet', async () => {
|
||||
authStateCallback(null)
|
||||
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue({
|
||||
user: mockUser
|
||||
} as Partial<UserCredential> as UserCredential)
|
||||
|
||||
await expect(store.login('test@example.com', 'password')).rejects.toThrow(
|
||||
'Cannot create customer: User not authenticated'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle concurrent login attempts correctly', async () => {
|
||||
// Set up multiple login promises
|
||||
const mockUserCredential = { user: mockUser }
|
||||
@@ -550,19 +486,6 @@ describe('useAuthStore', () => {
|
||||
).rejects.toThrow()
|
||||
expect(mockUser.delete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('tracks registration when Firebase returns no email', async () => {
|
||||
const userWithoutEmail = { ...mockUser, email: null }
|
||||
vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockResolvedValue({
|
||||
user: userWithoutEmail
|
||||
} as Partial<UserCredential> as UserCredential)
|
||||
|
||||
await store.register('new@example.com', 'password')
|
||||
|
||||
expect(mockTrackAuth).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ email: undefined })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('logout', () => {
|
||||
@@ -696,54 +619,6 @@ describe('useAuthStore', () => {
|
||||
const authHeader = await store.getAuthHeader()
|
||||
expect(authHeader).toBeNull() // Should fallback gracefully
|
||||
})
|
||||
|
||||
it('uses the unified cloud token when enabled', async () => {
|
||||
mockFeatureFlags.unifiedCloudAuthEnabled = true
|
||||
mockWorkspaceAuthStore.unifiedToken = 'unified-token'
|
||||
|
||||
await expect(store.getAuthHeader()).resolves.toEqual({
|
||||
Authorization: 'Bearer unified-token'
|
||||
})
|
||||
await expect(store.getAuthToken()).resolves.toBe('unified-token')
|
||||
})
|
||||
|
||||
it('returns no unified auth when the unified token is missing', async () => {
|
||||
mockFeatureFlags.unifiedCloudAuthEnabled = true
|
||||
mockWorkspaceAuthStore.unifiedToken = null
|
||||
|
||||
await expect(store.getAuthHeader()).resolves.toBeNull()
|
||||
await expect(store.getAuthToken()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('prefers workspace auth when team workspaces are enabled', async () => {
|
||||
mockFeatureFlags.teamWorkspacesEnabled = true
|
||||
mockWorkspaceAuthStore.getWorkspaceAuthHeader.mockReturnValue({
|
||||
Authorization: 'Bearer workspace-header'
|
||||
})
|
||||
mockWorkspaceAuthStore.getWorkspaceToken.mockReturnValue(
|
||||
'workspace-token'
|
||||
)
|
||||
|
||||
await expect(store.getAuthHeader()).resolves.toEqual({
|
||||
Authorization: 'Bearer workspace-header'
|
||||
})
|
||||
await expect(store.getAuthToken()).resolves.toBe('workspace-token')
|
||||
})
|
||||
|
||||
it('falls back to Firebase when workspace auth is unavailable', async () => {
|
||||
mockFeatureFlags.teamWorkspacesEnabled = true
|
||||
mockWorkspaceAuthStore.getWorkspaceAuthHeader.mockReturnValue(null)
|
||||
mockWorkspaceAuthStore.getWorkspaceToken.mockReturnValue(undefined)
|
||||
|
||||
await expect(store.getAuthHeader()).resolves.toEqual({
|
||||
Authorization: 'Bearer mock-id-token'
|
||||
})
|
||||
await expect(store.getAuthToken()).resolves.toBe('mock-id-token')
|
||||
})
|
||||
|
||||
it('returns the Firebase token by default', async () => {
|
||||
await expect(store.getAuthToken()).resolves.toBe('mock-id-token')
|
||||
})
|
||||
})
|
||||
|
||||
describe('social authentication', () => {
|
||||
@@ -929,22 +804,6 @@ describe('useAuthStore', () => {
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.for(['loginWithGoogle', 'loginWithGithub'] as const)(
|
||||
'%s should track undefined email when Firebase returns no email',
|
||||
async (method) => {
|
||||
const userWithoutEmail = { ...mockUser, email: null }
|
||||
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue({
|
||||
user: userWithoutEmail
|
||||
} as Partial<UserCredential> as UserCredential)
|
||||
|
||||
await store[method]()
|
||||
|
||||
expect(mockTrackAuth).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ email: undefined })
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1116,61 +975,6 @@ describe('useAuthStore', () => {
|
||||
|
||||
await expect(store.accessBillingPortal()).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('throws when no auth method is available', async () => {
|
||||
authStateCallback(null)
|
||||
mockApiKeyGetAuthHeader.mockReturnValue(null)
|
||||
|
||||
await expect(store.accessBillingPortal()).rejects.toMatchObject({
|
||||
name: 'AuthStoreError',
|
||||
message: 'toastMessages.userNotAuthenticated'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchBalance', () => {
|
||||
it('stores the balance and update time when fetching succeeds', async () => {
|
||||
await expect(store.fetchBalance()).resolves.toEqual({ balance: 0 })
|
||||
|
||||
expect(store.balance).toEqual({ balance: 0 })
|
||||
expect(store.lastBalanceUpdateTime).toBeInstanceOf(Date)
|
||||
expect(store.isFetchingBalance).toBe(false)
|
||||
})
|
||||
|
||||
it('throws when no auth method is available', async () => {
|
||||
authStateCallback(null)
|
||||
mockApiKeyGetAuthHeader.mockReturnValue(null)
|
||||
|
||||
await expect(store.fetchBalance()).rejects.toMatchObject({
|
||||
name: 'AuthStoreError',
|
||||
message: 'toastMessages.userNotAuthenticated'
|
||||
})
|
||||
expect(store.isFetchingBalance).toBe(false)
|
||||
})
|
||||
|
||||
it('returns null when the customer balance is missing', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404
|
||||
})
|
||||
|
||||
await expect(store.fetchBalance()).resolves.toBeNull()
|
||||
expect(store.balance).toBeNull()
|
||||
expect(store.isFetchingBalance).toBe(false)
|
||||
})
|
||||
|
||||
it('throws API errors when fetching balance fails', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: () => Promise.resolve({ message: 'Balance unavailable' })
|
||||
})
|
||||
|
||||
await expect(store.fetchBalance()).rejects.toThrow(
|
||||
'toastMessages.failedToFetchBalance'
|
||||
)
|
||||
expect(store.isFetchingBalance).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAuthHeaderOrThrow', () => {
|
||||
@@ -1258,117 +1062,5 @@ describe('useAuthStore', () => {
|
||||
expect(error).toBeInstanceOf(AuthStoreError)
|
||||
expect((error as AuthStoreError).status).toBe(422)
|
||||
})
|
||||
|
||||
it('throws when the response has no customer id', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({})
|
||||
})
|
||||
|
||||
await expect(store.createCustomer()).rejects.toThrow(
|
||||
'toastMessages.failedToCreateCustomer'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('password actions', () => {
|
||||
it('sends password reset emails', async () => {
|
||||
vi.mocked(firebaseAuth.sendPasswordResetEmail).mockResolvedValue()
|
||||
|
||||
await store.sendPasswordReset('test@example.com')
|
||||
|
||||
expect(firebaseAuth.sendPasswordResetEmail).toHaveBeenCalledWith(
|
||||
mockAuth,
|
||||
'test@example.com'
|
||||
)
|
||||
})
|
||||
|
||||
it('updates the current user password', async () => {
|
||||
vi.mocked(firebaseAuth.updatePassword).mockResolvedValue()
|
||||
|
||||
await store.updatePassword('new-password')
|
||||
|
||||
expect(firebaseAuth.updatePassword).toHaveBeenCalledWith(
|
||||
mockUser,
|
||||
'new-password'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when updating password without a user', async () => {
|
||||
authStateCallback(null)
|
||||
|
||||
await expect(store.updatePassword('new-password')).rejects.toMatchObject({
|
||||
name: 'AuthStoreError',
|
||||
message: 'toastMessages.userNotAuthenticated'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('initiateCreditPurchase', () => {
|
||||
it('creates the customer once before adding credits', async () => {
|
||||
mockFetch.mockImplementation((url: string) => {
|
||||
if (url.endsWith('/customers')) {
|
||||
return Promise.resolve(mockCreateCustomerResponse)
|
||||
}
|
||||
if (url.endsWith('/customers/credit')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ redirect_url: 'https://stripe.test' })
|
||||
})
|
||||
}
|
||||
return Promise.reject(new Error('Unexpected API call'))
|
||||
})
|
||||
|
||||
await store.initiateCreditPurchase({
|
||||
amount_micros: 10_000_000,
|
||||
currency: 'usd'
|
||||
})
|
||||
await store.initiateCreditPurchase({
|
||||
amount_micros: 10_000_000,
|
||||
currency: 'usd'
|
||||
})
|
||||
|
||||
const customerCalls = mockFetch.mock.calls.filter(([url]) =>
|
||||
String(url).endsWith('/customers')
|
||||
)
|
||||
expect(customerCalls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('throws when credit purchase fails', async () => {
|
||||
mockFetch.mockImplementation((url: string) => {
|
||||
if (url.endsWith('/customers')) {
|
||||
return Promise.resolve(mockCreateCustomerResponse)
|
||||
}
|
||||
if (url.endsWith('/customers/credit')) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
json: () => Promise.resolve({ message: 'Checkout unavailable' })
|
||||
})
|
||||
}
|
||||
return Promise.reject(new Error('Unexpected API call'))
|
||||
})
|
||||
|
||||
await expect(
|
||||
store.initiateCreditPurchase({
|
||||
amount_micros: 10_000_000,
|
||||
currency: 'usd'
|
||||
})
|
||||
).rejects.toThrow('toastMessages.failedToInitiateCreditPurchase')
|
||||
})
|
||||
|
||||
it('throws when no auth method is available', async () => {
|
||||
authStateCallback(null)
|
||||
mockApiKeyGetAuthHeader.mockReturnValue(null)
|
||||
|
||||
await expect(
|
||||
store.initiateCreditPurchase({
|
||||
amount_micros: 10_000_000,
|
||||
currency: 'usd'
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
name: 'AuthStoreError',
|
||||
message: 'toastMessages.userNotAuthenticated'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -93,17 +93,6 @@ describe('bootstrapStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not reload authenticated stores after bootstrap already ran', async () => {
|
||||
const store = useBootstrapStore()
|
||||
|
||||
await store.startStoreBootstrap()
|
||||
await store.startStoreBootstrap()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(store.isI18nReady).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cloud mode', () => {
|
||||
beforeEach(() => {
|
||||
mockDistributionTypes.isCloud = true
|
||||
|
||||
@@ -4,10 +4,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
const keybindingMock = vi.hoisted(() => ({
|
||||
value: null as null | { combo: { getKeySequences: () => string[] } }
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
useErrorHandling: () => ({
|
||||
wrapWithErrorHandlingAsync:
|
||||
@@ -25,13 +21,12 @@ vi.mock('@/composables/useErrorHandling', () => ({
|
||||
|
||||
vi.mock('@/platform/keybindings/keybindingStore', () => ({
|
||||
useKeybindingStore: () => ({
|
||||
getKeybindingByCommandId: () => keybindingMock.value
|
||||
getKeybindingByCommandId: () => null
|
||||
})
|
||||
}))
|
||||
|
||||
describe('commandStore', () => {
|
||||
beforeEach(() => {
|
||||
keybindingMock.value = null
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
@@ -169,16 +164,6 @@ describe('commandStore', () => {
|
||||
expect(store.getCommand('tip.fn')?.tooltip).toBe('Dynamic tip')
|
||||
})
|
||||
|
||||
it('resolves icon as function', () => {
|
||||
const store = useCommandStore()
|
||||
store.registerCommand({
|
||||
id: 'icon.fn',
|
||||
function: vi.fn(),
|
||||
icon: () => 'pi pi-bolt'
|
||||
})
|
||||
expect(store.getCommand('icon.fn')?.icon).toBe('pi pi-bolt')
|
||||
})
|
||||
|
||||
it('uses explicit menubarLabel over label', () => {
|
||||
const store = useCommandStore()
|
||||
store.registerCommand({
|
||||
@@ -199,16 +184,6 @@ describe('commandStore', () => {
|
||||
})
|
||||
expect(store.getCommand('mbl.default')?.menubarLabel).toBe('My Label')
|
||||
})
|
||||
|
||||
it('resolves menubarLabel as function', () => {
|
||||
const store = useCommandStore()
|
||||
store.registerCommand({
|
||||
id: 'mbl.fn',
|
||||
function: vi.fn(),
|
||||
menubarLabel: () => 'Dynamic menu'
|
||||
})
|
||||
expect(store.getCommand('mbl.fn')?.menubarLabel).toBe('Dynamic menu')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatKeySequence', () => {
|
||||
@@ -218,17 +193,5 @@ describe('commandStore', () => {
|
||||
const cmd = store.getCommand('no.kb')!
|
||||
expect(store.formatKeySequence(cmd)).toBe('')
|
||||
})
|
||||
|
||||
it('formats keybinding sequences', () => {
|
||||
const store = useCommandStore()
|
||||
keybindingMock.value = {
|
||||
combo: { getKeySequences: () => ['Control+A', 'Shift+B'] }
|
||||
}
|
||||
store.registerCommand({ id: 'with.kb', function: vi.fn() })
|
||||
|
||||
const cmd = store.getCommand('with.kb')!
|
||||
|
||||
expect(store.formatKeySequence(cmd)).toBe('Ctrl+A + Shift+B')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
@@ -141,110 +141,6 @@ describe('dialogStore', () => {
|
||||
})
|
||||
|
||||
describe('basic dialog operations', () => {
|
||||
it('generates a key when none is provided', () => {
|
||||
const store = useDialogStore()
|
||||
|
||||
const dialog = store.showDialog({ component: MockComponent })
|
||||
|
||||
expect(dialog.key).toMatch(/^dialog-/)
|
||||
expect(store.isDialogOpen(dialog.key)).toBe(true)
|
||||
})
|
||||
|
||||
it('evicts the first stack entry when the stack is full', () => {
|
||||
const store = useDialogStore()
|
||||
|
||||
for (let i = 0; i < 11; i++) {
|
||||
store.showDialog({
|
||||
key: `dialog-${i}`,
|
||||
component: MockComponent,
|
||||
priority: i
|
||||
})
|
||||
}
|
||||
|
||||
expect(store.dialogStack).toHaveLength(10)
|
||||
expect(store.isDialogOpen('dialog-9')).toBe(false)
|
||||
})
|
||||
|
||||
it('stores optional header and footer components and props', () => {
|
||||
const store = useDialogStore()
|
||||
|
||||
const dialog = store.showDialog({
|
||||
key: 'with-slots',
|
||||
component: MockComponent,
|
||||
headerComponent: MockComponent,
|
||||
footerComponent: MockComponent,
|
||||
headerProps: { title: 'Header' },
|
||||
footerProps: { action: 'Save' }
|
||||
})
|
||||
|
||||
expect(dialog.headerComponent).toBeDefined()
|
||||
expect(dialog.footerComponent).toBeDefined()
|
||||
expect(dialog.headerProps).toEqual({ title: 'Header' })
|
||||
expect(dialog.footerProps).toEqual({ action: 'Save' })
|
||||
})
|
||||
|
||||
it('runs dialog lifecycle handlers', () => {
|
||||
const store = useDialogStore()
|
||||
const onClose = vi.fn()
|
||||
const dialog = store.showDialog({
|
||||
key: 'lifecycle',
|
||||
component: MockComponent,
|
||||
dialogComponentProps: { onClose }
|
||||
})
|
||||
const props =
|
||||
dialog.dialogComponentProps as typeof dialog.dialogComponentProps & {
|
||||
onAfterHide: () => void
|
||||
onMaximize: () => void
|
||||
onUnmaximize: () => void
|
||||
pt: { root: { onMousedown: () => void } }
|
||||
}
|
||||
|
||||
props.onMaximize()
|
||||
expect(dialog.dialogComponentProps.maximized).toBe(true)
|
||||
|
||||
props.onUnmaximize()
|
||||
expect(dialog.dialogComponentProps.maximized).toBe(false)
|
||||
|
||||
props.pt.root.onMousedown()
|
||||
expect(store.activeKey).toBe('lifecycle')
|
||||
|
||||
props.onAfterHide()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
expect(store.isDialogOpen('lifecycle')).toBe(false)
|
||||
})
|
||||
|
||||
it('does nothing when rising or closing a missing dialog', () => {
|
||||
const store = useDialogStore()
|
||||
|
||||
store.riseDialog({ key: 'missing' })
|
||||
store.closeDialog({ key: 'missing' })
|
||||
|
||||
expect(store.dialogStack).toEqual([])
|
||||
expect(store.activeKey).toBeNull()
|
||||
})
|
||||
|
||||
it('closes the active dialog when no key is provided', () => {
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({ key: 'active', component: MockComponent })
|
||||
store.closeDialog()
|
||||
|
||||
expect(store.isDialogOpen('active')).toBe(false)
|
||||
expect(store.activeKey).toBeNull()
|
||||
})
|
||||
|
||||
it('disables escape closing for a non-closable active dialog', () => {
|
||||
const store = useDialogStore()
|
||||
|
||||
const dialog = store.showDialog({
|
||||
key: 'locked',
|
||||
component: MockComponent,
|
||||
dialogComponentProps: { closable: false }
|
||||
})
|
||||
|
||||
expect(dialog.dialogComponentProps.closeOnEscape).toBe(false)
|
||||
})
|
||||
|
||||
it('should show and close dialogs', () => {
|
||||
const store = useDialogStore()
|
||||
|
||||
@@ -312,86 +208,6 @@ describe('dialogStore', () => {
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('updates only content props when dialog component props are omitted', () => {
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'content-only',
|
||||
component: MockContentPropsComponent,
|
||||
props: { openingAction: null }
|
||||
})
|
||||
|
||||
expect(
|
||||
store.updateDialog({
|
||||
key: 'content-only',
|
||||
contentProps: { openingAction: 'open' }
|
||||
})
|
||||
).toBe(true)
|
||||
expect(store.dialogStack[0].contentProps.openingAction).toBe('open')
|
||||
})
|
||||
|
||||
it('updates only dialog component props when content props are omitted', () => {
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'dialog-props-only',
|
||||
component: MockContentPropsComponent,
|
||||
dialogComponentProps: { dismissableMask: true }
|
||||
})
|
||||
|
||||
expect(
|
||||
store.updateDialog({
|
||||
key: 'dialog-props-only',
|
||||
dialogComponentProps: { dismissableMask: false }
|
||||
})
|
||||
).toBe(true)
|
||||
expect(store.dialogStack[0].dialogComponentProps.dismissableMask).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('returns false when updating a missing dialog', () => {
|
||||
const store = useDialogStore()
|
||||
|
||||
expect(
|
||||
store.updateDialog({
|
||||
key: 'missing',
|
||||
contentProps: { openingAction: 'open' }
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('creates and reuses extension dialogs with extension-prefixed keys', () => {
|
||||
const store = useDialogStore()
|
||||
|
||||
const first = store.showExtensionDialog({
|
||||
key: 'external',
|
||||
component: MockComponent
|
||||
})
|
||||
const second = store.showExtensionDialog({
|
||||
key: 'extension-external',
|
||||
component: MockComponent
|
||||
})
|
||||
|
||||
expect(first?.key).toBe('extension-external')
|
||||
expect(second?.key).toBe(first?.key)
|
||||
expect(store.dialogStack).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rejects extension dialogs without keys', () => {
|
||||
const store = useDialogStore()
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const dialog = store.showExtensionDialog({
|
||||
key: '',
|
||||
component: MockComponent
|
||||
})
|
||||
|
||||
expect(dialog).toBeUndefined()
|
||||
expect(error).toHaveBeenCalledWith('Extension dialog key is required')
|
||||
error.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ESC key behavior with multiple dialogs', () => {
|
||||
|
||||
@@ -112,36 +112,6 @@ describe('domWidgetStore', () => {
|
||||
store.activateWidget('non-existent')
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should ignore deactivating non-existent widgets', () => {
|
||||
store.deactivateWidget('non-existent')
|
||||
|
||||
expect(store.widgetStates.size).toBe(0)
|
||||
})
|
||||
|
||||
it('should replace registered widgets', () => {
|
||||
const widget = createMockDOMWidget('widget-1')
|
||||
const replacement = {
|
||||
...createMockDOMWidget('widget-1'),
|
||||
value: 'replacement'
|
||||
}
|
||||
store.registerWidget(widget)
|
||||
store.deactivateWidget('widget-1')
|
||||
|
||||
store.setWidget(replacement)
|
||||
|
||||
const state = store.widgetStates.get('widget-1')
|
||||
expect(state?.widget.value).toBe('replacement')
|
||||
expect(state?.active).toBe(true)
|
||||
})
|
||||
|
||||
it('should ignore missing widgets when replacing', () => {
|
||||
const widget = createMockDOMWidget('widget-1')
|
||||
|
||||
store.setWidget(widget)
|
||||
|
||||
expect(store.widgetStates.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computed states', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
|
||||
import { releaseSharedObjectUrl } from '@/utils/objectUrlUtil'
|
||||
@@ -71,14 +71,6 @@ describe('jobPreviewStore', () => {
|
||||
expect(store.previewsByPromptId).toEqual({ p2: 'blob:b' })
|
||||
})
|
||||
|
||||
it('ignores clearPreview without a prompt id', () => {
|
||||
const store = useJobPreviewStore()
|
||||
|
||||
store.clearPreview(undefined)
|
||||
|
||||
expect(store.nodePreviewsByPromptId).toEqual({})
|
||||
})
|
||||
|
||||
it('clears all previews', () => {
|
||||
const store = useJobPreviewStore()
|
||||
store.setPreviewUrl('p1', 'blob:a', 'node-1')
|
||||
@@ -99,24 +91,6 @@ describe('jobPreviewStore', () => {
|
||||
expect(releaseSharedObjectUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores missing prompt ids', () => {
|
||||
const store = useJobPreviewStore()
|
||||
|
||||
store.setPreviewUrl(undefined, 'blob:a', 'node-1')
|
||||
|
||||
expect(store.nodePreviewsByPromptId).toEqual({})
|
||||
})
|
||||
|
||||
it('releases the old url when replacing a preview', () => {
|
||||
const store = useJobPreviewStore()
|
||||
store.setPreviewUrl('p1', 'blob:a', 'node-1')
|
||||
|
||||
store.setPreviewUrl('p1', 'blob:b', 'node-1')
|
||||
|
||||
expect(releaseSharedObjectUrl).toHaveBeenCalledWith('blob:a')
|
||||
expect(store.nodePreviewsByPromptId['p1']?.url).toBe('blob:b')
|
||||
})
|
||||
|
||||
it('ignores setPreviewUrl when previews are disabled', () => {
|
||||
previewMethodRef.value = 'none'
|
||||
const store = useJobPreviewStore()
|
||||
@@ -125,15 +99,4 @@ describe('jobPreviewStore', () => {
|
||||
|
||||
expect(store.nodePreviewsByPromptId).toEqual({})
|
||||
})
|
||||
|
||||
it('clears previews when previews are disabled after storage', async () => {
|
||||
const store = useJobPreviewStore()
|
||||
store.setPreviewUrl('p1', 'blob:a', 'node-1')
|
||||
|
||||
previewMethodRef.value = 'none'
|
||||
await nextTick()
|
||||
|
||||
expect(store.nodePreviewsByPromptId).toEqual({})
|
||||
expect(releaseSharedObjectUrl).toHaveBeenCalledWith('blob:a')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useMenuItemStore } from '@/stores/menuItemStore'
|
||||
|
||||
const canvasStoreMock = vi.hoisted(() => ({ linearMode: false }))
|
||||
|
||||
vi.mock('@/constants/coreMenuCommands', () => ({
|
||||
CORE_MENU_COMMANDS: [[['Core'], ['core.command']]]
|
||||
}))
|
||||
|
||||
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
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => canvasStoreMock
|
||||
}))
|
||||
|
||||
describe('menuItemStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
canvasStoreMock.linearMode = false
|
||||
})
|
||||
|
||||
it('records that linear mode has been seen', () => {
|
||||
canvasStoreMock.linearMode = true
|
||||
|
||||
const store = useMenuItemStore()
|
||||
|
||||
expect(store.hasSeenLinear).toBe(true)
|
||||
})
|
||||
|
||||
it('creates nested groups, separators, and active-state metadata', () => {
|
||||
const store = useMenuItemStore()
|
||||
const activeItem: MenuItem = {
|
||||
label: 'Active',
|
||||
comfyCommand: { id: 'active', function: vi.fn(), active: () => true }
|
||||
}
|
||||
const plainItem: MenuItem = { label: 'Plain' }
|
||||
|
||||
store.registerMenuGroup(['File', 'Export'], [activeItem])
|
||||
store.registerMenuGroup(['File', 'Export'], [plainItem])
|
||||
|
||||
const file = store.menuItems[0]
|
||||
const exportGroup = file.items?.[0]
|
||||
|
||||
expect(file.label).toBe('File')
|
||||
expect(exportGroup?.items).toEqual([
|
||||
activeItem,
|
||||
{ separator: true },
|
||||
plainItem
|
||||
])
|
||||
expect(store.menuItemHasActiveStateChildren['File.Export']).toBe(true)
|
||||
})
|
||||
|
||||
it('repairs existing group items before appending children', () => {
|
||||
const store = useMenuItemStore()
|
||||
store.menuItems.push({ label: 'Tools' })
|
||||
|
||||
store.registerMenuGroup(['Tools'], [{ label: 'Child' }])
|
||||
|
||||
expect(store.menuItems[0].items).toEqual([{ label: 'Child' }])
|
||||
})
|
||||
|
||||
it('maps command ids to executable menu items', async () => {
|
||||
const commandStore = useCommandStore()
|
||||
const fn = vi.fn()
|
||||
commandStore.registerCommand({
|
||||
id: 'test.command',
|
||||
function: fn,
|
||||
icon: 'icon-[lucide--test]',
|
||||
label: 'Label',
|
||||
menubarLabel: 'Menu Label',
|
||||
tooltip: 'Tip'
|
||||
})
|
||||
|
||||
const store = useMenuItemStore()
|
||||
const item = store.commandIdToMenuItem('test.command', ['Tools'])
|
||||
await item.command?.({ originalEvent: new Event('click'), item })
|
||||
|
||||
expect(fn).toHaveBeenCalled()
|
||||
expect(item).toMatchObject({
|
||||
label: 'Menu Label',
|
||||
icon: 'icon-[lucide--test]',
|
||||
tooltip: 'Tip',
|
||||
parentPath: 'Tools'
|
||||
})
|
||||
})
|
||||
|
||||
it('loads extension menu commands only for commands owned by the extension', () => {
|
||||
const commandStore = useCommandStore()
|
||||
commandStore.registerCommand({
|
||||
id: 'owned',
|
||||
function: vi.fn(),
|
||||
menubarLabel: 'Owned'
|
||||
})
|
||||
|
||||
const store = useMenuItemStore()
|
||||
store.loadExtensionMenuCommands({
|
||||
name: 'extension',
|
||||
commands: [{ id: 'owned', function: vi.fn() }],
|
||||
menuCommands: [{ path: ['Tools'], commands: ['owned', 'external'] }]
|
||||
})
|
||||
store.loadExtensionMenuCommands({ name: 'plain' })
|
||||
store.loadExtensionMenuCommands({
|
||||
name: 'empty',
|
||||
menuCommands: [{ path: ['Tools'], commands: ['missing'] }]
|
||||
})
|
||||
|
||||
expect(store.menuItems[0].items?.map((item) => item.label)).toEqual([
|
||||
'Owned'
|
||||
])
|
||||
})
|
||||
|
||||
it('registers core menu commands', () => {
|
||||
const commandStore = useCommandStore()
|
||||
commandStore.registerCommand({
|
||||
id: 'core.command',
|
||||
function: vi.fn(),
|
||||
menubarLabel: 'Core Command'
|
||||
})
|
||||
|
||||
const store = useMenuItemStore()
|
||||
store.registerCoreMenuCommands()
|
||||
|
||||
expect(store.menuItems[0].items?.[0].label).toBe('Core Command')
|
||||
})
|
||||
})
|
||||
@@ -69,7 +69,9 @@ export class ComfyModelDef {
|
||||
this.path_index = pathIndex
|
||||
this.file_name = name
|
||||
this.normalized_file_name = name.replaceAll('\\', '/')
|
||||
this.simplified_file_name = this.normalized_file_name.split('/').pop() ?? ''
|
||||
this.simplified_file_name = this.normalized_file_name.slice(
|
||||
this.normalized_file_name.lastIndexOf('/') + 1
|
||||
)
|
||||
if (this.simplified_file_name.endsWith('.safetensors')) {
|
||||
this.simplified_file_name = this.simplified_file_name.slice(
|
||||
0,
|
||||
|
||||
256
src/stores/nodeBookmarkStore.test.ts
Normal file
256
src/stores/nodeBookmarkStore.test.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
const BOOKMARK_ID = 'Comfy.NodeLibrary.Bookmarks.V2'
|
||||
const CUSTOMIZATION_ID = 'Comfy.NodeLibrary.BookmarksCustomization'
|
||||
|
||||
const { settings, setSpy, nodeDefs } = vi.hoisted(() => ({
|
||||
settings: {} as Record<string, unknown>,
|
||||
setSpy: vi.fn(),
|
||||
nodeDefs: {} as Record<string, unknown>
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', async () => {
|
||||
const { reactive } = await import('vue')
|
||||
const reactiveSettings = reactive(settings)
|
||||
setSpy.mockImplementation(async (id: string, value: unknown) => {
|
||||
reactiveSettings[id] = value
|
||||
})
|
||||
return {
|
||||
useSettingStore: () => ({
|
||||
get: (id: string) => reactiveSettings[id],
|
||||
set: setSpy
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: () => ({ allNodeDefsByName: nodeDefs }),
|
||||
buildNodeDefTree: (defs: unknown[]) => ({ key: 'root', children: defs }),
|
||||
createDummyFolderNodeDef: (path: string) => ({
|
||||
isDummyFolder: true,
|
||||
nodePath: path,
|
||||
name: path
|
||||
})
|
||||
}))
|
||||
|
||||
type BookmarkNodeFixture = Pick<
|
||||
ComfyNodeDefImpl,
|
||||
'isDummyFolder' | 'nodePath' | 'category' | 'name'
|
||||
>
|
||||
|
||||
function folderNode(nodePath: string) {
|
||||
const node = {
|
||||
isDummyFolder: true,
|
||||
nodePath,
|
||||
category: nodePath.replace(/\/$/, ''),
|
||||
name: nodePath
|
||||
} satisfies BookmarkNodeFixture
|
||||
return node as ComfyNodeDefImpl
|
||||
}
|
||||
|
||||
function leafNode(name: string, nodePath = name) {
|
||||
const node = {
|
||||
isDummyFolder: false,
|
||||
name,
|
||||
nodePath,
|
||||
category: ''
|
||||
} satisfies BookmarkNodeFixture
|
||||
return node as ComfyNodeDefImpl
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
for (const key of Object.keys(settings)) delete settings[key]
|
||||
for (const key of Object.keys(nodeDefs)) delete nodeDefs[key]
|
||||
settings[BOOKMARK_ID] = []
|
||||
settings[CUSTOMIZATION_ID] = {}
|
||||
setSpy.mockClear()
|
||||
})
|
||||
|
||||
describe('nodeBookmarkStore', () => {
|
||||
it('reports isBookmarked by either nodePath or top-level name', () => {
|
||||
settings[BOOKMARK_ID] = ['sampling/KSampler', 'LoadImage']
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
expect(store.isBookmarked(leafNode('KSampler', 'sampling/KSampler'))).toBe(
|
||||
true
|
||||
)
|
||||
expect(store.isBookmarked(leafNode('LoadImage'))).toBe(true)
|
||||
expect(store.isBookmarked(leafNode('VAEDecode'))).toBe(false)
|
||||
})
|
||||
|
||||
it('adds a bookmark by appending to the current list', async () => {
|
||||
settings[BOOKMARK_ID] = ['A']
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await store.addBookmark('B')
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, ['A', 'B'])
|
||||
})
|
||||
|
||||
it('toggles an un-bookmarked node by adding its name', async () => {
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await store.toggleBookmark(leafNode('KSampler'))
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, ['KSampler'])
|
||||
})
|
||||
|
||||
it('toggles a bookmarked node by deleting both nodePath and name', async () => {
|
||||
settings[BOOKMARK_ID] = ['sampling/KSampler', 'KSampler']
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await store.toggleBookmark(leafNode('KSampler', 'sampling/KSampler'))
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, ['KSampler'])
|
||||
expect(setSpy).toHaveBeenLastCalledWith(BOOKMARK_ID, [])
|
||||
expect(store.bookmarks).toEqual([])
|
||||
})
|
||||
|
||||
it('creates a folder under a parent and at the root', async () => {
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
const rootPath = await store.addNewBookmarkFolder(undefined, 'Favorites')
|
||||
expect(rootPath).toBe('Favorites/')
|
||||
|
||||
const childPath = await store.addNewBookmarkFolder(
|
||||
folderNode('Favorites/'),
|
||||
'Nested'
|
||||
)
|
||||
expect(childPath).toBe('Favorites/Nested/')
|
||||
})
|
||||
|
||||
it('parses each bookmark into its parent category, dropping unknown node defs', () => {
|
||||
nodeDefs['LoadImage'] = leafNode('LoadImage')
|
||||
nodeDefs['KSampler'] = leafNode('KSampler')
|
||||
nodeDefs['Canny'] = leafNode('Canny')
|
||||
settings[BOOKMARK_ID] = [
|
||||
'LoadImage',
|
||||
'sampling/KSampler',
|
||||
'image/preprocessors/Canny',
|
||||
'sampling/Unknown',
|
||||
'Folder/'
|
||||
]
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
const children = (
|
||||
store.bookmarkedRoot as unknown as { children: BookmarkNodeFixture[] }
|
||||
).children
|
||||
|
||||
expect(
|
||||
children.map((node) =>
|
||||
node.isDummyFolder ? node.nodePath : [node.name, node.category]
|
||||
)
|
||||
).toEqual([
|
||||
['LoadImage', ''],
|
||||
['KSampler', 'sampling'],
|
||||
['Canny', 'image/preprocessors'],
|
||||
'Folder/'
|
||||
])
|
||||
})
|
||||
|
||||
describe('renameBookmarkFolder', () => {
|
||||
it('rejects renaming a non-folder node', async () => {
|
||||
const store = useNodeBookmarkStore()
|
||||
await expect(
|
||||
store.renameBookmarkFolder(leafNode('KSampler'), 'New')
|
||||
).rejects.toThrow('Cannot rename non-folder node')
|
||||
})
|
||||
|
||||
it('rejects a name containing a slash', async () => {
|
||||
const store = useNodeBookmarkStore()
|
||||
await expect(
|
||||
store.renameBookmarkFolder(folderNode('Old/'), 'a/b')
|
||||
).rejects.toThrow('cannot contain')
|
||||
})
|
||||
|
||||
it('rejects a rename that collides with an existing folder', async () => {
|
||||
settings[BOOKMARK_ID] = ['Taken/']
|
||||
const store = useNodeBookmarkStore()
|
||||
await expect(
|
||||
store.renameBookmarkFolder(folderNode('Old/'), 'Taken')
|
||||
).rejects.toThrow('already exists')
|
||||
})
|
||||
|
||||
it('rewrites matching bookmark paths on a valid rename', async () => {
|
||||
settings[BOOKMARK_ID] = ['Old/', 'Old/KSampler', 'Other/Node']
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await store.renameBookmarkFolder(folderNode('Old/'), 'New')
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, [
|
||||
'New/',
|
||||
'New/KSampler',
|
||||
'Other/Node'
|
||||
])
|
||||
})
|
||||
|
||||
it('does nothing when the folder keeps the same path', async () => {
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await store.renameBookmarkFolder(folderNode('Old/'), 'Old')
|
||||
|
||||
expect(setSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes a folder and all its descendants', async () => {
|
||||
settings[BOOKMARK_ID] = ['Old/', 'Old/KSampler', 'Keep/Node']
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await store.deleteBookmarkFolder(folderNode('Old/'))
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, ['Keep/Node'])
|
||||
})
|
||||
|
||||
it('rejects deleting a non-folder node', async () => {
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await expect(
|
||||
store.deleteBookmarkFolder(leafNode('KSampler'))
|
||||
).rejects.toThrow('Cannot delete non-folder node')
|
||||
})
|
||||
|
||||
describe('updateBookmarkCustomization', () => {
|
||||
it('persists a non-default customization', async () => {
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await store.updateBookmarkCustomization('Folder/', {
|
||||
color: '#ff0000',
|
||||
icon: 'pi-star'
|
||||
})
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(CUSTOMIZATION_ID, {
|
||||
'Folder/': { color: '#ff0000', icon: 'pi-star' }
|
||||
})
|
||||
})
|
||||
|
||||
it('drops attributes set to their default values', async () => {
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await store.updateBookmarkCustomization('Folder/', {
|
||||
color: store.defaultBookmarkColor,
|
||||
icon: store.defaultBookmarkIcon
|
||||
})
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(CUSTOMIZATION_ID, {
|
||||
'Folder/': undefined
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('renames a customization entry, moving the old key to the new one', async () => {
|
||||
settings[CUSTOMIZATION_ID] = { 'Old/': { color: '#abc' } }
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await store.renameBookmarkCustomization('Old/', 'New/')
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(CUSTOMIZATION_ID, {
|
||||
'New/': { color: '#abc' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -50,9 +50,9 @@ export const useNodeBookmarkStore = defineStore('nodeBookmark', () => {
|
||||
.map((bookmark: string) => {
|
||||
if (bookmark.endsWith('/')) return createDummyFolderNodeDef(bookmark)
|
||||
|
||||
const parts = bookmark.split('/')
|
||||
const name = parts.pop() ?? ''
|
||||
const category = parts.join('/')
|
||||
const slashIndex = bookmark.lastIndexOf('/')
|
||||
const name = bookmark.slice(slashIndex + 1)
|
||||
const category = bookmark.slice(0, Math.max(0, slashIndex))
|
||||
const srcNodeDef = nodeDefStore.allNodeDefsByName[name]
|
||||
if (!srcNodeDef) {
|
||||
return null
|
||||
|
||||
@@ -95,22 +95,6 @@ describe(usePreviewExposureStore, () => {
|
||||
|
||||
expect(store.getExposures(rootGraphA, hostA)).toEqual([])
|
||||
})
|
||||
|
||||
it('clears only the requested host when other hosts remain', () => {
|
||||
store.addExposure(rootGraphA, hostA, {
|
||||
sourceNodeId: '42',
|
||||
sourcePreviewName: 'preview'
|
||||
})
|
||||
store.addExposure(rootGraphA, hostB, {
|
||||
sourceNodeId: '43',
|
||||
sourcePreviewName: 'preview'
|
||||
})
|
||||
|
||||
store.setExposures(rootGraphA, hostA, [])
|
||||
|
||||
expect(store.getExposures(rootGraphA, hostA)).toEqual([])
|
||||
expect(store.getExposures(rootGraphA, hostB)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeExposure', () => {
|
||||
@@ -138,12 +122,6 @@ describe(usePreviewExposureStore, () => {
|
||||
store.removeExposure(rootGraphA, hostA, 'does-not-exist')
|
||||
expect(store.getExposures(rootGraphA, hostA)).toEqual(before)
|
||||
})
|
||||
|
||||
it('is a no-op for an unknown host', () => {
|
||||
store.removeExposure(rootGraphA, 'missing-host', 'preview')
|
||||
|
||||
expect(store.getExposures(rootGraphA, 'missing-host')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getExposuresAsPromotionShape', () => {
|
||||
|
||||
Reference in New Issue
Block a user