Compare commits

...

1 Commits

Author SHA1 Message Date
huang47
08648920af test: cover session UI stores 2026-07-02 09:00:38 -07:00
12 changed files with 1541 additions and 68 deletions

View File

@@ -8,7 +8,16 @@ import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workfl
type ModifiedWorkflow = Pick<ComfyWorkflow, 'path' | 'isModified'>
const mockAuthStore = vi.hoisted(() => ({
logout: vi.fn().mockResolvedValue(undefined)
logout: vi.fn().mockResolvedValue(undefined),
sendPasswordReset: vi.fn().mockResolvedValue(undefined),
initiateCreditPurchase: vi.fn(),
accessBillingPortal: vi.fn(),
fetchBalance: vi.fn(),
loginWithGoogle: vi.fn(),
loginWithGithub: vi.fn(),
login: vi.fn(),
register: vi.fn(),
updatePassword: vi.fn().mockResolvedValue(undefined)
}))
const mockToastStore = vi.hoisted(() => ({
@@ -29,6 +38,16 @@ const mockDialogService = vi.hoisted(() => ({
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
const mockBillingContext = vi.hoisted(() => ({
isActiveSubscription: { value: false },
isFreeTier: { value: true },
type: { value: 'free' }
}))
const mockTelemetry = vi.hoisted(() => ({
startTopupTracking: vi.fn()
}))
const knownAuthErrorCodes = new Set([
'auth/invalid-credential',
'auth/email-already-in-use'
@@ -48,7 +67,7 @@ vi.mock('@/platform/distribution/types', () => ({
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => undefined)
useTelemetry: vi.fn(() => mockTelemetry)
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
@@ -72,11 +91,7 @@ vi.mock('@/stores/authStore', () => ({
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: vi.fn(() => ({
isActiveSubscription: { value: false },
isFreeTier: { value: true },
type: { value: 'free' }
}))
useBillingContext: vi.fn(() => mockBillingContext)
}))
vi.mock('@/composables/useErrorHandling', () => ({
@@ -97,6 +112,7 @@ describe('useAuthActions.logout', () => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockWorkflowStore.modifiedWorkflows = []
mockBillingContext.isActiveSubscription.value = false
})
it('logs out without prompting when no workflows are modified', async () => {
@@ -281,4 +297,158 @@ describe('useAuthActions.reportError', () => {
expect(mockToastErrorHandler).toHaveBeenCalledWith(networkError)
expect(mockToastStore.add).not.toHaveBeenCalled()
})
it('shows the unauthorized-domain access error message', () => {
const { reportError, accessError } = useAuthActions()
reportError(new FirebaseError('auth/unauthorized-domain', 'blocked'))
expect(accessError.value).toBe(true)
expect(mockToastStore.add).toHaveBeenCalledWith({
severity: 'error',
summary: 'g.error',
detail: 'toastMessages.unauthorizedDomain'
})
})
})
describe('useAuthActions account actions', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockBillingContext.isActiveSubscription.value = false
vi.stubGlobal(
'open',
vi.fn(() => ({}))
)
})
it('sends password reset emails and shows success toast', async () => {
const { sendPasswordReset } = useAuthActions()
await sendPasswordReset('user@example.com')
expect(mockAuthStore.sendPasswordReset).toHaveBeenCalledWith(
'user@example.com'
)
expect(mockToastStore.add).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'success',
summary: 'auth.login.passwordResetSent'
})
)
})
it('does not purchase credits without an active subscription', async () => {
const { purchaseCredits } = useAuthActions()
await purchaseCredits(25)
expect(mockAuthStore.initiateCreditPurchase).not.toHaveBeenCalled()
expect(window.open).not.toHaveBeenCalled()
})
it('opens checkout and tracks top-up starts for credit purchases', async () => {
mockBillingContext.isActiveSubscription.value = true
mockAuthStore.initiateCreditPurchase.mockResolvedValueOnce({
checkout_url: 'https://checkout.example.test'
})
const { purchaseCredits } = useAuthActions()
await purchaseCredits(25)
expect(mockAuthStore.initiateCreditPurchase).toHaveBeenCalledWith({
amount_micros: 25000000,
currency: 'usd'
})
expect(mockTelemetry.startTopupTracking).toHaveBeenCalledOnce()
expect(window.open).toHaveBeenCalledWith(
'https://checkout.example.test',
'_blank'
)
})
it('throws when credit checkout URL is missing', async () => {
mockBillingContext.isActiveSubscription.value = true
mockAuthStore.initiateCreditPurchase.mockResolvedValueOnce({})
const { purchaseCredits } = useAuthActions()
await expect(purchaseCredits(10)).rejects.toThrow(
'toastMessages.failedToPurchaseCredits'
)
})
it('opens the billing portal in a new tab by default', async () => {
mockAuthStore.accessBillingPortal.mockResolvedValueOnce({
billing_portal_url: 'https://billing.example.test'
})
const { accessBillingPortal } = useAuthActions()
await expect(accessBillingPortal('pro')).resolves.toBe(true)
expect(mockAuthStore.accessBillingPortal).toHaveBeenCalledWith('pro')
expect(window.open).toHaveBeenCalledWith(
'https://billing.example.test',
'_blank'
)
})
it('throws when billing portal URL is missing', async () => {
mockAuthStore.accessBillingPortal.mockResolvedValueOnce({})
const { accessBillingPortal } = useAuthActions()
await expect(accessBillingPortal()).rejects.toThrow(
'toastMessages.failedToAccessBillingPortal'
)
})
it('delegates balance and sign-in methods to the auth store', async () => {
mockAuthStore.fetchBalance.mockResolvedValueOnce({ balance: 12 })
mockAuthStore.loginWithGoogle.mockResolvedValueOnce('google')
mockAuthStore.loginWithGithub.mockResolvedValueOnce('github')
mockAuthStore.login.mockResolvedValueOnce('email')
mockAuthStore.register.mockResolvedValueOnce('registered')
const actions = useAuthActions()
await expect(actions.fetchBalance()).resolves.toEqual({ balance: 12 })
await expect(actions.signInWithGoogle({ isNewUser: true })).resolves.toBe(
'google'
)
await expect(actions.signInWithGithub({ isNewUser: false })).resolves.toBe(
'github'
)
await expect(actions.signInWithEmail('u@example.com', 'pw')).resolves.toBe(
'email'
)
await expect(
actions.signUpWithEmail('u@example.com', 'pw', 'turnstile')
).resolves.toBe('registered')
expect(mockAuthStore.loginWithGoogle).toHaveBeenCalledWith({
isNewUser: true
})
expect(mockAuthStore.loginWithGithub).toHaveBeenCalledWith({
isNewUser: false
})
expect(mockAuthStore.login).toHaveBeenCalledWith('u@example.com', 'pw')
expect(mockAuthStore.register).toHaveBeenCalledWith(
'u@example.com',
'pw',
'turnstile'
)
})
it('updates passwords and shows success toast', async () => {
const { updatePassword } = useAuthActions()
await updatePassword('new-password')
expect(mockAuthStore.updatePassword).toHaveBeenCalledWith('new-password')
expect(mockToastStore.add).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'success',
summary: 'auth.passwordUpdate.success'
})
)
})
})

View File

@@ -0,0 +1,213 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, reactive } from 'vue'
import type { User as FirebaseUser } from 'firebase/auth'
import type { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
type FirebaseUserMock = Pick<
FirebaseUser,
'uid' | 'displayName' | 'email' | 'photoURL'
> & {
providerData: Array<Pick<FirebaseUser['providerData'][number], 'providerId'>>
}
type ApiKeyUser = NonNullable<
ReturnType<typeof useApiKeyAuthStore>['currentUser']
>
const mockStores = vi.hoisted(() => ({
authStore: undefined as
| undefined
| {
currentUser: FirebaseUserMock | null
loading: boolean
tokenRefreshTrigger: number
},
apiKeyStore: undefined as
| undefined
| {
isAuthenticated: boolean
currentUser: ApiKeyUser | null
clearStoredApiKey: ReturnType<typeof vi.fn>
},
commandStore: undefined as
| undefined
| {
execute: ReturnType<typeof vi.fn>
}
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => mockStores.authStore
}))
vi.mock('@/stores/apiKeyAuthStore', () => ({
useApiKeyAuthStore: () => mockStores.apiKeyStore
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => mockStores.commandStore
}))
async function setup() {
vi.resetModules()
const authStore = reactive({
currentUser: null as FirebaseUserMock | null,
loading: false,
tokenRefreshTrigger: 0
})
const apiKeyStore = reactive({
isAuthenticated: false,
currentUser: null as ApiKeyUser | null,
clearStoredApiKey: vi.fn()
})
const commandStore = {
execute: vi.fn()
}
mockStores.authStore = authStore
mockStores.apiKeyStore = apiKeyStore
mockStores.commandStore = commandStore
const { useCurrentUser } = await import('./useCurrentUser')
return {
currentUser: useCurrentUser(),
authStore,
apiKeyStore,
commandStore
}
}
function firebaseUser(
providerId: string,
overrides: Partial<FirebaseUserMock> = {}
): FirebaseUserMock {
return {
uid: 'firebase-user',
displayName: 'Firebase User',
email: 'firebase@example.com',
photoURL: 'https://example.com/photo.png',
providerData: [{ providerId }],
...overrides
}
}
describe('useCurrentUser', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('reports logged-out state when no auth source is active', async () => {
const { currentUser } = await setup()
expect(currentUser.loading).toBe(false)
expect(currentUser.isLoggedIn.value).toBe(false)
expect(currentUser.resolvedUserInfo.value).toBeNull()
expect(currentUser.userDisplayName.value).toBeUndefined()
expect(currentUser.userEmail.value).toBeUndefined()
expect(currentUser.userPhotoUrl.value).toBeUndefined()
expect(currentUser.providerName.value).toBeUndefined()
expect(currentUser.providerIcon.value).toBe('pi pi-user')
expect(currentUser.isEmailProvider.value).toBe(false)
})
it('uses API key user identity before firebase identity', async () => {
const { currentUser, authStore, apiKeyStore } = await setup()
authStore.currentUser = firebaseUser('google.com')
apiKeyStore.isAuthenticated = true
apiKeyStore.currentUser = {
id: 'api-user',
name: 'API User',
email: 'api@example.com'
}
expect(currentUser.isLoggedIn.value).toBe(true)
expect(currentUser.isApiKeyLogin.value).toBe(true)
expect(currentUser.resolvedUserInfo.value).toEqual({ id: 'api-user' })
expect(currentUser.userDisplayName.value).toBe('API User')
expect(currentUser.userEmail.value).toBe('api@example.com')
expect(currentUser.userPhotoUrl.value).toBeNull()
expect(currentUser.providerName.value).toBe('Comfy API Key')
expect(currentUser.providerIcon.value).toBe('pi pi-key')
expect(currentUser.isEmailProvider.value).toBe(false)
})
it('maps firebase provider metadata to display fields', async () => {
const { currentUser, authStore } = await setup()
authStore.currentUser = firebaseUser('google.com')
expect(currentUser.providerName.value).toBe('Google')
expect(currentUser.providerIcon.value).toBe('pi pi-google')
expect(currentUser.userDisplayName.value).toBe('Firebase User')
expect(currentUser.userEmail.value).toBe('firebase@example.com')
expect(currentUser.userPhotoUrl.value).toBe('https://example.com/photo.png')
expect(currentUser.resolvedUserInfo.value).toEqual({ id: 'firebase-user' })
authStore.currentUser = firebaseUser('github.com')
expect(currentUser.providerName.value).toBe('GitHub')
expect(currentUser.providerIcon.value).toBe('pi pi-github')
authStore.currentUser = firebaseUser('password')
expect(currentUser.providerName.value).toBe('password')
expect(currentUser.providerIcon.value).toBe('pi pi-user')
expect(currentUser.isEmailProvider.value).toBe(true)
})
it('routes sign out through the active auth source', async () => {
const { currentUser, apiKeyStore, commandStore } = await setup()
apiKeyStore.isAuthenticated = true
apiKeyStore.currentUser = { id: 'api-user' }
await currentUser.handleSignOut()
expect(apiKeyStore.clearStoredApiKey).toHaveBeenCalledOnce()
apiKeyStore.isAuthenticated = false
await currentUser.handleSignOut()
expect(commandStore.execute).toHaveBeenCalledWith('Comfy.User.SignOut')
})
it('opens the sign-in dialog through the command store', async () => {
const { currentUser, commandStore } = await setup()
await currentUser.handleSignIn()
expect(commandStore.execute).toHaveBeenCalledWith(
'Comfy.User.OpenSignInDialog'
)
})
it('runs user lifecycle callbacks for resolve, token refresh, and logout', async () => {
const { currentUser, authStore } = await setup()
const resolved = vi.fn()
const tokenRefreshed = vi.fn()
const logout = vi.fn()
currentUser.onUserResolved(resolved)
currentUser.onTokenRefreshed(tokenRefreshed)
currentUser.onUserLogout(logout)
authStore.currentUser = firebaseUser('google.com')
await nextTick()
expect(resolved.mock.calls[0][0]).toEqual({ id: 'firebase-user' })
authStore.tokenRefreshTrigger += 1
await nextTick()
expect(tokenRefreshed).toHaveBeenCalledOnce()
authStore.currentUser = null
await nextTick()
expect(logout).toHaveBeenCalledOnce()
})
it('runs onUserResolved immediately when a user already exists', async () => {
const { currentUser, apiKeyStore } = await setup()
apiKeyStore.isAuthenticated = true
apiKeyStore.currentUser = { id: 'api-user' }
const resolved = vi.fn()
currentUser.onUserResolved(resolved)
expect(resolved.mock.calls[0][0]).toEqual({ id: 'api-user' })
})
})

View 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 }])
})
})

