mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-03 13:48:49 +00:00
Compare commits
2 Commits
lint/test-
...
codex/cove
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c25e88d366 | ||
|
|
cfe4e0f444 |
256
src/composables/billing/useLegacyBilling.test.ts
Normal file
256
src/composables/billing/useLegacyBilling.test.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useLegacyBilling } from './useLegacyBilling'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
isActiveSubscription: { value: false },
|
||||
subscriptionTier: { value: null as string | null },
|
||||
subscriptionDuration: { value: null as string | null },
|
||||
subscriptionStatus: {
|
||||
value: null as null | {
|
||||
renewal_date?: string | null
|
||||
end_date?: string | null
|
||||
}
|
||||
},
|
||||
isCancelled: { value: false },
|
||||
fetchStatus: vi.fn(),
|
||||
manageSubscription: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
showSubscriptionDialog: vi.fn(),
|
||||
balance: {
|
||||
value: null as null | {
|
||||
amount_micros?: number
|
||||
currency?: string
|
||||
effective_balance_micros?: number
|
||||
prepaid_balance_micros?: number
|
||||
cloud_credit_balance_micros?: number
|
||||
}
|
||||
},
|
||||
fetchBalance: vi.fn(),
|
||||
purchaseCredits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: () => ({
|
||||
isActiveSubscription: mocks.isActiveSubscription,
|
||||
subscriptionTier: mocks.subscriptionTier,
|
||||
subscriptionDuration: mocks.subscriptionDuration,
|
||||
subscriptionStatus: mocks.subscriptionStatus,
|
||||
isCancelled: mocks.isCancelled,
|
||||
fetchStatus: mocks.fetchStatus,
|
||||
manageSubscription: mocks.manageSubscription,
|
||||
subscribe: mocks.subscribe,
|
||||
showSubscriptionDialog: mocks.showSubscriptionDialog
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => ({
|
||||
get balance() {
|
||||
return mocks.balance.value
|
||||
},
|
||||
fetchBalance: mocks.fetchBalance
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useAuthActions', () => ({
|
||||
useAuthActions: () => ({
|
||||
purchaseCredits: mocks.purchaseCredits
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useLegacyBilling', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mocks.isActiveSubscription.value = false
|
||||
mocks.subscriptionTier.value = null
|
||||
mocks.subscriptionDuration.value = null
|
||||
mocks.subscriptionStatus.value = null
|
||||
mocks.isCancelled.value = false
|
||||
mocks.balance.value = null
|
||||
mocks.fetchStatus.mockResolvedValue(undefined)
|
||||
mocks.manageSubscription.mockResolvedValue(undefined)
|
||||
mocks.subscribe.mockResolvedValue(undefined)
|
||||
mocks.fetchBalance.mockResolvedValue(undefined)
|
||||
mocks.purchaseCredits.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('returns empty subscription and balance state without legacy data', () => {
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
expect(billing.subscription.value).toBeNull()
|
||||
expect(billing.balance.value).toBeNull()
|
||||
expect(billing.subscriptionStatus.value).toBeNull()
|
||||
expect(billing.renewalDate.value).toBeNull()
|
||||
expect(billing.isFreeTier.value).toBe(false)
|
||||
})
|
||||
|
||||
it('maps active subscription and explicit balance fields', () => {
|
||||
mocks.isActiveSubscription.value = true
|
||||
mocks.subscriptionTier.value = 'PRO'
|
||||
mocks.subscriptionDuration.value = 'MONTHLY'
|
||||
mocks.subscriptionStatus.value = {
|
||||
renewal_date: '2026-01-01T00:00:00Z',
|
||||
end_date: '2026-02-01T00:00:00Z'
|
||||
}
|
||||
mocks.balance.value = {
|
||||
amount_micros: 500,
|
||||
currency: 'eur',
|
||||
effective_balance_micros: 400,
|
||||
prepaid_balance_micros: 300,
|
||||
cloud_credit_balance_micros: 200
|
||||
}
|
||||
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
expect(billing.subscription.value).toEqual({
|
||||
isActive: true,
|
||||
tier: 'PRO',
|
||||
duration: 'MONTHLY',
|
||||
planSlug: null,
|
||||
renewalDate: '2026-01-01T00:00:00Z',
|
||||
endDate: '2026-02-01T00:00:00Z',
|
||||
isCancelled: false,
|
||||
hasFunds: true
|
||||
})
|
||||
expect(billing.balance.value).toEqual({
|
||||
amountMicros: 500,
|
||||
currency: 'eur',
|
||||
effectiveBalanceMicros: 400,
|
||||
prepaidBalanceMicros: 300,
|
||||
cloudCreditBalanceMicros: 200
|
||||
})
|
||||
expect(billing.subscriptionStatus.value).toBe('active')
|
||||
})
|
||||
|
||||
it('uses legacy balance defaults when optional fields are absent', () => {
|
||||
mocks.subscriptionTier.value = 'FREE'
|
||||
mocks.balance.value = {}
|
||||
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
expect(billing.balance.value).toEqual({
|
||||
amountMicros: 0,
|
||||
currency: 'usd',
|
||||
effectiveBalanceMicros: 0,
|
||||
prepaidBalanceMicros: 0,
|
||||
cloudCreditBalanceMicros: 0
|
||||
})
|
||||
expect(billing.subscription.value?.hasFunds).toBe(false)
|
||||
})
|
||||
|
||||
it('uses amount as effective balance when only amount is present', () => {
|
||||
mocks.balance.value = { amount_micros: 250 }
|
||||
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
expect(billing.balance.value?.effectiveBalanceMicros).toBe(250)
|
||||
})
|
||||
|
||||
it('reports canceled status before active status', () => {
|
||||
mocks.isActiveSubscription.value = true
|
||||
mocks.isCancelled.value = true
|
||||
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
expect(billing.subscriptionStatus.value).toBe('canceled')
|
||||
})
|
||||
|
||||
it('initializes once and re-fetches zero free-tier balance', async () => {
|
||||
mocks.subscriptionTier.value = 'FREE'
|
||||
mocks.balance.value = { amount_micros: 0 }
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
await billing.initialize()
|
||||
await billing.initialize()
|
||||
|
||||
expect(billing.isInitialized.value).toBe(true)
|
||||
expect(mocks.fetchStatus).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.fetchBalance).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('stores initialization error messages from Error failures', async () => {
|
||||
mocks.fetchStatus.mockRejectedValue(new Error('status failed'))
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
await expect(billing.initialize()).rejects.toThrow('status failed')
|
||||
|
||||
expect(billing.error.value).toBe('status failed')
|
||||
expect(billing.isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('stores fallback initialization error messages for non-Error failures', async () => {
|
||||
mocks.fetchStatus.mockRejectedValue('status failed')
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
await expect(billing.initialize()).rejects.toBe('status failed')
|
||||
|
||||
expect(billing.error.value).toBe('Failed to initialize billing')
|
||||
})
|
||||
|
||||
it('stores subscription fetch fallback errors', async () => {
|
||||
mocks.fetchStatus.mockRejectedValue('status failed')
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
await expect(billing.fetchStatus()).rejects.toBe('status failed')
|
||||
|
||||
expect(billing.error.value).toBe('Failed to fetch subscription')
|
||||
expect(billing.isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('stores balance fetch errors', async () => {
|
||||
mocks.fetchBalance.mockRejectedValue(new Error('balance failed'))
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
await expect(billing.fetchBalance()).rejects.toThrow('balance failed')
|
||||
|
||||
expect(billing.error.value).toBe('balance failed')
|
||||
expect(billing.isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('stores balance fetch fallback errors', async () => {
|
||||
mocks.fetchBalance.mockRejectedValue('balance failed')
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
await expect(billing.fetchBalance()).rejects.toBe('balance failed')
|
||||
|
||||
expect(billing.error.value).toBe('Failed to fetch balance')
|
||||
})
|
||||
|
||||
it('delegates legacy billing actions', async () => {
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
await expect(billing.subscribe('pro-monthly')).resolves.toBeUndefined()
|
||||
await expect(billing.previewSubscribe('pro-monthly')).resolves.toBeNull()
|
||||
await billing.manageSubscription()
|
||||
await billing.cancelSubscription()
|
||||
await billing.resubscribe()
|
||||
await billing.topup(750)
|
||||
await expect(billing.fetchPlans()).resolves.toBeUndefined()
|
||||
billing.showSubscriptionDialog()
|
||||
|
||||
expect(mocks.subscribe).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.manageSubscription).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.purchaseCredits).toHaveBeenCalledWith(7.5)
|
||||
expect(mocks.showSubscriptionDialog).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows the subscription dialog when active subscription is required', async () => {
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
await billing.requireActiveSubscription()
|
||||
|
||||
expect(mocks.showSubscriptionDialog).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not show the subscription dialog for active subscribers', async () => {
|
||||
mocks.isActiveSubscription.value = true
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
await billing.requireActiveSubscription()
|
||||
|
||||
expect(mocks.showSubscriptionDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
217
src/composables/bottomPanelTabs/useTerminal.test.ts
Normal file
217
src/composables/bottomPanelTabs/useTerminal.test.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent, ref } from 'vue'
|
||||
|
||||
interface MockTerminalInstance {
|
||||
cols: number
|
||||
rows: number
|
||||
options: unknown
|
||||
loadAddon: ReturnType<typeof vi.fn>
|
||||
attachCustomKeyEventHandler: ReturnType<typeof vi.fn>
|
||||
open: ReturnType<typeof vi.fn>
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
resize: ReturnType<typeof vi.fn>
|
||||
hasSelection: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
interface MockFitAddonInstance {
|
||||
proposeDimensions: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const mockXterm = vi.hoisted(() => {
|
||||
const terminalInstances: MockTerminalInstance[] = []
|
||||
const fitAddonInstances: MockFitAddonInstance[] = []
|
||||
|
||||
class Terminal {
|
||||
cols = 80
|
||||
rows = 24
|
||||
loadAddon = vi.fn()
|
||||
attachCustomKeyEventHandler = vi.fn()
|
||||
open = vi.fn()
|
||||
dispose = vi.fn()
|
||||
resize = vi.fn((cols: number, rows: number) => {
|
||||
this.cols = cols
|
||||
this.rows = rows
|
||||
})
|
||||
hasSelection = vi.fn(() => false)
|
||||
|
||||
constructor(readonly options: unknown) {
|
||||
terminalInstances.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
class FitAddon {
|
||||
proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 }))
|
||||
|
||||
constructor() {
|
||||
fitAddonInstances.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
Terminal,
|
||||
FitAddon,
|
||||
terminalInstances,
|
||||
fitAddonInstances
|
||||
}
|
||||
})
|
||||
|
||||
const mockResizeObserverInstances = [] as MockResizeObserver[]
|
||||
|
||||
class MockResizeObserver {
|
||||
observe = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
|
||||
constructor(readonly callback: ResizeObserverCallback) {
|
||||
mockResizeObserverInstances.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@xterm/xterm', () => ({
|
||||
Terminal: mockXterm.Terminal
|
||||
}))
|
||||
|
||||
vi.mock('@xterm/addon-fit', () => ({
|
||||
FitAddon: mockXterm.FitAddon
|
||||
}))
|
||||
|
||||
vi.mock('es-toolkit/compat', () => ({
|
||||
debounce: (fn: () => void) => fn
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isDesktop: true
|
||||
}))
|
||||
|
||||
import { useTerminal } from './useTerminal'
|
||||
|
||||
function terminalElement() {
|
||||
const element = document.createElement('div')
|
||||
Object.defineProperty(element, 'clientWidth', { value: 160 })
|
||||
Object.defineProperty(element, 'clientHeight', { value: 100 })
|
||||
return element
|
||||
}
|
||||
|
||||
function mountTerminal(
|
||||
configure?: (
|
||||
result: ReturnType<typeof useTerminal>,
|
||||
root: ReturnType<typeof ref<HTMLElement | undefined>>
|
||||
) => void
|
||||
) {
|
||||
let result: ReturnType<typeof useTerminal> | undefined
|
||||
const root = ref<HTMLElement | undefined>(terminalElement())
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
result = useTerminal(root)
|
||||
configure?.(result, root)
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
)
|
||||
app.mount(document.createElement('div'))
|
||||
if (!result) throw new Error('Expected terminal composable to initialize')
|
||||
return { app, result, root }
|
||||
}
|
||||
|
||||
describe('useTerminal', () => {
|
||||
beforeEach(() => {
|
||||
mockXterm.terminalInstances.length = 0
|
||||
mockXterm.fitAddonInstances.length = 0
|
||||
mockResizeObserverInstances.length = 0
|
||||
vi.stubGlobal('ResizeObserver', MockResizeObserver)
|
||||
})
|
||||
|
||||
it('creates a desktop themed terminal and opens it on mount', () => {
|
||||
const { app, root } = mountTerminal()
|
||||
const terminal = mockXterm.terminalInstances[0]
|
||||
const fitAddon = mockXterm.fitAddonInstances[0]
|
||||
|
||||
expect(terminal.options).toMatchObject({
|
||||
convertEol: true,
|
||||
theme: { background: '#171717' }
|
||||
})
|
||||
expect(terminal.loadAddon).toHaveBeenCalledWith(fitAddon)
|
||||
expect(terminal.open).toHaveBeenCalledWith(root.value)
|
||||
|
||||
app.unmount()
|
||||
expect(terminal.dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('lets browser copy and paste shortcuts pass through', () => {
|
||||
mountTerminal()
|
||||
const terminal = mockXterm.terminalInstances[0]
|
||||
const handler = terminal.attachCustomKeyEventHandler.mock.calls[0][0] as (
|
||||
event: KeyboardEvent
|
||||
) => boolean
|
||||
|
||||
terminal.hasSelection.mockReturnValue(true)
|
||||
expect(
|
||||
handler(new KeyboardEvent('keydown', { key: 'c', ctrlKey: true }))
|
||||
).toBe(false)
|
||||
expect(
|
||||
handler(new KeyboardEvent('keydown', { key: 'v', metaKey: true }))
|
||||
).toBe(false)
|
||||
|
||||
terminal.hasSelection.mockReturnValue(false)
|
||||
expect(
|
||||
handler(new KeyboardEvent('keydown', { key: 'c', ctrlKey: true }))
|
||||
).toBe(true)
|
||||
expect(
|
||||
handler(new KeyboardEvent('keyup', { key: 'v', ctrlKey: true }))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('auto-sizes from fit dimensions and disconnects the observer on unmount', () => {
|
||||
const onResize = vi.fn()
|
||||
const { app, root } = mountTerminal((terminal, rootRef) => {
|
||||
terminal.useAutoSize({
|
||||
root: rootRef,
|
||||
minCols: 100,
|
||||
minRows: 20,
|
||||
onResize
|
||||
})
|
||||
})
|
||||
const terminal = mockXterm.terminalInstances[0]
|
||||
const observer = mockResizeObserverInstances[0]
|
||||
|
||||
expect(observer.observe).toHaveBeenCalledWith(root.value)
|
||||
expect(terminal.resize).toHaveBeenCalledWith(120, 40)
|
||||
expect(onResize).toHaveBeenCalledOnce()
|
||||
|
||||
app.unmount()
|
||||
expect(observer.disconnect).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('estimates invalid fit dimensions from the root element', () => {
|
||||
const { result, root } = mountTerminal()
|
||||
const fitAddon = mockXterm.fitAddonInstances[0]
|
||||
fitAddon.proposeDimensions.mockReturnValue({
|
||||
cols: Number.NaN,
|
||||
rows: undefined
|
||||
})
|
||||
const { resize } = result.useAutoSize({ root, minCols: 30, minRows: 10 })
|
||||
const terminal = mockXterm.terminalInstances[0]
|
||||
|
||||
resize()
|
||||
|
||||
expect(terminal.resize).toHaveBeenLastCalledWith(30, 10)
|
||||
})
|
||||
|
||||
it('keeps existing terminal dimensions when auto sizing is disabled', () => {
|
||||
const { result, root } = mountTerminal()
|
||||
const terminal = mockXterm.terminalInstances[0]
|
||||
terminal.cols = 90
|
||||
terminal.rows = 30
|
||||
const { resize } = result.useAutoSize({
|
||||
root,
|
||||
autoCols: false,
|
||||
autoRows: false,
|
||||
minCols: 10,
|
||||
minRows: 10
|
||||
})
|
||||
|
||||
resize()
|
||||
|
||||
expect(terminal.resize).toHaveBeenLastCalledWith(90, 30)
|
||||
})
|
||||
})
|
||||
@@ -179,6 +179,14 @@ describe('useTeamWorkspaceStore', () => {
|
||||
expect(store.canCreateWorkspace).toBe(true)
|
||||
expect(store.members).toEqual([])
|
||||
expect(store.pendingInvites).toEqual([])
|
||||
expect(store.originalOwnerId).toBeNull()
|
||||
expect(store.isCurrentUserOriginalOwner).toBe(false)
|
||||
expect(store.totalMemberSlots).toBe(0)
|
||||
expect(store.isInviteLimitReached).toBe(false)
|
||||
expect(store.workspaceId).toBeNull()
|
||||
expect(store.workspaceName).toBe('')
|
||||
expect(store.isWorkspaceSubscribed).toBe(false)
|
||||
expect(store.subscriptionPlan).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -208,6 +216,30 @@ describe('useTeamWorkspaceStore', () => {
|
||||
expect(mockWorkspaceAuthStore.switchWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back when the restored session workspace is no longer available', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
|
||||
mockWorkspaceAuthStore.currentWorkspace = {
|
||||
...mockTeamWorkspace,
|
||||
id: 'ws-stale'
|
||||
}
|
||||
mockWorkspaceAuthStore.switchWorkspace.mockRejectedValueOnce(
|
||||
new Error('Token exchange failed')
|
||||
)
|
||||
|
||||
const store = useTeamWorkspaceStore()
|
||||
await store.initialize()
|
||||
|
||||
expect(mockWorkspaceAuthStore.clearWorkspaceContext).toHaveBeenCalled()
|
||||
expect(store.activeWorkspaceId).toBe(mockPersonalWorkspace.id)
|
||||
expect(store.initState).toBe('ready')
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'[teamWorkspaceStore] Token exchange failed during fallback'
|
||||
)
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('falls back to localStorage if no session', async () => {
|
||||
mockLocalStorage.getItem.mockReturnValue(mockTeamWorkspace.id)
|
||||
|
||||
@@ -217,6 +249,17 @@ describe('useTeamWorkspaceStore', () => {
|
||||
expect(store.activeWorkspaceId).toBe(mockTeamWorkspace.id)
|
||||
})
|
||||
|
||||
it('uses the default workspace when localStorage cannot be read', async () => {
|
||||
mockLocalStorage.getItem.mockImplementationOnce(() => {
|
||||
throw new Error('blocked')
|
||||
})
|
||||
|
||||
const store = useTeamWorkspaceStore()
|
||||
await store.initialize()
|
||||
|
||||
expect(store.activeWorkspaceId).toBe(mockPersonalWorkspace.id)
|
||||
})
|
||||
|
||||
it('falls back to personal if stored workspace not in list', async () => {
|
||||
mockLocalStorage.getItem.mockReturnValue('non-existent-workspace')
|
||||
|
||||
@@ -226,6 +269,23 @@ describe('useTeamWorkspaceStore', () => {
|
||||
expect(store.activeWorkspaceId).toBe(mockPersonalWorkspace.id)
|
||||
})
|
||||
|
||||
it('continues initialization when persisting the last workspace fails', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
mockLocalStorage.setItem.mockImplementationOnce(() => {
|
||||
throw new Error('quota')
|
||||
})
|
||||
|
||||
const store = useTeamWorkspaceStore()
|
||||
await store.initialize()
|
||||
|
||||
expect(store.initState).toBe('ready')
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to persist last workspace ID to localStorage'
|
||||
)
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('sets error state when workspaces fetch fails after retries', async () => {
|
||||
vi.useFakeTimers()
|
||||
mockWorkspaceApi.list.mockRejectedValue(new Error('Network error'))
|
||||
@@ -428,6 +488,14 @@ describe('useTeamWorkspaceStore', () => {
|
||||
})
|
||||
|
||||
describe('deleteWorkspace', () => {
|
||||
it('throws when no active workspace is available', async () => {
|
||||
const store = useTeamWorkspaceStore()
|
||||
|
||||
await expect(store.deleteWorkspace()).rejects.toThrow(
|
||||
'No workspace to delete'
|
||||
)
|
||||
})
|
||||
|
||||
it('deletes non-active workspace without reload', async () => {
|
||||
const store = useTeamWorkspaceStore()
|
||||
await store.initialize()
|
||||
@@ -459,6 +527,35 @@ describe('useTeamWorkspaceStore', () => {
|
||||
expect(mockReload).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deletes an active team workspace even when no personal workspace exists', async () => {
|
||||
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
|
||||
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
|
||||
mockWorkspaceApi.list.mockResolvedValue({
|
||||
workspaces: [mockTeamWorkspace]
|
||||
})
|
||||
|
||||
const store = useTeamWorkspaceStore()
|
||||
await store.initialize()
|
||||
|
||||
await store.deleteWorkspace()
|
||||
|
||||
expect(mockWorkspaceApi.delete).toHaveBeenCalledWith(mockTeamWorkspace.id)
|
||||
expect(mockWorkspaceAuthStore.clearWorkspaceContext).toHaveBeenCalled()
|
||||
expect(mockReload).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resets isDeleting when deletion fails', async () => {
|
||||
mockWorkspaceApi.delete.mockRejectedValue(new Error('Delete failed'))
|
||||
|
||||
const store = useTeamWorkspaceStore()
|
||||
await store.initialize()
|
||||
|
||||
await expect(store.deleteWorkspace(mockTeamWorkspace.id)).rejects.toThrow(
|
||||
'Delete failed'
|
||||
)
|
||||
expect(store.isDeleting).toBe(false)
|
||||
})
|
||||
|
||||
it('throws when trying to delete personal workspace', async () => {
|
||||
const store = useTeamWorkspaceStore()
|
||||
await store.initialize()
|
||||
@@ -500,6 +597,32 @@ describe('useTeamWorkspaceStore', () => {
|
||||
)
|
||||
expect(updated?.name).toBe('Renamed Workspace')
|
||||
})
|
||||
|
||||
it('renames the active workspace by name', async () => {
|
||||
mockWorkspaceApi.update.mockResolvedValue({
|
||||
...mockPersonalWorkspace,
|
||||
name: 'Renamed Personal'
|
||||
})
|
||||
|
||||
const store = useTeamWorkspaceStore()
|
||||
await store.initialize()
|
||||
|
||||
await store.updateWorkspaceName('Renamed Personal')
|
||||
|
||||
expect(mockWorkspaceApi.update).toHaveBeenCalledWith(
|
||||
mockPersonalWorkspace.id,
|
||||
{ name: 'Renamed Personal' }
|
||||
)
|
||||
expect(store.activeWorkspace?.name).toBe('Renamed Personal')
|
||||
})
|
||||
|
||||
it('throws when renaming without an active workspace', async () => {
|
||||
const store = useTeamWorkspaceStore()
|
||||
|
||||
await expect(store.updateWorkspaceName('Nope')).rejects.toThrow(
|
||||
'No active workspace'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('leaveWorkspace', () => {
|
||||
@@ -613,6 +736,13 @@ describe('useTeamWorkspaceStore', () => {
|
||||
})
|
||||
|
||||
describe('member actions', () => {
|
||||
it('fetchMembers returns empty before a workspace is active', async () => {
|
||||
const store = useTeamWorkspaceStore()
|
||||
|
||||
await expect(store.fetchMembers()).resolves.toEqual([])
|
||||
expect(mockWorkspaceApi.listMembers).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fetchMembers updates active workspace members', async () => {
|
||||
const mockMembers = [
|
||||
{
|
||||
@@ -690,6 +820,27 @@ describe('useTeamWorkspaceStore', () => {
|
||||
expect(store.members[0].id).toBe('user-2')
|
||||
})
|
||||
|
||||
it('removeMember succeeds without a current workspace', async () => {
|
||||
const store = useTeamWorkspaceStore()
|
||||
|
||||
await store.removeMember('user-1')
|
||||
|
||||
expect(mockWorkspaceApi.removeMember).toHaveBeenCalledWith('user-1')
|
||||
expect(store.members).toEqual([])
|
||||
})
|
||||
|
||||
it('changeMemberRole succeeds without a current workspace', async () => {
|
||||
const store = useTeamWorkspaceStore()
|
||||
|
||||
await store.changeMemberRole('user-1', 'owner')
|
||||
|
||||
expect(mockWorkspaceApi.updateMemberRole).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
'owner'
|
||||
)
|
||||
expect(store.members).toEqual([])
|
||||
})
|
||||
|
||||
it('changeMemberRole flips the role locally without trusting the response body', async () => {
|
||||
mockWorkspaceApi.listMembers.mockResolvedValue({
|
||||
members: [
|
||||
@@ -943,6 +1094,14 @@ describe('useTeamWorkspaceStore', () => {
|
||||
return store
|
||||
}
|
||||
|
||||
it('does nothing before a workspace is active', async () => {
|
||||
const store = useTeamWorkspaceStore()
|
||||
|
||||
await store.ensureMembersLoaded()
|
||||
|
||||
expect(mockWorkspaceApi.listMembers).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('loads members for a team workspace that is not yet loaded', async () => {
|
||||
mockMembersResponse()
|
||||
const store = await activateTeamWorkspace()
|
||||
@@ -1129,6 +1288,21 @@ describe('useTeamWorkspaceStore', () => {
|
||||
})
|
||||
|
||||
describe('invite actions', () => {
|
||||
it('fetchPendingInvites returns empty before a workspace is active', async () => {
|
||||
const store = useTeamWorkspaceStore()
|
||||
|
||||
await expect(store.fetchPendingInvites()).resolves.toEqual([])
|
||||
expect(mockWorkspaceApi.listInvites).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fetchPendingInvites returns empty for personal workspaces', async () => {
|
||||
const store = useTeamWorkspaceStore()
|
||||
await store.initialize()
|
||||
|
||||
await expect(store.fetchPendingInvites()).resolves.toEqual([])
|
||||
expect(mockWorkspaceApi.listInvites).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fetchPendingInvites updates active workspace invites', async () => {
|
||||
const mockInvites = [
|
||||
{
|
||||
@@ -1179,6 +1353,23 @@ describe('useTeamWorkspaceStore', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('createInvite returns the invite before a workspace is active', async () => {
|
||||
const newInvite = {
|
||||
id: 'inv-new',
|
||||
email: 'new@test.com',
|
||||
token: 'token-new',
|
||||
invited_at: '2024-01-01T00:00:00Z',
|
||||
expires_at: '2024-01-08T00:00:00Z'
|
||||
}
|
||||
mockWorkspaceApi.createInvite.mockResolvedValue(newInvite)
|
||||
|
||||
const store = useTeamWorkspaceStore()
|
||||
const result = await store.createInvite('new@test.com')
|
||||
|
||||
expect(result.email).toBe('new@test.com')
|
||||
expect(store.pendingInvites).toEqual([])
|
||||
})
|
||||
|
||||
it('revokeInvite removes from local list', async () => {
|
||||
const mockInvites = [
|
||||
{
|
||||
@@ -1211,6 +1402,15 @@ describe('useTeamWorkspaceStore', () => {
|
||||
expect(store.pendingInvites[0].id).toBe('inv-2')
|
||||
})
|
||||
|
||||
it('revokeInvite succeeds before a workspace is active', async () => {
|
||||
const store = useTeamWorkspaceStore()
|
||||
|
||||
await store.revokeInvite('inv-1')
|
||||
|
||||
expect(mockWorkspaceApi.revokeInvite).toHaveBeenCalledWith('inv-1')
|
||||
expect(store.pendingInvites).toEqual([])
|
||||
})
|
||||
|
||||
it('resendInvite creates a fresh invite before revoking the old one', async () => {
|
||||
mockWorkspaceApi.listInvites.mockResolvedValue({
|
||||
invites: [
|
||||
@@ -1391,6 +1591,36 @@ describe('useTeamWorkspaceStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('subscription placeholder', () => {
|
||||
it('warns with the default subscription plan', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const store = useTeamWorkspaceStore()
|
||||
|
||||
store.subscribeWorkspace()
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'PRO_MONTHLY',
|
||||
'Billing endpoint has not been added yet.'
|
||||
)
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('warns with a custom subscription plan', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const store = useTeamWorkspaceStore()
|
||||
|
||||
store.subscribeWorkspace('TEAM_YEARLY')
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'TEAM_YEARLY',
|
||||
'Billing endpoint has not been added yet.'
|
||||
)
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('totalMemberSlots and isInviteLimitReached', () => {
|
||||
it('calculates total slots from members and invites', async () => {
|
||||
const mockMembers = [
|
||||
|
||||
@@ -74,6 +74,23 @@ function expectedExpiresAtMs(expiresAt: string): string {
|
||||
return new Date(expiresAt).getTime().toString()
|
||||
}
|
||||
|
||||
function createThrowingSessionStorage(
|
||||
overrides: Partial<Pick<Storage, 'getItem' | 'removeItem'>>
|
||||
): Storage {
|
||||
const original = globalThis.sessionStorage
|
||||
return {
|
||||
get length() {
|
||||
return original.length
|
||||
},
|
||||
key: original.key.bind(original),
|
||||
getItem: original.getItem.bind(original),
|
||||
setItem: original.setItem.bind(original),
|
||||
removeItem: original.removeItem.bind(original),
|
||||
clear: original.clear.bind(original),
|
||||
...overrides
|
||||
} satisfies Storage
|
||||
}
|
||||
|
||||
describe('useWorkspaceAuthStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
@@ -202,6 +219,93 @@ describe('useWorkspaceAuthStore', () => {
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false and clears storage when the workspace shape is invalid', () => {
|
||||
sessionStorage.setItem(
|
||||
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE,
|
||||
JSON.stringify({ ...mockWorkspaceWithRole, role: 'admin' })
|
||||
)
|
||||
sessionStorage.setItem(WORKSPACE_STORAGE_KEYS.TOKEN, 'some-token')
|
||||
sessionStorage.setItem(
|
||||
WORKSPACE_STORAGE_KEYS.EXPIRES_AT,
|
||||
(Date.now() + 3600 * 1000).toString()
|
||||
)
|
||||
|
||||
const store = useWorkspaceAuthStore()
|
||||
|
||||
const result = store.initializeFromSession()
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(
|
||||
sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('returns false when sessionStorage access throws', () => {
|
||||
const originalSessionStorage = globalThis.sessionStorage
|
||||
const throwingSessionStorage = createThrowingSessionStorage({
|
||||
getItem: vi.fn(() => {
|
||||
throw new Error('blocked')
|
||||
}),
|
||||
removeItem: vi.fn(() => {
|
||||
throw new Error('blocked')
|
||||
})
|
||||
})
|
||||
vi.stubGlobal('sessionStorage', throwingSessionStorage)
|
||||
const consoleWarnSpy = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
const store = useWorkspaceAuthStore()
|
||||
|
||||
expect(store.initializeFromSession()).toBe(false)
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'Failed to clear workspace context from sessionStorage'
|
||||
)
|
||||
} finally {
|
||||
vi.stubGlobal('sessionStorage', originalSessionStorage)
|
||||
consoleWarnSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('init restores session state and refreshes immediately inside the buffer window', async () => {
|
||||
const nearExpiry = Date.now() + 1000
|
||||
sessionStorage.setItem(
|
||||
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE,
|
||||
JSON.stringify(mockWorkspaceWithRole)
|
||||
)
|
||||
sessionStorage.setItem(WORKSPACE_STORAGE_KEYS.TOKEN, 'session-token')
|
||||
sessionStorage.setItem(
|
||||
WORKSPACE_STORAGE_KEYS.EXPIRES_AT,
|
||||
nearExpiry.toString()
|
||||
)
|
||||
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||
const refreshedExpiry = new Date(Date.now() + 3600 * 1000).toISOString()
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
...mockTokenResponse,
|
||||
token: 'refreshed-token',
|
||||
expires_at: refreshedExpiry
|
||||
})
|
||||
})
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const store = useWorkspaceAuthStore()
|
||||
const { workspaceToken } = storeToRefs(store)
|
||||
|
||||
store.init()
|
||||
expect(workspaceToken.value).toBe('session-token')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(workspaceToken.value).toBe('refreshed-token')
|
||||
expect(sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.TOKEN)).toBe(
|
||||
'refreshed-token'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('switchWorkspace', () => {
|
||||
@@ -431,6 +535,71 @@ describe('useWorkspaceAuthStore', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to statusText when an error body has no message', async () => {
|
||||
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Server exploded',
|
||||
json: () => Promise.resolve({})
|
||||
})
|
||||
)
|
||||
|
||||
const store = useWorkspaceAuthStore()
|
||||
const { error } = storeToRefs(store)
|
||||
|
||||
await expect(store.switchWorkspace('workspace-123')).rejects.toThrow(
|
||||
WorkspaceAuthError
|
||||
)
|
||||
|
||||
expect((error.value as WorkspaceAuthError).code).toBe(
|
||||
'TOKEN_EXCHANGE_FAILED'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws TOKEN_EXCHANGE_FAILED when the expiry timestamp is invalid', async () => {
|
||||
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
...mockTokenResponse,
|
||||
expires_at: 'not-a-date'
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
const store = useWorkspaceAuthStore()
|
||||
const { error } = storeToRefs(store)
|
||||
|
||||
await expect(store.switchWorkspace('workspace-123')).rejects.toThrow(
|
||||
WorkspaceAuthError
|
||||
)
|
||||
|
||||
expect((error.value as WorkspaceAuthError).code).toBe(
|
||||
'TOKEN_EXCHANGE_FAILED'
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes non-Error request failures into store error state', async () => {
|
||||
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue('network down'))
|
||||
|
||||
const store = useWorkspaceAuthStore()
|
||||
const { error } = storeToRefs(store)
|
||||
|
||||
await expect(store.switchWorkspace('workspace-123')).rejects.toThrow(
|
||||
'network down'
|
||||
)
|
||||
|
||||
expect(error.value).toBeInstanceOf(Error)
|
||||
expect(error.value?.message).toBe('network down')
|
||||
})
|
||||
|
||||
it('sends correct request to API', async () => {
|
||||
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
@@ -455,6 +624,120 @@ describe('useWorkspaceAuthStore', () => {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('uses status text when the error body cannot be parsed', async () => {
|
||||
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Gateway Timeout',
|
||||
json: () => Promise.reject(new Error('bad json'))
|
||||
})
|
||||
)
|
||||
|
||||
const store = useWorkspaceAuthStore()
|
||||
const { error } = storeToRefs(store)
|
||||
|
||||
await expect(store.switchWorkspace('workspace-123')).rejects.toThrow(
|
||||
WorkspaceAuthError
|
||||
)
|
||||
|
||||
expect((error.value as WorkspaceAuthError).code).toBe(
|
||||
'TOKEN_EXCHANGE_FAILED'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not let an older switch overwrite a newer committed workspace', async () => {
|
||||
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||
let resolveFirst: (value: unknown) => void = () => {}
|
||||
const firstResponse = new Promise((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
const secondExpiry = new Date(Date.now() + 3600 * 1000).toISOString()
|
||||
const mockFetch = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(firstResponse)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
...mockTokenResponse,
|
||||
token: 'newer-token',
|
||||
expires_at: secondExpiry,
|
||||
workspace: { ...mockWorkspace, id: 'workspace-other' }
|
||||
})
|
||||
})
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const store = useWorkspaceAuthStore()
|
||||
const { currentWorkspace, workspaceToken } = storeToRefs(store)
|
||||
|
||||
const firstSwitch = store.switchWorkspace('workspace-123')
|
||||
await Promise.resolve()
|
||||
const secondSwitch = store.switchWorkspace('workspace-other')
|
||||
await secondSwitch
|
||||
|
||||
resolveFirst({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTokenResponse)
|
||||
})
|
||||
await firstSwitch
|
||||
|
||||
expect(currentWorkspace.value?.id).toBe('workspace-other')
|
||||
expect(workspaceToken.value).toBe('newer-token')
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'Aborting stale workspace switch: workspace context changed before commit'
|
||||
)
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('does not surface an older switch error after a newer workspace commits', async () => {
|
||||
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||
let resolveFirst: (value: unknown) => void = () => {}
|
||||
const firstResponse = new Promise((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
const mockFetch = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(firstResponse)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
...mockTokenResponse,
|
||||
token: 'newer-token',
|
||||
workspace: { ...mockWorkspace, id: 'workspace-other' }
|
||||
})
|
||||
})
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const store = useWorkspaceAuthStore()
|
||||
const { currentWorkspace, error } = storeToRefs(store)
|
||||
|
||||
const firstSwitch = store.switchWorkspace('workspace-123')
|
||||
await Promise.resolve()
|
||||
await store.switchWorkspace('workspace-other')
|
||||
|
||||
resolveFirst({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
json: () => Promise.resolve({ message: 'Server error' })
|
||||
})
|
||||
await firstSwitch
|
||||
|
||||
expect(currentWorkspace.value?.id).toBe('workspace-other')
|
||||
expect(error.value).toBeNull()
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'Aborting stale workspace switch: workspace context changed before error commit',
|
||||
expect.any(WorkspaceAuthError)
|
||||
)
|
||||
warn.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearWorkspaceContext', () => {
|
||||
@@ -504,6 +787,32 @@ describe('useWorkspaceAuthStore', () => {
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('warns when sessionStorage cannot be cleared', () => {
|
||||
const originalSessionStorage = globalThis.sessionStorage
|
||||
const throwingSessionStorage = createThrowingSessionStorage({
|
||||
removeItem: vi.fn(() => {
|
||||
throw new Error('blocked')
|
||||
})
|
||||
})
|
||||
vi.stubGlobal('sessionStorage', throwingSessionStorage)
|
||||
const consoleWarnSpy = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
const store = useWorkspaceAuthStore()
|
||||
|
||||
store.clearWorkspaceContext()
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'Failed to clear workspace context from sessionStorage'
|
||||
)
|
||||
} finally {
|
||||
vi.stubGlobal('sessionStorage', originalSessionStorage)
|
||||
consoleWarnSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('prevents in-flight refreshes from restoring cleared state', async () => {
|
||||
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||
const mockFetch = vi.fn().mockResolvedValueOnce({
|
||||
@@ -581,6 +890,25 @@ describe('useWorkspaceAuthStore', () => {
|
||||
Authorization: 'Bearer workspace-token-abc'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the raw workspace token only when present', async () => {
|
||||
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTokenResponse)
|
||||
})
|
||||
)
|
||||
|
||||
const store = useWorkspaceAuthStore()
|
||||
|
||||
expect(store.getWorkspaceToken()).toBeUndefined()
|
||||
|
||||
await store.switchWorkspace('workspace-123')
|
||||
|
||||
expect(store.getWorkspaceToken()).toBe('workspace-token-abc')
|
||||
})
|
||||
})
|
||||
|
||||
describe('token refresh scheduling', () => {
|
||||
@@ -614,6 +942,28 @@ describe('useWorkspaceAuthStore', () => {
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('destroy stops a scheduled token refresh', async () => {
|
||||
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||
const expiresInMs = 3600 * 1000
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
...mockTokenResponse,
|
||||
expires_at: new Date(Date.now() + expiresInMs).toISOString()
|
||||
})
|
||||
})
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const store = useWorkspaceAuthStore()
|
||||
|
||||
await store.switchWorkspace('workspace-123')
|
||||
store.destroy()
|
||||
await vi.advanceTimersByTimeAsync(expiresInMs)
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('clears context when refresh fails with ACCESS_DENIED', async () => {
|
||||
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||
const expiresInMs = 3600 * 1000
|
||||
@@ -878,6 +1228,49 @@ describe('useWorkspaceAuthStore', () => {
|
||||
consoleWarnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('clears context when transient refresh retries outlive the token expiry', async () => {
|
||||
const nearExpiry = Date.now() + 1
|
||||
sessionStorage.setItem(
|
||||
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE,
|
||||
JSON.stringify(mockWorkspaceWithRole)
|
||||
)
|
||||
sessionStorage.setItem(WORKSPACE_STORAGE_KEYS.TOKEN, 'nearly-expired')
|
||||
sessionStorage.setItem(
|
||||
WORKSPACE_STORAGE_KEYS.EXPIRES_AT,
|
||||
nearExpiry.toString()
|
||||
)
|
||||
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
json: () => Promise.resolve({ message: 'Server error' })
|
||||
})
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
const consoleWarnSpy = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const store = useWorkspaceAuthStore()
|
||||
const { currentWorkspace, workspaceToken } = storeToRefs(store)
|
||||
|
||||
expect(store.initializeFromSession()).toBe(true)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
await vi.advanceTimersByTimeAsync(4000)
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(4)
|
||||
expect(currentWorkspace.value).toBeNull()
|
||||
expect(workspaceToken.value).toBeNull()
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
consoleWarnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('clears context immediately on INVALID_FIREBASE_TOKEN without retrying', async () => {
|
||||
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||
const mockFetch = vi.fn().mockResolvedValueOnce({
|
||||
@@ -1823,6 +2216,62 @@ describe('useWorkspaceAuthStore', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('warns and resolves false when the login mint hits a transient error', async () => {
|
||||
mockUnifiedCloudAuthEnabled.value = true
|
||||
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
json: () => Promise.resolve({ message: 'try again' })
|
||||
})
|
||||
)
|
||||
const consoleWarnSpy = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const store = useWorkspaceAuthStore()
|
||||
const { unifiedToken } = storeToRefs(store)
|
||||
|
||||
const result = await store.mintAtLogin()
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(unifiedToken.value).toBeNull()
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'Unified login mint failed:',
|
||||
expect.any(WorkspaceAuthError)
|
||||
)
|
||||
|
||||
consoleWarnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('skips a scheduled unified refresh after the flag turns off', async () => {
|
||||
mockUnifiedCloudAuthEnabled.value = true
|
||||
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||
const expiresInMs = 3600 * 1000
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
...personalTokenResponse,
|
||||
expires_at: new Date(Date.now() + expiresInMs).toISOString()
|
||||
})
|
||||
})
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const store = useWorkspaceAuthStore()
|
||||
|
||||
await store.mintAtLogin()
|
||||
mockUnifiedCloudAuthEnabled.value = false
|
||||
await vi.advanceTimersByTimeAsync(expiresInMs - 5 * 60 * 1000)
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
expect(mockNotifyTokenRefreshed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never toasts from the unified lifecycle when the flag is OFF', async () => {
|
||||
mockUnifiedCloudAuthEnabled.value = false
|
||||
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||
|
||||
26
src/stores/electronDownloadStore.nonDesktop.test.ts
Normal file
26
src/stores/electronDownloadStore.nonDesktop.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 { useElectronDownloadStore } from '@/stores/electronDownloadStore'
|
||||
|
||||
const electronAPI = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({ isDesktop: false }))
|
||||
vi.mock('@/utils/envUtil', () => ({ electronAPI }))
|
||||
|
||||
describe('electronDownloadStore outside desktop', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
electronAPI.mockClear()
|
||||
})
|
||||
|
||||
it('skips the Electron bridge when not running on desktop', async () => {
|
||||
const store = useElectronDownloadStore()
|
||||
|
||||
await store.initialize()
|
||||
|
||||
expect(electronAPI).not.toHaveBeenCalled()
|
||||
expect(store.downloads).toEqual([])
|
||||
})
|
||||
})
|
||||
106
src/stores/electronDownloadStore.test.ts
Normal file
106
src/stores/electronDownloadStore.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { DownloadStatus } from '@comfyorg/comfyui-electron-types'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
|
||||
|
||||
const downloadManagerMock = vi.hoisted(() => ({
|
||||
cancelDownload: vi.fn(),
|
||||
getAllDownloads: vi.fn(),
|
||||
onDownloadProgress: vi.fn(),
|
||||
pauseDownload: vi.fn(),
|
||||
resumeDownload: vi.fn(),
|
||||
startDownload: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isDesktop: true
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: () => ({
|
||||
DownloadManager: downloadManagerMock
|
||||
})
|
||||
}))
|
||||
|
||||
describe('electronDownloadStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
Object.values(downloadManagerMock).forEach((mock) => mock.mockReset())
|
||||
downloadManagerMock.getAllDownloads.mockResolvedValue([
|
||||
{
|
||||
filename: 'done.bin',
|
||||
status: DownloadStatus.COMPLETED,
|
||||
url: 'https://example.com/done.bin'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('loads existing downloads and applies progress updates by URL', async () => {
|
||||
let progressCallback:
|
||||
| Parameters<typeof downloadManagerMock.onDownloadProgress>[0]
|
||||
| undefined
|
||||
downloadManagerMock.onDownloadProgress.mockImplementation((callback) => {
|
||||
progressCallback = callback
|
||||
})
|
||||
// The store runs initialize() automatically during setup; wait for it to
|
||||
// finish instead of calling it again (which would double-load downloads).
|
||||
const store = useElectronDownloadStore()
|
||||
await vi.waitFor(() => expect(progressCallback).toBeDefined())
|
||||
|
||||
progressCallback?.({
|
||||
filename: 'model.bin',
|
||||
progress: 25,
|
||||
savePath: '/tmp/model.bin',
|
||||
status: DownloadStatus.IN_PROGRESS,
|
||||
url: 'https://example.com/model.bin'
|
||||
})
|
||||
progressCallback?.({
|
||||
filename: 'model.bin',
|
||||
progress: 50,
|
||||
savePath: '/tmp/model.bin',
|
||||
status: DownloadStatus.IN_PROGRESS,
|
||||
url: 'https://example.com/model.bin'
|
||||
})
|
||||
|
||||
expect(store.findByUrl('https://example.com/done.bin')?.status).toBe(
|
||||
DownloadStatus.COMPLETED
|
||||
)
|
||||
expect(store.findByUrl('https://example.com/model.bin')).toMatchObject({
|
||||
filename: 'model.bin',
|
||||
progress: 50,
|
||||
status: DownloadStatus.IN_PROGRESS
|
||||
})
|
||||
expect(store.inProgressDownloads).toHaveLength(1)
|
||||
expect(store.downloads).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('delegates download controls to the Electron bridge', async () => {
|
||||
const store = useElectronDownloadStore()
|
||||
|
||||
await store.start({
|
||||
filename: 'model.bin',
|
||||
savePath: '/tmp/model.bin',
|
||||
url: 'https://example.com/model.bin'
|
||||
})
|
||||
await store.pause('https://example.com/model.bin')
|
||||
await store.resume('https://example.com/model.bin')
|
||||
await store.cancel('https://example.com/model.bin')
|
||||
|
||||
expect(downloadManagerMock.startDownload).toHaveBeenCalledWith(
|
||||
'https://example.com/model.bin',
|
||||
'/tmp/model.bin',
|
||||
'model.bin'
|
||||
)
|
||||
expect(downloadManagerMock.pauseDownload).toHaveBeenCalledWith(
|
||||
'https://example.com/model.bin'
|
||||
)
|
||||
expect(downloadManagerMock.resumeDownload).toHaveBeenCalledWith(
|
||||
'https://example.com/model.bin'
|
||||
)
|
||||
expect(downloadManagerMock.cancelDownload).toHaveBeenCalledWith(
|
||||
'https://example.com/model.bin'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,7 @@ import type { SystemStats } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
|
||||
const mockData = vi.hoisted(() => ({ isDesktop: false }))
|
||||
const mockData = vi.hoisted(() => ({ isCloud: false, isDesktop: false }))
|
||||
|
||||
// Mock the API
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
@@ -19,7 +19,9 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
get isDesktop() {
|
||||
return mockData.isDesktop
|
||||
},
|
||||
isCloud: false
|
||||
get isCloud() {
|
||||
return mockData.isCloud
|
||||
}
|
||||
}))
|
||||
|
||||
describe('useSystemStatsStore', () => {
|
||||
@@ -138,6 +140,7 @@ describe('useSystemStatsStore', () => {
|
||||
describe('getFormFactor', () => {
|
||||
beforeEach(() => {
|
||||
// Reset systemStats for each test
|
||||
mockData.isCloud = false
|
||||
store.systemStats = null
|
||||
})
|
||||
|
||||
@@ -162,6 +165,12 @@ describe('useSystemStatsStore', () => {
|
||||
expect(store.getFormFactor()).toBe('other')
|
||||
})
|
||||
|
||||
it('should return "cloud" in cloud mode', () => {
|
||||
mockData.isCloud = true
|
||||
|
||||
expect(store.getFormFactor()).toBe('cloud')
|
||||
})
|
||||
|
||||
describe('desktop environment', () => {
|
||||
beforeEach(() => {
|
||||
mockData.isDesktop = true
|
||||
|
||||
@@ -116,6 +116,33 @@ describe('useUserFileStore', () => {
|
||||
"Failed to load file 'file1.txt': 404 Not Found"
|
||||
)
|
||||
})
|
||||
|
||||
it('should skip loading temporary and already loaded files', async () => {
|
||||
const temporaryFile = UserFile.createTemporary('draft.txt')
|
||||
const loadedFile = new UserFile('file1.txt', 123, 100)
|
||||
loadedFile.content = 'content'
|
||||
loadedFile.originalContent = 'content'
|
||||
|
||||
await temporaryFile.load()
|
||||
await loadedFile.load()
|
||||
|
||||
expect(api.getUserData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should force reload loaded files', async () => {
|
||||
const file = new UserFile('file1.txt', 123, 100)
|
||||
file.content = 'old'
|
||||
file.originalContent = 'old'
|
||||
vi.mocked(api.getUserData).mockResolvedValue({
|
||||
status: 200,
|
||||
text: () => Promise.resolve('new')
|
||||
} as Response)
|
||||
|
||||
await file.load({ force: true })
|
||||
|
||||
expect(api.getUserData).toHaveBeenCalledWith('file1.txt')
|
||||
expect(file.content).toBe('new')
|
||||
})
|
||||
})
|
||||
|
||||
describe('save', () => {
|
||||
@@ -148,6 +175,60 @@ describe('useUserFileStore', () => {
|
||||
|
||||
expect(api.storeUserData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should save unmodified files when forced', async () => {
|
||||
const file = new UserFile('file1.txt', 123, 100)
|
||||
file.content = 'content'
|
||||
file.originalContent = 'content'
|
||||
vi.mocked(api.storeUserData).mockResolvedValue({
|
||||
status: 200,
|
||||
json: () => Promise.resolve('file1.txt')
|
||||
} as Response)
|
||||
|
||||
await file.save({ force: true })
|
||||
|
||||
expect(api.storeUserData).toHaveBeenCalledWith('file1.txt', 'content', {
|
||||
throwOnError: true,
|
||||
full_info: true,
|
||||
overwrite: true
|
||||
})
|
||||
expect(file.lastModified).toBe(123)
|
||||
expect(file.size).toBe(100)
|
||||
})
|
||||
|
||||
it('should normalize string modified times', async () => {
|
||||
const file = new UserFile('file1.txt', 123, 100)
|
||||
file.content = 'modified content'
|
||||
file.originalContent = 'original content'
|
||||
vi.mocked(api.storeUserData).mockResolvedValue({
|
||||
status: 200,
|
||||
json: () =>
|
||||
Promise.resolve({ modified: '2024-01-02T03:04:05Z', size: 200 })
|
||||
} as Response)
|
||||
|
||||
await file.save()
|
||||
|
||||
expect(file.lastModified).toBe(
|
||||
new Date('2024-01-02T03:04:05Z').getTime()
|
||||
)
|
||||
expect(file.size).toBe(200)
|
||||
})
|
||||
|
||||
it('should fall back when modified time is invalid', async () => {
|
||||
const dateNow = vi.spyOn(Date, 'now').mockReturnValue(999)
|
||||
const file = new UserFile('file1.txt', 123, 100)
|
||||
file.content = 'modified content'
|
||||
file.originalContent = 'original content'
|
||||
vi.mocked(api.storeUserData).mockResolvedValue({
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ modified: 'bad date', size: 200 })
|
||||
} as Response)
|
||||
|
||||
await file.save()
|
||||
|
||||
expect(file.lastModified).toBe(999)
|
||||
dateNow.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('delete', () => {
|
||||
@@ -161,6 +242,26 @@ describe('useUserFileStore', () => {
|
||||
|
||||
expect(api.deleteUserData).toHaveBeenCalledWith('file1.txt')
|
||||
})
|
||||
|
||||
it('should skip deleting temporary files', async () => {
|
||||
const file = UserFile.createTemporary('draft.txt')
|
||||
|
||||
await file.delete()
|
||||
|
||||
expect(api.deleteUserData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should throw when delete fails', async () => {
|
||||
const file = new UserFile('file1.txt', 123, 100)
|
||||
vi.mocked(api.deleteUserData).mockResolvedValue({
|
||||
status: 500,
|
||||
statusText: 'Server Error'
|
||||
} as Response)
|
||||
|
||||
await expect(file.delete()).rejects.toThrow(
|
||||
"Failed to delete file 'file1.txt': 500 Server Error"
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rename', () => {
|
||||
@@ -181,6 +282,41 @@ describe('useUserFileStore', () => {
|
||||
expect(file.lastModified).toBe(456)
|
||||
expect(file.size).toBe(200)
|
||||
})
|
||||
|
||||
it('should rename temporary files locally', async () => {
|
||||
const file = UserFile.createTemporary('draft.txt')
|
||||
|
||||
await file.rename('renamed.txt')
|
||||
|
||||
expect(api.moveUserData).not.toHaveBeenCalled()
|
||||
expect(file.path).toBe('renamed.txt')
|
||||
})
|
||||
|
||||
it('should throw when rename fails', async () => {
|
||||
const file = new UserFile('file1.txt', 123, 100)
|
||||
vi.mocked(api.moveUserData).mockResolvedValue({
|
||||
status: 409,
|
||||
statusText: 'Conflict'
|
||||
} as Response)
|
||||
|
||||
await expect(file.rename('newfile.txt')).rejects.toThrow(
|
||||
"Failed to rename file 'file1.txt': 409 Conflict"
|
||||
)
|
||||
})
|
||||
|
||||
it('should leave metadata unchanged when rename returns a string', async () => {
|
||||
const file = new UserFile('file1.txt', 123, 100)
|
||||
vi.mocked(api.moveUserData).mockResolvedValue({
|
||||
status: 200,
|
||||
json: () => Promise.resolve('newfile.txt')
|
||||
} as Response)
|
||||
|
||||
await file.rename('newfile.txt')
|
||||
|
||||
expect(file.path).toBe('newfile.txt')
|
||||
expect(file.lastModified).toBe(123)
|
||||
expect(file.size).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveAs', () => {
|
||||
@@ -207,6 +343,25 @@ describe('useUserFileStore', () => {
|
||||
expect(newFile.size).toBe(200)
|
||||
expect(newFile.content).toBe('file content')
|
||||
})
|
||||
|
||||
it('should save temporary files in place', async () => {
|
||||
const file = UserFile.createTemporary('draft.txt')
|
||||
file.content = 'file content'
|
||||
vi.mocked(api.storeUserData).mockResolvedValue({
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ modified: 456, size: 200 })
|
||||
} as Response)
|
||||
|
||||
const newFile = await file.saveAs('newfile.txt')
|
||||
|
||||
expect(api.storeUserData).toHaveBeenCalledWith(
|
||||
'draft.txt',
|
||||
'file content',
|
||||
{ throwOnError: true, full_info: true, overwrite: false }
|
||||
)
|
||||
expect(newFile).toBe(file)
|
||||
expect(newFile.path).toBe('draft.txt')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,61 +1,72 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useUserStore } from './userStore'
|
||||
|
||||
const getUserConfig = vi.fn()
|
||||
const apiMock = vi.hoisted(() => ({
|
||||
createUser: vi.fn(),
|
||||
getUserConfig: vi.fn(),
|
||||
user: undefined as string | undefined
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
getUserConfig: (...args: unknown[]) => getUserConfig(...args)
|
||||
}
|
||||
api: apiMock
|
||||
}))
|
||||
|
||||
describe('userStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
getUserConfig.mockReset()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
apiMock.createUser.mockReset()
|
||||
apiMock.getUserConfig.mockReset()
|
||||
apiMock.user = undefined
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('initialize', () => {
|
||||
it('returns an empty user list before initialization', () => {
|
||||
const store = useUserStore()
|
||||
|
||||
expect(store.users).toEqual([])
|
||||
})
|
||||
|
||||
it('fetches user config on first call', async () => {
|
||||
getUserConfig.mockResolvedValue({})
|
||||
apiMock.getUserConfig.mockResolvedValue({})
|
||||
const store = useUserStore()
|
||||
|
||||
await store.initialize()
|
||||
|
||||
expect(getUserConfig).toHaveBeenCalledTimes(1)
|
||||
expect(apiMock.getUserConfig).toHaveBeenCalledTimes(1)
|
||||
expect(store.initialized).toBe(true)
|
||||
})
|
||||
|
||||
it('is a no-op once already initialized', async () => {
|
||||
getUserConfig.mockResolvedValue({})
|
||||
apiMock.getUserConfig.mockResolvedValue({})
|
||||
const store = useUserStore()
|
||||
await store.initialize()
|
||||
getUserConfig.mockClear()
|
||||
apiMock.getUserConfig.mockClear()
|
||||
|
||||
await store.initialize()
|
||||
|
||||
expect(getUserConfig).not.toHaveBeenCalled()
|
||||
expect(apiMock.getUserConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retries on a subsequent call when the first fetch failed', async () => {
|
||||
getUserConfig.mockRejectedValueOnce(new Error('network down'))
|
||||
getUserConfig.mockResolvedValueOnce({})
|
||||
apiMock.getUserConfig.mockRejectedValueOnce(new Error('network down'))
|
||||
apiMock.getUserConfig.mockResolvedValueOnce({})
|
||||
const store = useUserStore()
|
||||
|
||||
await expect(store.initialize()).rejects.toThrow('network down')
|
||||
expect(store.initialized).toBe(false)
|
||||
await expect(store.initialize()).resolves.toBeUndefined()
|
||||
|
||||
expect(getUserConfig).toHaveBeenCalledTimes(2)
|
||||
expect(apiMock.getUserConfig).toHaveBeenCalledTimes(2)
|
||||
expect(store.initialized).toBe(true)
|
||||
})
|
||||
|
||||
it('deduplicates concurrent calls before the first fetch resolves', async () => {
|
||||
let resolveConfig: (value: unknown) => void = () => {}
|
||||
getUserConfig.mockImplementation(
|
||||
apiMock.getUserConfig.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveConfig = resolve
|
||||
@@ -68,7 +79,100 @@ describe('userStore', () => {
|
||||
resolveConfig({})
|
||||
await Promise.all([a, b])
|
||||
|
||||
expect(getUserConfig).toHaveBeenCalledTimes(1)
|
||||
expect(apiMock.getUserConfig).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('derives multi-user state and restores the current user from storage', async () => {
|
||||
localStorage['Comfy.userId'] = 'user-2'
|
||||
apiMock.getUserConfig.mockResolvedValue({
|
||||
users: { 'user-1': 'Ada', 'user-2': 'Grace' }
|
||||
})
|
||||
const store = useUserStore()
|
||||
|
||||
await store.initialize()
|
||||
|
||||
expect(store.isMultiUserServer).toBe(true)
|
||||
expect(store.needsLogin).toBe(false)
|
||||
expect(store.users).toEqual([
|
||||
{ userId: 'user-1', username: 'Ada' },
|
||||
{ userId: 'user-2', username: 'Grace' }
|
||||
])
|
||||
expect(store.currentUser).toEqual({ userId: 'user-2', username: 'Grace' })
|
||||
await vi.waitFor(() => expect(apiMock.user).toBe('user-2'))
|
||||
})
|
||||
|
||||
it('requires login on multi-user servers without a stored user', async () => {
|
||||
apiMock.getUserConfig.mockResolvedValue({
|
||||
users: { 'user-1': 'Ada' }
|
||||
})
|
||||
const store = useUserStore()
|
||||
|
||||
await store.initialize()
|
||||
|
||||
expect(store.needsLogin).toBe(true)
|
||||
expect(store.currentUser).toBeNull()
|
||||
expect(apiMock.user).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createUser', () => {
|
||||
it('returns the created user id with the requested username', async () => {
|
||||
apiMock.createUser.mockResolvedValue({
|
||||
json: () => Promise.resolve('user-1'),
|
||||
status: 201
|
||||
})
|
||||
const store = useUserStore()
|
||||
|
||||
await expect(store.createUser('Ada')).resolves.toEqual({
|
||||
userId: 'user-1',
|
||||
username: 'Ada'
|
||||
})
|
||||
})
|
||||
|
||||
it('throws API errors returned by user creation', async () => {
|
||||
apiMock.createUser.mockResolvedValue({
|
||||
json: () => Promise.resolve({ error: 'name taken' }),
|
||||
status: 409,
|
||||
statusText: 'Conflict'
|
||||
})
|
||||
const store = useUserStore()
|
||||
|
||||
await expect(store.createUser('Ada')).rejects.toThrow('name taken')
|
||||
})
|
||||
|
||||
it('throws a fallback error when user creation has no error body', async () => {
|
||||
apiMock.createUser.mockResolvedValue({
|
||||
json: () => Promise.resolve({}),
|
||||
status: 500,
|
||||
statusText: 'Server Error'
|
||||
})
|
||||
const store = useUserStore()
|
||||
|
||||
await expect(store.createUser('Ada')).rejects.toThrow(
|
||||
'Error creating user: 500 Server Error'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('login/logout', () => {
|
||||
it('persists login identity and clears it on logout', async () => {
|
||||
const store = useUserStore()
|
||||
|
||||
await store.login({ userId: 'user-1', username: 'Ada' })
|
||||
expect(localStorage['Comfy.userId']).toBe('user-1')
|
||||
expect(localStorage['Comfy.userName']).toBe('Ada')
|
||||
|
||||
await store.logout()
|
||||
expect(localStorage['Comfy.userId']).toBeUndefined()
|
||||
expect(localStorage['Comfy.userName']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not set api.user when login happens before user config loads', async () => {
|
||||
const store = useUserStore()
|
||||
|
||||
await store.login({ userId: 'user-1', username: 'Ada' })
|
||||
|
||||
expect(apiMock.user).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import type { BottomPanelExtension } from '@/types/extensionTypes'
|
||||
|
||||
const { mockRegisterCommand } = vi.hoisted(() => ({
|
||||
mockRegisterCommand: vi.fn()
|
||||
}))
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/composables/bottomPanelTabs/useShortcutsTab', () => ({
|
||||
useShortcutsTab: () => [
|
||||
@@ -44,7 +48,7 @@ vi.mock('@/composables/bottomPanelTabs/useTerminalTabs', () => ({
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({
|
||||
registerCommand: vi.fn()
|
||||
registerCommand: mockRegisterCommand
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -59,6 +63,8 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
describe('useBottomPanelStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
mockRegisterCommand.mockClear()
|
||||
mockData.isDesktop = false
|
||||
})
|
||||
|
||||
it('should initialize with empty panels', () => {
|
||||
@@ -86,6 +92,39 @@ describe('useBottomPanelStore', () => {
|
||||
tab
|
||||
)
|
||||
expect(store.panels.terminal.activeTabId).toBe('test-tab')
|
||||
expect(mockRegisterCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'Workspace.ToggleBottomPanelTab.test-tab',
|
||||
label: 'Toggle Test Tab Bottom Panel'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('uses titleKey and id fallbacks in registered command labels', () => {
|
||||
const store = useBottomPanelStore()
|
||||
|
||||
store.registerBottomPanelTab({
|
||||
id: 'title-key-tab',
|
||||
titleKey: 'panel.titleKey',
|
||||
component: {},
|
||||
type: 'vue'
|
||||
})
|
||||
store.registerBottomPanelTab({
|
||||
id: 'id-fallback-tab',
|
||||
component: {},
|
||||
type: 'vue'
|
||||
})
|
||||
|
||||
expect(mockRegisterCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
label: 'Toggle panel.titleKey Bottom Panel'
|
||||
})
|
||||
)
|
||||
expect(mockRegisterCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
label: 'Toggle id-fallback-tab Bottom Panel'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should toggle panel visibility', () => {
|
||||
@@ -114,6 +153,14 @@ describe('useBottomPanelStore', () => {
|
||||
expect(store.bottomPanelVisible).toBe(false)
|
||||
})
|
||||
|
||||
it('does not open an empty panel', () => {
|
||||
const store = useBottomPanelStore()
|
||||
|
||||
store.togglePanel('terminal')
|
||||
|
||||
expect(store.activePanel).toBeNull()
|
||||
})
|
||||
|
||||
it('should switch between panel types', () => {
|
||||
const store = useBottomPanelStore()
|
||||
|
||||
@@ -147,6 +194,31 @@ describe('useBottomPanelStore', () => {
|
||||
expect(store.activeBottomPanelTab?.id).toBe('shortcuts-tab')
|
||||
})
|
||||
|
||||
it('sets active tab only when a panel is active', () => {
|
||||
const store = useBottomPanelStore()
|
||||
store.setActiveTab('missing')
|
||||
expect(store.activeBottomPanelTabId).toBe('')
|
||||
|
||||
store.registerBottomPanelTab({
|
||||
id: 'first',
|
||||
title: 'First',
|
||||
component: {},
|
||||
type: 'vue',
|
||||
targetPanel: 'shortcuts'
|
||||
})
|
||||
store.registerBottomPanelTab({
|
||||
id: 'second',
|
||||
title: 'Second',
|
||||
component: {},
|
||||
type: 'vue',
|
||||
targetPanel: 'shortcuts'
|
||||
})
|
||||
store.togglePanel('shortcuts')
|
||||
|
||||
store.setActiveTab('second')
|
||||
expect(store.activeBottomPanelTab?.id).toBe('second')
|
||||
})
|
||||
|
||||
it('should toggle specific tabs', () => {
|
||||
const store = useBottomPanelStore()
|
||||
const tab: BottomPanelExtension = {
|
||||
@@ -168,4 +240,84 @@ describe('useBottomPanelStore', () => {
|
||||
store.toggleBottomPanelTab('specific-tab')
|
||||
expect(store.activePanel).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores toggles for unknown bottom panel tabs', () => {
|
||||
const store = useBottomPanelStore()
|
||||
|
||||
store.toggleBottomPanelTab('missing-tab')
|
||||
|
||||
expect(store.activePanel).toBeNull()
|
||||
})
|
||||
|
||||
it('toggles terminal when available and shortcuts otherwise', () => {
|
||||
const store = useBottomPanelStore()
|
||||
const shortcutsTab: BottomPanelExtension = {
|
||||
id: 'shortcuts-tab',
|
||||
title: 'Shortcuts',
|
||||
component: {},
|
||||
type: 'vue',
|
||||
targetPanel: 'shortcuts'
|
||||
}
|
||||
const terminalTab: BottomPanelExtension = {
|
||||
id: 'terminal-tab',
|
||||
title: 'Terminal',
|
||||
component: {},
|
||||
type: 'vue',
|
||||
targetPanel: 'terminal'
|
||||
}
|
||||
|
||||
store.registerBottomPanelTab(shortcutsTab)
|
||||
store.toggleBottomPanel()
|
||||
expect(store.activePanel).toBe('shortcuts')
|
||||
|
||||
store.registerBottomPanelTab(terminalTab)
|
||||
store.toggleBottomPanel()
|
||||
expect(store.activePanel).toBe('terminal')
|
||||
})
|
||||
|
||||
it('registers extension bottom panel tabs when present', () => {
|
||||
const store = useBottomPanelStore()
|
||||
|
||||
store.registerExtensionBottomPanelTabs({
|
||||
name: 'extension',
|
||||
bottomPanelTabs: [
|
||||
{
|
||||
id: 'extension-tab',
|
||||
title: 'Extension',
|
||||
component: {},
|
||||
type: 'vue',
|
||||
targetPanel: 'shortcuts'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
expect(store.panels.shortcuts.tabs.map((tab) => tab.id)).toEqual([
|
||||
'extension-tab'
|
||||
])
|
||||
})
|
||||
|
||||
it('ignores extensions without bottom panel tabs', () => {
|
||||
const store = useBottomPanelStore()
|
||||
|
||||
store.registerExtensionBottomPanelTabs({ name: 'extension' })
|
||||
|
||||
expect(store.panels.shortcuts.tabs).toHaveLength(0)
|
||||
expect(store.panels.terminal.tabs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('registers core tabs including desktop command terminal', async () => {
|
||||
mockData.isDesktop = true
|
||||
const store = useBottomPanelStore()
|
||||
|
||||
await store.registerCoreBottomPanelTabs()
|
||||
|
||||
expect(store.panels.shortcuts.tabs.map((tab) => tab.id)).toEqual([
|
||||
'shortcuts-essentials',
|
||||
'shortcuts-view-controls'
|
||||
])
|
||||
expect(store.panels.terminal.tabs.map((tab) => tab.id)).toEqual([
|
||||
'logs',
|
||||
'command'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
108
src/stores/workspace/colorPaletteStore.test.ts
Normal file
108
src/stores/workspace/colorPaletteStore.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
CORE_COLOR_PALETTES,
|
||||
DEFAULT_DARK_COLOR_PALETTE,
|
||||
DEFAULT_LIGHT_COLOR_PALETTE
|
||||
} from '@/constants/coreColorPalettes'
|
||||
import type { Palette } from '@/schemas/colorPaletteSchema'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
|
||||
function createPalette(overrides: Partial<Palette> = {}): Palette {
|
||||
return {
|
||||
id: 'custom',
|
||||
name: 'Custom',
|
||||
colors: {
|
||||
node_slot: {},
|
||||
litegraph_base: {},
|
||||
comfy_base: {}
|
||||
},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('useColorPaletteStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('adds and deletes custom palettes', () => {
|
||||
const store = useColorPaletteStore()
|
||||
const palette = createPalette()
|
||||
|
||||
store.addCustomPalette(palette)
|
||||
|
||||
expect(store.isCustomPalette('custom')).toBe(true)
|
||||
expect(store.activePaletteId).toBe('custom')
|
||||
expect(store.palettesLookup.custom).toStrictEqual(palette)
|
||||
|
||||
store.deleteCustomPalette('custom')
|
||||
|
||||
expect(store.isCustomPalette('custom')).toBe(false)
|
||||
expect(store.activePaletteId).toBe(CORE_COLOR_PALETTES.dark.id)
|
||||
})
|
||||
|
||||
it('rejects duplicate and missing custom palette operations', () => {
|
||||
const store = useColorPaletteStore()
|
||||
|
||||
expect(() =>
|
||||
store.addCustomPalette(
|
||||
createPalette({
|
||||
id: CORE_COLOR_PALETTES.dark.id
|
||||
})
|
||||
)
|
||||
).toThrow(`Palette with id ${CORE_COLOR_PALETTES.dark.id} already exists`)
|
||||
|
||||
expect(() => store.deleteCustomPalette('missing')).toThrow(
|
||||
'Palette with id missing does not exist'
|
||||
)
|
||||
})
|
||||
|
||||
it('completes dark palettes and mirrors menu background when secondary is missing', () => {
|
||||
const store = useColorPaletteStore()
|
||||
const completed = store.completePalette(
|
||||
createPalette({
|
||||
colors: {
|
||||
node_slot: {},
|
||||
litegraph_base: {},
|
||||
comfy_base: {
|
||||
'comfy-menu-bg': '#101010'
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(completed.colors.comfy_base['comfy-menu-secondary-bg']).toBe(
|
||||
'#101010'
|
||||
)
|
||||
expect(completed.colors.node_slot.CLIP).toBe(
|
||||
DEFAULT_DARK_COLOR_PALETTE.colors.node_slot.CLIP
|
||||
)
|
||||
})
|
||||
|
||||
it('completes light palettes without overwriting an existing secondary menu background', () => {
|
||||
const store = useColorPaletteStore()
|
||||
const completed = store.completePalette(
|
||||
createPalette({
|
||||
light_theme: true,
|
||||
colors: {
|
||||
node_slot: {},
|
||||
litegraph_base: {},
|
||||
comfy_base: {
|
||||
'comfy-menu-bg': '#ffffff',
|
||||
'comfy-menu-secondary-bg': '#eeeeee'
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(completed.colors.comfy_base['comfy-menu-secondary-bg']).toBe(
|
||||
'#eeeeee'
|
||||
)
|
||||
expect(completed.colors.node_slot.CLIP).toBe(
|
||||
DEFAULT_LIGHT_COLOR_PALETTE.colors.node_slot.CLIP
|
||||
)
|
||||
})
|
||||
})
|
||||
302
src/stores/workspace/favoritedWidgetsStore.test.ts
Normal file
302
src/stores/workspace/favoritedWidgetsStore.test.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const { mockState } = vi.hoisted(() => ({
|
||||
mockState: {
|
||||
graph: null as { extra: Record<string, unknown> } | null,
|
||||
nodes: {} as Record<string, unknown>,
|
||||
setDirty: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
get rootGraph() {
|
||||
return mockState.graph
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
activeWorkflow: undefined,
|
||||
nodeToNodeLocatorId: (node: { id: unknown }) => String(node.id),
|
||||
nodeIdToNodeLocatorId: (id: unknown) => String(id)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({ canvas: { setDirty: mockState.setDirty } })
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
getNodeByLocatorId: (_graph: unknown, id: string) =>
|
||||
mockState.nodes[id] ?? null
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/nodeTitleUtil', () => ({
|
||||
resolveNodeDisplayName: (node: { title?: string }) => node.title ?? 'Node'
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
st: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
interface FakeWidget {
|
||||
name: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
function makeWidget({ name, label }: FakeWidget): IBaseWidget {
|
||||
return {
|
||||
name,
|
||||
label,
|
||||
options: {},
|
||||
type: 'number',
|
||||
y: 0
|
||||
} as IBaseWidget
|
||||
}
|
||||
|
||||
function makeNode(id: number, widgets: FakeWidget[] = [], title = 'My Node') {
|
||||
const node = new LGraphNode(title)
|
||||
node.id = toNodeId(id)
|
||||
node.title = title
|
||||
node.widgets = widgets.map(makeWidget)
|
||||
return node
|
||||
}
|
||||
|
||||
function registerNode(node: { id: unknown }) {
|
||||
mockState.nodes[String(node.id)] = node
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockState.graph = { extra: {} }
|
||||
mockState.nodes = {}
|
||||
mockState.setDirty = vi.fn()
|
||||
})
|
||||
|
||||
describe('favoritedWidgetsStore', () => {
|
||||
it('adds a favorite, marks workflow dirty, and persists to graph.extra', () => {
|
||||
const store = useFavoritedWidgetsStore()
|
||||
const node = makeNode(1, [{ name: 'seed' }])
|
||||
registerNode(node)
|
||||
|
||||
store.addFavorite(node, 'seed')
|
||||
|
||||
expect(store.isFavorited(node, 'seed')).toBe(true)
|
||||
expect(mockState.setDirty).toHaveBeenCalledWith(true, true)
|
||||
expect(mockState.graph?.extra.favoritedWidgets).toEqual({
|
||||
favorites: [{ nodeLocatorId: '1', widgetName: 'seed' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('does not add the same favorite twice', () => {
|
||||
const store = useFavoritedWidgetsStore()
|
||||
const node = makeNode(1, [{ name: 'seed' }])
|
||||
registerNode(node)
|
||||
|
||||
store.addFavorite(node, 'seed')
|
||||
const persisted = structuredClone(mockState.graph?.extra.favoritedWidgets)
|
||||
const dirtyCalls = mockState.setDirty.mock.calls.length
|
||||
|
||||
store.addFavorite(node, 'seed')
|
||||
|
||||
expect(store.favoritedWidgets).toHaveLength(1)
|
||||
expect(mockState.graph?.extra.favoritedWidgets).toEqual(persisted)
|
||||
expect(mockState.setDirty).toHaveBeenCalledTimes(dirtyCalls)
|
||||
})
|
||||
|
||||
it('removes a favorite and treats removing an absent one as a no-op', () => {
|
||||
const store = useFavoritedWidgetsStore()
|
||||
const node = makeNode(1, [{ name: 'seed' }])
|
||||
registerNode(node)
|
||||
store.addFavorite(node, 'seed')
|
||||
const persisted = structuredClone(mockState.graph?.extra.favoritedWidgets)
|
||||
const dirtyCalls = mockState.setDirty.mock.calls.length
|
||||
|
||||
store.removeFavorite(node, 'missing')
|
||||
expect(store.isFavorited(node, 'seed')).toBe(true)
|
||||
expect(mockState.graph?.extra.favoritedWidgets).toEqual(persisted)
|
||||
expect(mockState.setDirty).toHaveBeenCalledTimes(dirtyCalls)
|
||||
|
||||
store.removeFavorite(node, 'seed')
|
||||
expect(store.isFavorited(node, 'seed')).toBe(false)
|
||||
})
|
||||
|
||||
it('toggles favorite state in both directions', () => {
|
||||
const store = useFavoritedWidgetsStore()
|
||||
const node = makeNode(1, [{ name: 'seed' }])
|
||||
registerNode(node)
|
||||
|
||||
store.toggleFavorite(node, 'seed')
|
||||
expect(store.isFavorited(node, 'seed')).toBe(true)
|
||||
|
||||
store.toggleFavorite(node, 'seed')
|
||||
expect(store.isFavorited(node, 'seed')).toBe(false)
|
||||
})
|
||||
|
||||
it('resolves a valid favorite to a node/widget with a composed label', () => {
|
||||
const store = useFavoritedWidgetsStore()
|
||||
const node = makeNode(7, [{ name: 'cfg', label: 'CFG Scale' }], 'KSampler')
|
||||
registerNode(node)
|
||||
|
||||
store.addFavorite(node, 'cfg')
|
||||
|
||||
const [resolved] = store.favoritedWidgets
|
||||
expect(resolved.label).toBe('KSampler / CFG Scale')
|
||||
expect(store.validFavoritedWidgets).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('labels favorites whose node was deleted and excludes them from valid', () => {
|
||||
const store = useFavoritedWidgetsStore()
|
||||
const node = makeNode(2, [{ name: 'seed' }])
|
||||
registerNode(node)
|
||||
store.addFavorite(node, 'seed')
|
||||
|
||||
delete mockState.nodes['2']
|
||||
|
||||
expect(store.favoritedWidgets[0].label).toContain('(node deleted)')
|
||||
expect(store.validFavoritedWidgets).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('labels favorites whose widget no longer exists', () => {
|
||||
const store = useFavoritedWidgetsStore()
|
||||
const node = makeNode(3, [{ name: 'seed' }])
|
||||
registerNode(node)
|
||||
store.addFavorite(node, 'seed')
|
||||
|
||||
mockState.nodes['3'] = makeNode(3, [], 'My Node')
|
||||
|
||||
expect(store.favoritedWidgets[0].label).toContain('(widget not found)')
|
||||
})
|
||||
|
||||
it('prunes invalid favorites while keeping valid ones', () => {
|
||||
const store = useFavoritedWidgetsStore()
|
||||
const valid = makeNode(1, [{ name: 'seed' }])
|
||||
const stale = makeNode(2, [{ name: 'steps' }])
|
||||
registerNode(valid)
|
||||
registerNode(stale)
|
||||
store.addFavorite(valid, 'seed')
|
||||
store.addFavorite(stale, 'steps')
|
||||
|
||||
delete mockState.nodes['2']
|
||||
store.pruneInvalidFavorites()
|
||||
|
||||
expect(store.favoritedWidgets).toHaveLength(1)
|
||||
expect(store.isFavorited(valid, 'seed')).toBe(true)
|
||||
})
|
||||
|
||||
it('reorders favorites to match the provided order', () => {
|
||||
const store = useFavoritedWidgetsStore()
|
||||
const a = makeNode(1, [{ name: 'seed' }])
|
||||
const b = makeNode(2, [{ name: 'steps' }])
|
||||
registerNode(a)
|
||||
registerNode(b)
|
||||
store.addFavorite(a, 'seed')
|
||||
store.addFavorite(b, 'steps')
|
||||
|
||||
store.reorderFavorites([...store.validFavoritedWidgets].reverse())
|
||||
|
||||
expect(store.favoritedWidgets.map((fw) => fw.nodeLocatorId)).toEqual([
|
||||
'2',
|
||||
'1'
|
||||
])
|
||||
})
|
||||
|
||||
it('clears all favorites', () => {
|
||||
const store = useFavoritedWidgetsStore()
|
||||
const node = makeNode(1, [{ name: 'seed' }])
|
||||
registerNode(node)
|
||||
store.addFavorite(node, 'seed')
|
||||
|
||||
store.clearFavorites()
|
||||
|
||||
expect(store.favoritedWidgets).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('loads favorites from graph.extra on init, normalizing legacy nodeId entries', () => {
|
||||
mockState.graph = {
|
||||
extra: {
|
||||
favoritedWidgets: {
|
||||
favorites: [
|
||||
{ nodeLocatorId: '1', widgetName: 'seed' },
|
||||
{ nodeId: 2, widgetName: 'steps' },
|
||||
{ widgetName: 'no-node' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
registerNode(makeNode(1, [{ name: 'seed' }]))
|
||||
registerNode(makeNode(2, [{ name: 'steps' }]))
|
||||
|
||||
const store = useFavoritedWidgetsStore()
|
||||
|
||||
expect(store.favoritedWidgets.map((fw) => fw.nodeLocatorId)).toEqual([
|
||||
'1',
|
||||
'2'
|
||||
])
|
||||
})
|
||||
|
||||
it('ignores malformed favorites when loading from graph.extra', () => {
|
||||
mockState.graph = {
|
||||
extra: {
|
||||
favoritedWidgets: {
|
||||
favorites: [
|
||||
{ nodeLocatorId: '1', widgetName: 'seed' },
|
||||
{ nodeLocatorId: 'bad:locator', widgetName: 'bad-locator' },
|
||||
{ nodeLocatorId: 42, widgetName: 'number-locator' },
|
||||
{ nodeLocatorId: '2', widgetName: '' },
|
||||
{ nodeId: '', widgetName: 'bad-node' },
|
||||
null,
|
||||
{ widgetName: 'missing-node' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
registerNode(makeNode(1, [{ name: 'seed' }]))
|
||||
|
||||
const store = useFavoritedWidgetsStore()
|
||||
|
||||
expect(store.favoritedWidgets.map((fw) => fw.nodeLocatorId)).toEqual(['1'])
|
||||
})
|
||||
|
||||
it('loads an empty list when the graph is not available', () => {
|
||||
mockState.graph = null
|
||||
|
||||
const store = useFavoritedWidgetsStore()
|
||||
|
||||
expect(store.favoritedWidgets).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not save when pruning already valid favorites', () => {
|
||||
const store = useFavoritedWidgetsStore()
|
||||
const node = makeNode(1, [{ name: 'seed' }])
|
||||
registerNode(node)
|
||||
store.addFavorite(node, 'seed')
|
||||
const dirtyCalls = mockState.setDirty.mock.calls.length
|
||||
|
||||
store.pruneInvalidFavorites()
|
||||
|
||||
expect(store.favoritedWidgets).toHaveLength(1)
|
||||
expect(mockState.setDirty).toHaveBeenCalledTimes(dirtyCalls)
|
||||
})
|
||||
|
||||
it('labels existing favorites when the graph is not loaded', () => {
|
||||
const node = makeNode(1, [{ name: 'seed' }])
|
||||
registerNode(node)
|
||||
const store = useFavoritedWidgetsStore()
|
||||
store.addFavorite(node, 'seed')
|
||||
|
||||
mockState.graph = null
|
||||
|
||||
expect(store.favoritedWidgets[0].label).toContain('(graph not loaded)')
|
||||
store.clearFavorites()
|
||||
expect(store.favoritedWidgets).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -5,12 +5,21 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
|
||||
const { mockGetSetting, mockRegisterCommand, mockRegisterCommands } =
|
||||
vi.hoisted(() => ({
|
||||
mockGetSetting: vi.fn(),
|
||||
mockRegisterCommand: vi.fn(),
|
||||
mockRegisterCommands: vi.fn()
|
||||
}))
|
||||
const {
|
||||
mockCommands,
|
||||
mockGetSetting,
|
||||
mockRegisterCommand,
|
||||
mockRegisterCommands,
|
||||
mockT,
|
||||
mockTe
|
||||
} = vi.hoisted(() => ({
|
||||
mockCommands: [] as Array<{ id: string; function?: () => void }>,
|
||||
mockGetSetting: vi.fn(),
|
||||
mockRegisterCommand: vi.fn(),
|
||||
mockRegisterCommands: vi.fn(),
|
||||
mockT: vi.fn((key: string) => `translated:${key}`),
|
||||
mockTe: vi.fn((_key: string) => false)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
@@ -21,7 +30,7 @@ vi.mock('@/platform/settings/settingStore', () => ({
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({
|
||||
registerCommand: mockRegisterCommand,
|
||||
commands: []
|
||||
commands: mockCommands
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -32,8 +41,8 @@ vi.mock('@/stores/menuItemStore', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key,
|
||||
te: () => false
|
||||
t: mockT,
|
||||
te: mockTe
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/sidebarTabs/useAssetsSidebarTab', () => ({
|
||||
@@ -96,7 +105,11 @@ vi.mock('@/platform/workflow/management/composables/useAppsSidebarTab', () => ({
|
||||
describe('useSidebarTabStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
mockCommands.length = 0
|
||||
mockGetSetting.mockReset()
|
||||
mockT.mockClear()
|
||||
mockTe.mockReset()
|
||||
mockTe.mockReturnValue(false)
|
||||
mockRegisterCommand.mockClear()
|
||||
mockRegisterCommands.mockClear()
|
||||
})
|
||||
@@ -120,6 +133,22 @@ describe('useSidebarTabStore', () => {
|
||||
expect(mockRegisterCommand).toHaveBeenCalledTimes(6)
|
||||
})
|
||||
|
||||
it('removes the job history tab when QPO V2 is toggled off', async () => {
|
||||
const qpoV2Enabled = ref(true)
|
||||
mockGetSetting.mockImplementation((key: string) =>
|
||||
key === 'Comfy.Queue.QPOV2' ? qpoV2Enabled.value : undefined
|
||||
)
|
||||
|
||||
const store = useSidebarTabStore()
|
||||
store.registerCoreSidebarTabs()
|
||||
expect(store.sidebarTabs[0].id).toBe('job-history')
|
||||
|
||||
qpoV2Enabled.value = false
|
||||
await nextTick()
|
||||
|
||||
expect(store.sidebarTabs.map((tab) => tab.id)).not.toContain('job-history')
|
||||
})
|
||||
|
||||
it('does not register the job history tab when QPO V2 is disabled', () => {
|
||||
mockGetSetting.mockImplementation((key: string) =>
|
||||
key === 'Comfy.Queue.QPOV2' ? false : undefined
|
||||
@@ -160,4 +189,96 @@ describe('useSidebarTabStore', () => {
|
||||
])
|
||||
expect(mockRegisterCommand).toHaveBeenCalledTimes(6)
|
||||
})
|
||||
|
||||
it('registers command metadata and toggles a custom sidebar tab', async () => {
|
||||
mockTe.mockImplementation((key: string) => key === 'custom.title')
|
||||
const store = useSidebarTabStore()
|
||||
store.registerSidebarTab({
|
||||
id: 'custom',
|
||||
title: 'custom.title',
|
||||
tooltip: 'custom.tooltip',
|
||||
icon: { render: () => null },
|
||||
type: 'vue',
|
||||
component: {}
|
||||
})
|
||||
|
||||
const command = mockRegisterCommand.mock.calls[0][0]
|
||||
expect(command.icon).toBeUndefined()
|
||||
expect(command.label()).toBe('Toggle translated:custom.title Sidebar')
|
||||
expect(command.tooltip).toBe('custom.tooltip')
|
||||
expect(command.menubarLabel()).toBe('custom.title')
|
||||
|
||||
await command.function()
|
||||
expect(store.activeSidebarTabId).toBe('custom')
|
||||
expect(command.active()).toBe(true)
|
||||
|
||||
await command.function()
|
||||
expect(store.activeSidebarTabId).toBeNull()
|
||||
})
|
||||
|
||||
it('uses translated menubar labels for known core tabs', () => {
|
||||
mockTe.mockImplementation((key: string) => key === 'sideToolbar.assets')
|
||||
const store = useSidebarTabStore()
|
||||
store.registerSidebarTab({
|
||||
id: 'assets',
|
||||
title: 'assets',
|
||||
type: 'vue',
|
||||
component: {}
|
||||
})
|
||||
|
||||
const command = mockRegisterCommand.mock.calls[0][0]
|
||||
|
||||
expect(command.menubarLabel()).toBe('translated:sideToolbar.assets')
|
||||
})
|
||||
|
||||
it('delegates model library command to BrowseModelAssets when asset API is enabled', async () => {
|
||||
const browseModelAssets = vi.fn()
|
||||
mockCommands.push({
|
||||
id: 'Comfy.BrowseModelAssets',
|
||||
function: browseModelAssets
|
||||
})
|
||||
mockGetSetting.mockImplementation((key: string) =>
|
||||
key === 'Comfy.Assets.UseAssetAPI' ? true : undefined
|
||||
)
|
||||
const store = useSidebarTabStore()
|
||||
store.registerSidebarTab({
|
||||
id: 'model-library',
|
||||
title: 'Models',
|
||||
type: 'vue',
|
||||
component: {}
|
||||
})
|
||||
|
||||
const command = mockRegisterCommand.mock.calls[0][0]
|
||||
await command.function()
|
||||
|
||||
expect(browseModelAssets).toHaveBeenCalledOnce()
|
||||
expect(store.activeSidebarTabId).toBeNull()
|
||||
})
|
||||
|
||||
it('destroys custom tabs and clears active state on unregister', () => {
|
||||
const destroy = vi.fn()
|
||||
const store = useSidebarTabStore()
|
||||
store.registerSidebarTab({
|
||||
id: 'custom',
|
||||
title: 'Custom',
|
||||
type: 'custom',
|
||||
render: vi.fn(),
|
||||
destroy
|
||||
})
|
||||
store.toggleSidebarTab('custom')
|
||||
|
||||
store.unregisterSidebarTab('custom')
|
||||
|
||||
expect(destroy).toHaveBeenCalledOnce()
|
||||
expect(store.sidebarTabs).toHaveLength(0)
|
||||
expect(store.activeSidebarTabId).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores unregister requests for missing tabs', () => {
|
||||
const store = useSidebarTabStore()
|
||||
|
||||
store.unregisterSidebarTab('missing')
|
||||
|
||||
expect(store.sidebarTabs).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
115
src/stores/workspaceStore.test.ts
Normal file
115
src/stores/workspaceStore.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
|
||||
const storeMocks = vi.hoisted(() => ({
|
||||
apiKeyAuthStore: {
|
||||
isAuthenticated: false
|
||||
},
|
||||
authStore: {
|
||||
currentUser: null as null | { uid: string }
|
||||
},
|
||||
commandStore: {
|
||||
commands: [],
|
||||
execute: vi.fn()
|
||||
},
|
||||
executionErrorStore: {
|
||||
lastExecutionError: null,
|
||||
lastNodeErrors: null
|
||||
},
|
||||
queueSettingsStore: {},
|
||||
settingStore: {
|
||||
settingsById: {},
|
||||
get: vi.fn(),
|
||||
set: vi.fn()
|
||||
},
|
||||
sidebarTabStore: {
|
||||
registerSidebarTab: vi.fn(),
|
||||
unregisterSidebarTab: vi.fn(),
|
||||
sidebarTabs: []
|
||||
},
|
||||
toastStore: {},
|
||||
workflowStore: {}
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useMagicKeys: () => ({ shift: false })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => storeMocks.settingStore
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => storeMocks.toastStore
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => storeMocks.workflowStore
|
||||
}))
|
||||
|
||||
vi.mock('@/services/colorPaletteService', () => ({
|
||||
useColorPaletteService: () => ({})
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/apiKeyAuthStore', () => ({
|
||||
useApiKeyAuthStore: () => storeMocks.apiKeyAuthStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => storeMocks.authStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => storeMocks.commandStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/executionErrorStore', () => ({
|
||||
useExecutionErrorStore: () => storeMocks.executionErrorStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/queueStore', () => ({
|
||||
useQueueSettingsStore: () => storeMocks.queueSettingsStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/bottomPanelStore', () => ({
|
||||
useBottomPanelStore: () => ({})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
|
||||
useSidebarTabStore: () => storeMocks.sidebarTabStore
|
||||
}))
|
||||
|
||||
describe('useWorkspaceStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
storeMocks.apiKeyAuthStore.isAuthenticated = false
|
||||
storeMocks.authStore.currentUser = null
|
||||
})
|
||||
|
||||
it('reports logged out when neither auth source is active', () => {
|
||||
const store = useWorkspaceStore()
|
||||
|
||||
expect(store.user.isLoggedIn).toBe(false)
|
||||
})
|
||||
|
||||
it('reports logged in for API-key auth', () => {
|
||||
storeMocks.apiKeyAuthStore.isAuthenticated = true
|
||||
const store = useWorkspaceStore()
|
||||
|
||||
expect(store.user.isLoggedIn).toBe(true)
|
||||
})
|
||||
|
||||
it('reports logged in for Firebase auth', () => {
|
||||
storeMocks.authStore.currentUser = { uid: 'user-1' }
|
||||
const store = useWorkspaceStore()
|
||||
|
||||
expect(store.user.isLoggedIn).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user