Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.51.3",
"version": "1.51.4",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
Expand Down
45 changes: 33 additions & 12 deletions src/platform/cloud/subscription/composables/useSubscription.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,29 +360,50 @@ describe('useSubscription', () => {
)
})

it('does not apply status after the active workspace changes', async () => {
let resolveStatus: (value: {
it('does not apply the previous account response after an identity switch', async () => {
let resolvePreviousAccount!: (value: {
is_active: boolean
has_funds: boolean
billing_rail: 'stripe'
}) => void = () => {}
mockGetBillingStatus.mockReturnValue(
new Promise((resolve) => {
resolveStatus = resolve
}) => void
mockGetBillingStatus
.mockReturnValueOnce(
new Promise((resolve) => {
resolvePreviousAccount = resolve
})
)
.mockResolvedValueOnce({
is_active: false,
has_funds: false,
billing_rail: 'legacy_stripe'
})
)

const { fetchStatus } = useSubscriptionWithScope()
const statusRequest = fetchStatus()
const { subscriptionStatus, fetchStatus } = useSubscriptionWithScope()
const previousAccountRequest = fetchStatus()

mockUserId.value = 'user-456'
mockActiveWorkspaceId.value = 'workspace-456'
resolveStatus({
const currentAccountRequest = fetchStatus()
await currentAccountRequest

resolvePreviousAccount({
is_active: true,
has_funds: true,
billing_rail: 'stripe'
})
await statusRequest
await previousAccountRequest

expect(mockSetWorkspaceBillingRail).not.toHaveBeenCalled()
expect(mockGetBillingStatus).toHaveBeenCalledTimes(2)
expect(subscriptionStatus.value).toEqual({
is_active: false,
has_funds: false,
billing_rail: 'legacy_stripe'
})
expect(mockSetWorkspaceBillingRail).toHaveBeenCalledOnce()
expect(mockSetWorkspaceBillingRail).toHaveBeenCalledWith(
'workspace-456',
'legacy_stripe'
)
})

it('coalesces concurrent callers into one fetch', async () => {
Expand Down
46 changes: 34 additions & 12 deletions src/platform/cloud/subscription/composables/useSubscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,21 +350,42 @@ function useSubscriptionInternal() {

// Coalesce concurrent callers so an auth/session-rotation burst mints one fetch.
let inFlightStatusFetch: Promise<BillingStatusResponse | null> | null = null
let latestStatusRequestId = 0
let inFlightStatusOwnerId: string | null = null
let inFlightStatusWorkspaceId: string | null = null

async function fetchSubscriptionStatus(): Promise<BillingStatusResponse | null> {
if (inFlightStatusFetch) return inFlightStatusFetch
inFlightStatusFetch = performFetchSubscriptionStatus().finally(() => {
inFlightStatusFetch = null
})
return inFlightStatusFetch
const ownerId = authStore.userId ?? null
const workspaceId = workspaceStore.activeWorkspaceId
if (
inFlightStatusFetch &&
inFlightStatusOwnerId === ownerId &&
inFlightStatusWorkspaceId === workspaceId
) {
return inFlightStatusFetch
}

const fetchPromise = performFetchSubscriptionStatus(ownerId, workspaceId)
inFlightStatusFetch = fetchPromise
inFlightStatusOwnerId = ownerId
inFlightStatusWorkspaceId = workspaceId
void fetchPromise
.catch(() => undefined)
.finally(() => {
if (inFlightStatusFetch === fetchPromise) {
inFlightStatusFetch = null
inFlightStatusOwnerId = null
inFlightStatusWorkspaceId = null
}
})
return fetchPromise
}

async function performFetchSubscriptionStatus(): Promise<BillingStatusResponse | null> {
async function performFetchSubscriptionStatus(
ownerId: string | null,
workspaceId: string | null
): Promise<BillingStatusResponse | null> {
if (!isCloud) return null

const requestId = ++latestStatusRequestId
const workspaceId = workspaceStore.activeWorkspaceId
let statusData: BillingStatusResponse
try {
statusData = await workspaceApi.getBillingStatus()
Expand All @@ -376,8 +397,8 @@ function useSubscriptionInternal() {
)
}
if (
requestId !== latestStatusRequestId ||
workspaceId !== workspaceStore.activeWorkspaceId
(authStore.userId ?? null) !== ownerId ||
workspaceStore.activeWorkspaceId !== workspaceId
) {
return null
}
Expand Down Expand Up @@ -424,7 +445,8 @@ function useSubscriptionInternal() {
})

watch(
() => [authStore.isInitialized, isLoggedIn.value] as const,
() =>
[authStore.isInitialized, isLoggedIn.value, authStore.userId] as const,
async ([authInitialized, loggedIn]) => {
if (!authInitialized) {
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,24 @@ const state = vi.hoisted(() => ({
showSettingsDialog: vi.fn()
}))

const workspaceStoreMock = vi.hoisted(() => ({
store: null as null | {
initState: string
workspaceName: string
isInPersonalWorkspace: boolean
}
}))

vi.mock('@/platform/workspace/stores/teamWorkspaceStore', async () => {
const { reactive, ref } = await import('vue')
workspaceStoreMock.store = reactive({
initState: ref('ready'),
workspaceName: ref('Personal Workspace'),
isInPersonalWorkspace: ref(true)
})
return { useTeamWorkspaceStore: () => workspaceStoreMock.store }
})

vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({
userDisplayName: ref('Liz'),
Expand Down Expand Up @@ -105,43 +123,19 @@ const i18n = createI18n({
messages: { en: enMessages }
})

function createWorkspaceState(
type: 'personal' | 'team',
role: 'owner' | 'member'
) {
return {
id: `ws-${type}`,
name: `${type === 'personal' ? 'Personal' : 'Team'} Workspace`,
type,
role,
created_at: '2026-01-01T00:00:00Z',
joined_at: '2026-01-01T00:00:00Z',
isSubscribed: true,
subscriptionPlan: 'team-pro-monthly',
subscriptionTier: 'PRO',
members: [],
pendingInvites: []
}
}

function renderComponent(
type: 'personal' | 'team' = 'personal',
role: 'owner' | 'member' = 'member',
accountActionsOnly = false
) {
if (!workspaceStoreMock.store) throw new Error('Workspace store not ready')
workspaceStoreMock.store.workspaceName = `${type === 'personal' ? 'Personal' : 'Team'} Workspace`
workspaceStoreMock.store.isInPersonalWorkspace = type === 'personal'
return render(CurrentUserPopoverWorkspace, {
props: { accountActionsOnly },
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn,
initialState: {
teamWorkspace: {
initState: 'ready',
activeWorkspaceId: `ws-${type}`,
workspaces: [createWorkspaceState(type, role)]
}
}
createSpy: vi.fn
}),
PrimeVue,
i18n
Expand Down Expand Up @@ -189,7 +183,7 @@ describe('CurrentUserPopoverWorkspace', () => {
})

it('keeps account actions available without workspace context', () => {
renderComponent('personal', 'member', true)
renderComponent('personal', true)

expect(screen.getByTestId('user-settings-menu-item')).toBeInTheDocument()
expect(screen.getByTestId('logout-menu-item')).toBeInTheDocument()
Expand All @@ -204,7 +198,7 @@ describe('CurrentUserPopoverWorkspace', () => {

it('exposes the full workspace name on hover', async () => {
const user = userEvent.setup()
renderComponent('team', 'member')
renderComponent('team')

await user.hover(screen.getByTestId('workspace-switcher-trigger'))

Expand Down Expand Up @@ -240,7 +234,7 @@ describe('CurrentUserPopoverWorkspace', () => {
})

it('keeps a team workspace member read-only', () => {
renderComponent('team', 'member')
renderComponent('team')

expect(screen.getByText('211')).toBeInTheDocument()
expect(screen.queryByTestId('add-credits-button')).not.toBeInTheDocument()
Expand Down Expand Up @@ -307,7 +301,7 @@ describe('CurrentUserPopoverWorkspace', () => {
state.canManageSubscription = canManageSubscription
state.canManageSubscriptionLifecycle = canManageSubscriptionLifecycle

renderComponent('team', 'owner')
renderComponent('team')

const subscribeAction = screen.queryByRole('button', { name: action })
if (visible) {
Expand All @@ -324,7 +318,7 @@ describe('CurrentUserPopoverWorkspace', () => {
state.canTopUp = true
state.canManageSubscription = true
state.canManageSubscriptionLifecycle = true
renderComponent('team', 'owner')
renderComponent('team')

expect(screen.getByTestId('add-credits-button')).toBeInTheDocument()
expect(screen.getByTestId('plans-pricing-menu-item')).toBeInTheDocument()
Expand All @@ -339,7 +333,7 @@ describe('CurrentUserPopoverWorkspace', () => {
it(`opens workspace plan management for a ${workspaceType} owner`, async () => {
const user = userEvent.setup()
state.canManageSubscription = true
const { emitted } = renderComponent(workspaceType, 'owner')
const { emitted } = renderComponent(workspaceType)

const menuItem = screen.getByTestId('manage-plan-menu-item')
expect(menuItem).toHaveTextContent(enMessages.subscription.managePlan)
Expand Down
35 changes: 17 additions & 18 deletions src/platform/workspace/stores/partnerNodeGovernanceStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,20 @@ import type {
} from '@/platform/workspace/api/partnerNodePolicyApi'
import { PartnerNodePolicyApiError } from '@/platform/workspace/api/partnerNodePolicyApi'
import { usePartnerNodeGovernanceStore } from '@/platform/workspace/stores/partnerNodeGovernanceStore'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'

const mockTeamWorkspaceStore = vi.hoisted(() => ({
store: null as null | {
activeWorkspace: null | { id: string; type: 'personal' | 'team' }
}
}))

vi.mock('@/platform/workspace/stores/teamWorkspaceStore', async () => {
const { reactive } = await import('vue')
mockTeamWorkspaceStore.store = reactive({ activeWorkspace: null })
return {
useTeamWorkspaceStore: () => mockTeamWorkspaceStore.store
}
})

const mockGetPartnerNodePolicy = vi.hoisted(() => vi.fn())
const mockGetPartnerProviders = vi.hoisted(() => vi.fn())
Expand Down Expand Up @@ -48,23 +61,9 @@ const providers: PartnerProvider[] = [
]

function activateWorkspace(id: string, type: 'personal' | 'team' = 'team') {
const store = useTeamWorkspaceStore()
store.workspaces = [
{
id,
name: id,
type,
role: 'owner',
created_at: '2026-01-01T00:00:00Z',
joined_at: '2026-01-01T00:00:00Z',
isSubscribed: false,
subscriptionPlan: null,
subscriptionTier: null,
members: [],
pendingInvites: []
}
]
store.activeWorkspaceId = id
if (!mockTeamWorkspaceStore.store)
throw new Error('Workspace store not ready')
mockTeamWorkspaceStore.store.activeWorkspace = { id, type }
}

async function createLoadedStore() {
Expand Down
50 changes: 2 additions & 48 deletions src/platform/workspace/stores/teamWorkspaceStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -528,9 +528,10 @@ describe('useTeamWorkspaceStore', () => {
])(
'reloads $reloads time(s) when the active $type workspace is revoked',
async ({ workspace, reloads }) => {
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
mockWorkspaceAuthStore.currentWorkspace = workspace
const store = useTeamWorkspaceStore()
await store.initialize()
store.activeWorkspaceId = workspace.id

const handled = store.forgetRevokedActiveWorkspace(workspace.id)

Expand All @@ -551,7 +552,6 @@ describe('useTeamWorkspaceStore', () => {
it('is a no-op when the workspace is not the active one', async () => {
const store = useTeamWorkspaceStore()
await store.initialize()
store.activeWorkspaceId = mockTeamWorkspace.id

const handled = store.forgetRevokedActiveWorkspace('some-other-workspace')

Expand Down Expand Up @@ -1608,52 +1608,6 @@ describe('useTeamWorkspaceStore', () => {
)
})

it('resendInvite updates the originating workspace after a workspace switch', async () => {
const originalInvite = {
id: 'inv-1',
email: 'one@test.com',
token: 'token-1',
invited_at: '2024-01-01T00:00:00Z',
expires_at: '2024-01-08T00:00:00Z'
}
const refreshedInvite = {
id: 'inv-1',
email: 'one@test.com',
invited_at: '2024-02-01T00:00:00Z',
expires_at: '2024-02-08T00:00:00Z'
}
let resolveResend!: (invite: typeof refreshedInvite) => void

mockWorkspaceApi.listInvites.mockResolvedValue({
invites: [originalInvite]
})
mockWorkspaceApi.resendInvite.mockReturnValue(
new Promise((resolve) => {
resolveResend = resolve
})
)
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace

const store = useTeamWorkspaceStore()
await store.initialize()
await store.fetchPendingInvites()

const resend = store.resendInvite('inv-1')
store.activeWorkspaceId = mockPersonalWorkspace.id
resolveResend(refreshedInvite)
await resend

const teamWorkspace = store.workspaces.find(
(workspace) => workspace.id === mockTeamWorkspace.id
)
expect(teamWorkspace?.pendingInvites[0].expiryDate).toEqual(
new Date('2024-02-08T00:00:00Z')
)
expect(store.activeWorkspace?.id).toBe(mockPersonalWorkspace.id)
expect(store.pendingInvites).toEqual([])
})

it('resendInvite rejects a concurrent resend for the same invite', async () => {
const inviteOne = {
id: 'inv-1',
Expand Down
Loading
Loading