View File

@@ -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

View File

@@ -37,6 +37,14 @@ type MockUser = Omit<User, 'getIdToken' | 'delete'> & {
type MockAuth = Record<string, unknown>
/**
* Centralizes the type-boundary double-cast for Firebase mock credentials
* so individual tests only deal with the mock user.
*/
function asUserCredential(user: Partial<MockUser>): UserCredential {
return { user } as Partial<UserCredential> as UserCredential
}
// Mock fetch
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
@@ -90,6 +98,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 +108,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 +137,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 +185,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 +300,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 +322,27 @@ 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
try {
authStateCallback(mockUser)
expect(mockWorkspaceAuthStore.mintAtLogin).not.toHaveBeenCalled()
} finally {
mockDistributionTypes.isCloud = true
}
})
it('should properly clean up error state between operations', async () => {
// First, cause an error
const mockError = new Error('Invalid password')
@@ -306,18 +357,18 @@ describe('useAuthStore', () => {
}
// Now, succeed on next attempt
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValueOnce({
user: mockUser
} as Partial<UserCredential> as UserCredential)
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValueOnce(
asUserCredential(mockUser)
)
await store.login('test@example.com', 'correct-password')
})
describe('login', () => {
it('should login with valid credentials', async () => {
const mockUserCredential = { user: mockUser }
const mockUserCredential = asUserCredential(mockUser)
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue(
mockUserCredential as Partial<UserCredential> as UserCredential
mockUserCredential
)
const result = await store.login('test@example.com', 'password')
@@ -349,11 +400,35 @@ 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(
asUserCredential(userWithoutEmail)
)
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(
asUserCredential(mockUser)
)
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 }
const mockUserCredential = asUserCredential(mockUser)
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue(
mockUserCredential as Partial<UserCredential> as UserCredential
mockUserCredential
)
const loginPromise1 = store.login('user1@example.com', 'password1')
@@ -369,9 +444,9 @@ describe('useAuthStore', () => {
describe('register', () => {
it('should register a new user', async () => {
const mockUserCredential = { user: mockUser }
const mockUserCredential = asUserCredential(mockUser)
vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockResolvedValue(
mockUserCredential as Partial<UserCredential> as UserCredential
mockUserCredential
)
const result = await store.register('new@example.com', 'password')
@@ -404,9 +479,9 @@ describe('useAuthStore', () => {
})
it('forwards the turnstile token to createCustomer as turnstile_token', async () => {
vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockResolvedValue({
user: mockUser
} as Partial<UserCredential> as UserCredential)
vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockResolvedValue(
asUserCredential(mockUser)
)
await store.register('new@example.com', 'password', 'turnstile-abc')
@@ -420,9 +495,9 @@ describe('useAuthStore', () => {
})
it('omits the request body when no turnstile token is provided', async () => {
vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockResolvedValue({
user: mockUser
} as Partial<UserCredential> as UserCredential)
vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockResolvedValue(
asUserCredential(mockUser)
)
await store.register('new@example.com', 'password')
@@ -433,9 +508,9 @@ describe('useAuthStore', () => {
})
it('rolls back the orphaned Firebase user when customer creation fails', async () => {
vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockResolvedValue({
user: mockUser
} as Partial<UserCredential> as UserCredential)
vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockResolvedValue(
asUserCredential(mockUser)
)
// The server-side customer creation (where Turnstile is validated) fails.
mockFetch.mockImplementation((url: string) =>
url.endsWith('/customers')
@@ -456,9 +531,9 @@ describe('useAuthStore', () => {
})
it('does not delete the user on a successful registration', async () => {
vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockResolvedValue({
user: mockUser
} as Partial<UserCredential> as UserCredential)
vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockResolvedValue(
asUserCredential(mockUser)
)
await store.register('new@example.com', 'password')
@@ -468,9 +543,9 @@ describe('useAuthStore', () => {
it('does not delete an existing user when customer creation fails during login', async () => {
// Regression guard: the rollback must be scoped to register only — login
// signs in an EXISTING user, so a customer hiccup must never delete it.
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue({
user: mockUser
} as Partial<UserCredential> as UserCredential)
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue(
asUserCredential(mockUser)
)
mockFetch.mockImplementation((url: string) =>
url.endsWith('/customers')
? Promise.resolve({
@@ -486,6 +561,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(
asUserCredential(userWithoutEmail)
)
await store.register('new@example.com', 'password')
expect(mockTrackAuth).toHaveBeenCalledWith(
expect.objectContaining({ email: undefined })
)
})
})
describe('logout', () => {
@@ -530,9 +618,9 @@ describe('useAuthStore', () => {
it('should return null for token after login and logout sequence', async () => {
// Setup mock for login
const mockUserCredential = { user: mockUser }
const mockUserCredential = asUserCredential(mockUser)
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue(
mockUserCredential as Partial<UserCredential> as UserCredential
mockUserCredential
)
// Login
@@ -619,14 +707,62 @@ 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', () => {
describe('loginWithGoogle', () => {
it('should sign in with Google', async () => {
const mockUserCredential = { user: mockUser }
const mockUserCredential = asUserCredential(mockUser)
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue(
mockUserCredential as Partial<UserCredential> as UserCredential
mockUserCredential
)
const result = await store.loginWithGoogle()
@@ -640,9 +776,9 @@ describe('useAuthStore', () => {
})
it('never sends a turnstile_token on the customer request (OAuth is exempt)', async () => {
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue({
user: mockUser
} as Partial<UserCredential> as UserCredential)
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue(
asUserCredential(mockUser)
)
await store.loginWithGoogle()
@@ -671,9 +807,9 @@ describe('useAuthStore', () => {
describe('loginWithGithub', () => {
it('should sign in with Github', async () => {
const mockUserCredential = { user: mockUser }
const mockUserCredential = asUserCredential(mockUser)
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue(
mockUserCredential as Partial<UserCredential> as UserCredential
mockUserCredential
)
const result = await store.loginWithGithub()
@@ -687,9 +823,9 @@ describe('useAuthStore', () => {
})
it('never sends a turnstile_token on the customer request (OAuth is exempt)', async () => {
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue({
user: mockUser
} as Partial<UserCredential> as UserCredential)
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue(
asUserCredential(mockUser)
)
await store.loginWithGithub()
@@ -717,9 +853,9 @@ describe('useAuthStore', () => {
})
it('should handle concurrent social login attempts correctly', async () => {
const mockUserCredential = { user: mockUser }
const mockUserCredential = asUserCredential(mockUser)
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue(
mockUserCredential as Partial<UserCredential> as UserCredential
mockUserCredential
)
const googleLoginPromise = store.loginWithGoogle()
@@ -731,9 +867,7 @@ describe('useAuthStore', () => {
})
describe('sign-up telemetry OR logic', () => {
const mockUserCredential = {
user: mockUser
} as Partial<UserCredential> as UserCredential
const mockUserCredential = asUserCredential(mockUser)
beforeEach(() => {
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue(
@@ -804,6 +938,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(
asUserCredential(userWithoutEmail)
)
await store[method]()
expect(mockTrackAuth).toHaveBeenCalledWith(
expect.objectContaining({ email: undefined })
)
}
)
})
})
@@ -975,6 +1125,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 +1267,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'
})
})
})
})

View File

@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useBootstrapStore } from './bootstrapStore'
@@ -21,25 +22,28 @@ vi.mock('@/i18n', () => ({
}))
const mockIsSettingsReady = ref(false)
const mockSettingStore = {
load: vi.fn(() => {
mockIsSettingsReady.value = true
}),
get isReady() {
return mockIsSettingsReady.value
},
isLoading: ref(false),
error: ref(undefined)
}
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
load: vi.fn(() => {
mockIsSettingsReady.value = true
}),
get isReady() {
return mockIsSettingsReady.value
},
isLoading: ref(false),
error: ref(undefined)
}))
useSettingStore: vi.fn(() => mockSettingStore)
}))
const mockWorkflowStore = {
loadWorkflows: vi.fn(),
syncWorkflows: vi.fn().mockResolvedValue(undefined)
}
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => ({
loadWorkflows: vi.fn(),
syncWorkflows: vi.fn().mockResolvedValue(undefined)
}))
useWorkflowStore: vi.fn(() => mockWorkflowStore)
}))
const mockNeedsLogin = ref(false)
@@ -93,6 +97,21 @@ describe('bootstrapStore', () => {
})
})
it('does not reload authenticated stores after bootstrap already ran', async () => {
const store = useBootstrapStore()
const settingStore = useSettingStore()
const workflowStore = useWorkflowStore()
await store.startStoreBootstrap()
await store.startStoreBootstrap()
await vi.waitFor(() => {
expect(store.isI18nReady).toBe(true)
})
expect(settingStore.load).toHaveBeenCalledOnce()
expect(workflowStore.loadWorkflows).toHaveBeenCalledOnce()
})
describe('cloud mode', () => {
beforeEach(() => {
mockDistributionTypes.isCloud = true

View File

@@ -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')
})
})
})

View File

@@ -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,114 @@ 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 }
})
// A second dialog steals focus so the mousedown below actually
// exercises riseDialog's promote-to-front behavior.
store.showDialog({ key: 'other', component: MockComponent })
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)
expect(store.activeKey).toBe('other')
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 +316,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', () => {

View File

@@ -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', () => {

View 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')
})
})

View File

@@ -90,6 +90,12 @@ describe('templateRankingStore', () => {
})
describe('computePopularScore', () => {
it('normalizes usage against itself before a largest score is loaded', () => {
const store = useTemplateRankingStore()
expect(store.computePopularScore('2024-01-01', 10)).toBeGreaterThan(0.8)
})
it('does not use searchRank', () => {
const store = useTemplateRankingStore()
store.largestUsageScore = 100

View File

@@ -0,0 +1,25 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { useExtensionStore } from '@/stores/extensionStore'
import { useTopbarBadgeStore } from '@/stores/topbarBadgeStore'
describe('topbarBadgeStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('collects topbar badges from registered extensions', () => {
const extensionStore = useExtensionStore()
extensionStore.registerExtension({
name: 'badges',
topbarBadges: [{ text: 'Beta', label: 'BETA' }]
})
extensionStore.registerExtension({ name: 'plain' })
const store = useTopbarBadgeStore()
expect(store.badges).toEqual([{ text: 'Beta', label: 'BETA' }])
})
})