mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-02 21:28:08 +00:00
Compare commits
1 Commits
alexisroll
...
codex/cove
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08648920af |
@@ -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'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
213
src/composables/auth/useCurrentUser.test.ts
Normal file
213
src/composables/auth/useCurrentUser.test.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
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
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
25
src/stores/topbarBadgeStore.test.ts
Normal file
25
src/stores/topbarBadgeStore.test.ts
Normal 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' }])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user