mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-03 13:48:49 +00:00
Compare commits
1 Commits
nathaniel/
...
shihchi/co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5d2a1aed1 |
26
src/stores/actionBarButtonStore.test.ts
Normal file
26
src/stores/actionBarButtonStore.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
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 } from 'vue'
|
||||
import { nextTick, reactive } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
@@ -56,9 +56,13 @@ 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: () => ({ read_only: false })
|
||||
getCanvas: () => ({ state: mockCanvas.state })
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -162,6 +166,7 @@ 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()
|
||||
@@ -365,6 +370,83 @@ 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]
|
||||
@@ -399,6 +481,32 @@ 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'])
|
||||
|
||||
@@ -481,7 +589,7 @@ describe('appModeStore', () => {
|
||||
expect(
|
||||
store.pruneLinearData({
|
||||
inputs: [[1, 'seed']],
|
||||
outputs: [toNodeId(1)]
|
||||
outputs: [toNodeId(1), fromAny<SerializedNodeId, unknown>('')]
|
||||
})
|
||||
).toEqual({
|
||||
inputs: [[1, 'seed']],
|
||||
@@ -641,6 +749,17 @@ 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
|
||||
@@ -755,6 +874,24 @@ 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', () => {
|
||||
@@ -819,6 +956,47 @@ 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', () => {
|
||||
@@ -907,6 +1085,121 @@ 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,6 +90,7 @@ 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()
|
||||
@@ -99,7 +100,8 @@ vi.mock('firebase/auth', async (importOriginal) => {
|
||||
setCustomParameters = vi.fn()
|
||||
},
|
||||
getAdditionalUserInfo: vi.fn(),
|
||||
setPersistence: vi.fn().mockResolvedValue(undefined)
|
||||
setPersistence: vi.fn().mockResolvedValue(undefined),
|
||||
updatePassword: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -127,6 +129,18 @@ 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', () => ({
|
||||
@@ -163,6 +177,9 @@ 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({
|
||||
@@ -275,6 +292,11 @@ 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', () => {
|
||||
@@ -292,6 +314,24 @@ 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')
|
||||
@@ -349,6 +389,30 @@ 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 }
|
||||
@@ -486,6 +550,19 @@ 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', () => {
|
||||
@@ -619,6 +696,54 @@ 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', () => {
|
||||
@@ -804,6 +929,22 @@ 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 })
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -975,6 +1116,61 @@ 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', () => {
|
||||
@@ -1062,5 +1258,117 @@ 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,6 +93,17 @@ 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,6 +4,10 @@ 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:
|
||||
@@ -21,12 +25,13 @@ vi.mock('@/composables/useErrorHandling', () => ({
|
||||
|
||||
vi.mock('@/platform/keybindings/keybindingStore', () => ({
|
||||
useKeybindingStore: () => ({
|
||||
getKeybindingByCommandId: () => null
|
||||
getKeybindingByCommandId: () => keybindingMock.value
|
||||
})
|
||||
}))
|
||||
|
||||
describe('commandStore', () => {
|
||||
beforeEach(() => {
|
||||
keybindingMock.value = null
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
@@ -164,6 +169,16 @@ 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({
|
||||
@@ -184,6 +199,16 @@ 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', () => {
|
||||
@@ -193,5 +218,17 @@ 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 } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
@@ -141,6 +141,110 @@ 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()
|
||||
|
||||
@@ -208,6 +312,86 @@ 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,6 +112,36 @@ 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 { ref } from 'vue'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
|
||||
import { releaseSharedObjectUrl } from '@/utils/objectUrlUtil'
|
||||
@@ -71,6 +71,14 @@ 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')
|
||||
@@ -91,6 +99,24 @@ 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()
|
||||
@@ -99,4 +125,15 @@ 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')
|
||||
})
|
||||
})
|
||||
|
||||
149
src/stores/menuItemStore.test.ts
Normal file
149
src/stores/menuItemStore.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -95,6 +95,22 @@ 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', () => {
|
||||
@@ -122,6 +138,12 @@ 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