mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-23 08:15:05 +00:00
Compare commits
5 Commits
bl/fix-sha
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0f4feb111 | ||
|
|
d4be483c03 | ||
|
|
8d0b21e9e8 | ||
|
|
69858538d0 | ||
|
|
001b132b0c |
138
browser_tests/tests/currentUserPopoverCredits.spec.ts
Normal file
138
browser_tests/tests/currentUserPopoverCredits.spec.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
|
||||
import type { WorkspaceTokenResponse } from '@/platform/workspace/stores/workspaceAuthStore'
|
||||
import type { operations } from '@/types/comfyRegistryTypes'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
type CustomerBalanceResponse = NonNullable<
|
||||
operations['GetCustomerBalance']['responses']['200']['content']['application/json']
|
||||
>
|
||||
|
||||
const PERSONAL_WORKSPACE_NAME = 'Personal Workspace'
|
||||
const FUTURE_DATE = '2099-01-01T00:00:00Z'
|
||||
|
||||
const mockRemoteConfig: RemoteConfig = { team_workspaces_enabled: true }
|
||||
|
||||
const mockListWorkspacesResponse: { workspaces: WorkspaceWithRole[] } = {
|
||||
workspaces: [
|
||||
{
|
||||
id: 'ws-personal',
|
||||
name: PERSONAL_WORKSPACE_NAME,
|
||||
type: 'personal',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
joined_at: '2026-01-01T00:00:00Z',
|
||||
role: 'owner'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const mockTokenResponse: WorkspaceTokenResponse = {
|
||||
token: 'mock-workspace-token',
|
||||
expires_at: FUTURE_DATE,
|
||||
workspace: {
|
||||
id: 'ws-personal',
|
||||
name: PERSONAL_WORKSPACE_NAME,
|
||||
type: 'personal'
|
||||
},
|
||||
role: 'owner',
|
||||
permissions: []
|
||||
}
|
||||
|
||||
// Cancelled but still active: `end_date` set (cancelled) while `is_active` is
|
||||
// true. A personal owner in this state sees BOTH "Add credits" and "Resubscribe"
|
||||
// in the credits row.
|
||||
const mockSubscriptionStatus: CloudSubscriptionStatusResponse = {
|
||||
is_active: true,
|
||||
subscription_id: 'sub_e2e',
|
||||
renewal_date: FUTURE_DATE,
|
||||
end_date: FUTURE_DATE
|
||||
}
|
||||
|
||||
// ~6.3M credits — a 7-digit balance is what pushes the second action button out
|
||||
// of the popover before the fix.
|
||||
const mockBalance: CustomerBalanceResponse = {
|
||||
amount_micros: 3_000_000,
|
||||
effective_balance_micros: 3_000_000,
|
||||
currency: 'usd'
|
||||
}
|
||||
|
||||
const test = comfyPageFixture.extend({
|
||||
page: async ({ page }, use) => {
|
||||
await page.route('**/api/features', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockRemoteConfig)
|
||||
})
|
||||
)
|
||||
|
||||
await page.route('**/api/workspaces', async (route) => {
|
||||
if (route.request().method() !== 'GET') {
|
||||
await route.fallback()
|
||||
return
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockListWorkspacesResponse)
|
||||
})
|
||||
})
|
||||
|
||||
await page.route('**/api/auth/token', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockTokenResponse)
|
||||
})
|
||||
)
|
||||
|
||||
await page.route('**/customers/cloud-subscription-status', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockSubscriptionStatus)
|
||||
})
|
||||
)
|
||||
|
||||
await page.route('**/customers/balance', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockBalance)
|
||||
})
|
||||
)
|
||||
|
||||
await use(page)
|
||||
}
|
||||
})
|
||||
|
||||
test.describe('Current user popover credits row', { tag: '@cloud' }, () => {
|
||||
test('keeps both action buttons inside the popover when cancelled but active', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const page = comfyPage.page
|
||||
|
||||
await comfyPage.toast.closeToasts()
|
||||
await page.getByRole('button', { name: 'Current user' }).click()
|
||||
|
||||
const popover = page.locator('.current-user-popover')
|
||||
await expect(popover).toBeVisible()
|
||||
|
||||
const addCredits = page.getByTestId('add-credits-button')
|
||||
const resubscribe = page.getByRole('button', { name: 'Resubscribe' })
|
||||
await expect(addCredits).toBeVisible()
|
||||
await expect(resubscribe).toBeVisible()
|
||||
|
||||
const popoverBox = await popover.boundingBox()
|
||||
const resubscribeBox = await resubscribe.boundingBox()
|
||||
expect(popoverBox).not.toBeNull()
|
||||
expect(resubscribeBox).not.toBeNull()
|
||||
|
||||
const popoverRight = popoverBox!.x + popoverBox!.width
|
||||
const resubscribeRight = resubscribeBox!.x + resubscribeBox!.width
|
||||
expect(resubscribeRight).toBeLessThanOrEqual(popoverRight)
|
||||
})
|
||||
})
|
||||
@@ -66,7 +66,6 @@ import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
||||
import { useResultGallery } from '@/composables/queue/useResultGallery'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useAssetSelectionStore } from '@/platform/assets/composables/useAssetSelectionStore'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
@@ -195,20 +194,15 @@ const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
|
||||
const jobId = item.taskRef?.jobId
|
||||
if (!jobId) return
|
||||
|
||||
if (item.state === 'running' || item.state === 'initialization') {
|
||||
// Running/initializing jobs: interrupt execution
|
||||
// Cloud backend uses deleteItem, local uses interrupt
|
||||
if (isCloud) {
|
||||
await api.deleteItem('queue', jobId)
|
||||
} else {
|
||||
await api.interrupt(jobId)
|
||||
}
|
||||
if (
|
||||
item.state === 'running' ||
|
||||
item.state === 'initialization' ||
|
||||
item.state === 'pending'
|
||||
) {
|
||||
// State-agnostic cancel (see api.ts cancelJob for the runtime-parity caveat).
|
||||
await api.cancelJob(jobId)
|
||||
executionStore.clearInitializationByJobId(jobId)
|
||||
await queueStore.update()
|
||||
} else if (item.state === 'pending') {
|
||||
// Pending jobs: remove from queue
|
||||
await api.deleteItem('queue', jobId)
|
||||
await queueStore.update()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -292,17 +286,8 @@ const interruptAll = wrapWithErrorHandlingAsync(async () => {
|
||||
|
||||
if (!jobIds.length) return
|
||||
|
||||
// Cloud backend supports cancelling specific jobs via /queue delete,
|
||||
// while /interrupt always targets the "first" job. Use the targeted API
|
||||
// on cloud to ensure we cancel the workflow the user clicked.
|
||||
if (isCloud) {
|
||||
await Promise.all(jobIds.map((id) => api.deleteItem('queue', id)))
|
||||
executionStore.clearInitializationByJobIds(jobIds)
|
||||
await queueStore.update()
|
||||
return
|
||||
}
|
||||
|
||||
await Promise.all(jobIds.map((id) => api.interrupt(id)))
|
||||
// State-agnostic batch cancel (see api.ts cancelJobs for the runtime-parity caveat).
|
||||
await api.cancelJobs(jobIds)
|
||||
executionStore.clearInitializationByJobIds(jobIds)
|
||||
await queueStore.update()
|
||||
})
|
||||
|
||||
@@ -68,12 +68,10 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
ComfyWorkflow: class {}
|
||||
}))
|
||||
|
||||
const interruptMock = vi.fn()
|
||||
const deleteItemMock = vi.fn()
|
||||
const cancelJobMock = vi.fn()
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
interrupt: (runningJobId: string | null) => interruptMock(runningJobId),
|
||||
deleteItem: (type: string, id: string) => deleteItemMock(type, id)
|
||||
cancelJob: (jobId: string) => cancelJobMock(jobId)
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -197,6 +195,7 @@ describe('useJobMenu', () => {
|
||||
}))
|
||||
queueStoreMock.update.mockResolvedValue(undefined)
|
||||
queueStoreMock.delete.mockResolvedValue(undefined)
|
||||
cancelJobMock.mockResolvedValue(undefined)
|
||||
mediaAssetActionsMock.deleteAssets.mockResolvedValue(false)
|
||||
mapTaskOutputToAssetItemMock.mockImplementation((task, output) => ({
|
||||
task,
|
||||
@@ -281,29 +280,18 @@ describe('useJobMenu', () => {
|
||||
expect(copyToClipboardMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.for([
|
||||
['running', interruptMock, deleteItemMock],
|
||||
['initialization', interruptMock, deleteItemMock]
|
||||
])('cancels %s job via interrupt', async ([state]) => {
|
||||
const { cancelJob } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: state as JobListItem['state'] }))
|
||||
it.for([['running'], ['initialization'], ['pending']])(
|
||||
'cancels %s job via the state-agnostic jobs-namespace endpoint',
|
||||
async ([state]) => {
|
||||
const { cancelJob } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: state as JobListItem['state'] }))
|
||||
|
||||
await cancelJob()
|
||||
await cancelJob()
|
||||
|
||||
expect(interruptMock).toHaveBeenCalledWith('job-1')
|
||||
expect(deleteItemMock).not.toHaveBeenCalled()
|
||||
expect(queueStoreMock.update).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels pending job via deleteItem', async () => {
|
||||
const { cancelJob } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: 'pending' }))
|
||||
|
||||
await cancelJob()
|
||||
|
||||
expect(deleteItemMock).toHaveBeenCalledWith('queue', 'job-1')
|
||||
expect(queueStoreMock.update).toHaveBeenCalled()
|
||||
})
|
||||
expect(cancelJobMock).toHaveBeenCalledWith('job-1')
|
||||
expect(queueStoreMock.update).toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('still updates queue for uncancellable states', async () => {
|
||||
const { cancelJob } = mountJobMenu()
|
||||
@@ -311,11 +299,22 @@ describe('useJobMenu', () => {
|
||||
|
||||
await cancelJob()
|
||||
|
||||
expect(interruptMock).not.toHaveBeenCalled()
|
||||
expect(deleteItemMock).not.toHaveBeenCalled()
|
||||
expect(cancelJobMock).not.toHaveBeenCalled()
|
||||
expect(queueStoreMock.update).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('propagates cancel failures from the API', async () => {
|
||||
cancelJobMock.mockRejectedValueOnce(new Error('Failed to cancel job'))
|
||||
const { cancelJob } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: 'running' }))
|
||||
|
||||
await expect(cancelJob()).rejects.toThrow('Failed to cancel job')
|
||||
|
||||
expect(cancelJobMock).toHaveBeenCalledWith('job-1')
|
||||
// Queue refresh is skipped when the cancel request itself fails.
|
||||
expect(queueStoreMock.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('copies error message from failed job entry', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
@@ -860,7 +859,7 @@ describe('useJobMenu', () => {
|
||||
const cancelEntry = findActionEntry(jobMenuEntries.value, 'cancel-job')
|
||||
await cancelEntry?.onClick?.()
|
||||
|
||||
expect(deleteItemMock).toHaveBeenCalledWith('queue', 'job-1')
|
||||
expect(cancelJobMock).toHaveBeenCalledWith('job-1')
|
||||
expect(queueStoreMock.update).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { st, t } from '@/i18n'
|
||||
import { mapTaskOutputToAssetItem } from '@/platform/assets/composables/media/assetMappers'
|
||||
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
@@ -83,14 +82,13 @@ export function useJobMenu(
|
||||
const cancelJob = async (item?: JobListItem | null) => {
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
if (target.state === 'running' || target.state === 'initialization') {
|
||||
if (isCloud) {
|
||||
await api.deleteItem('queue', target.id)
|
||||
} else {
|
||||
await api.interrupt(target.id)
|
||||
}
|
||||
} else if (target.state === 'pending') {
|
||||
await api.deleteItem('queue', target.id)
|
||||
if (
|
||||
target.state === 'running' ||
|
||||
target.state === 'initialization' ||
|
||||
target.state === 'pending'
|
||||
) {
|
||||
// State-agnostic cancel (see api.ts cancelJob for the runtime-parity caveat).
|
||||
await api.cancelJob(target.id)
|
||||
}
|
||||
executionStore.clearInitializationByJobId(target.id)
|
||||
await queueStore.update()
|
||||
|
||||
@@ -2474,6 +2474,19 @@
|
||||
"confirmCancel": "Cancel subscription",
|
||||
"failed": "Failed to cancel subscription"
|
||||
},
|
||||
"downgrade": {
|
||||
"title": "Change to {plan} plan?",
|
||||
"body": "All other members of this workspace will be immediately removed.",
|
||||
"confirmationPhrase": "I understand",
|
||||
"confirmationPrompt": "Type \"{phrase}\" to confirm.",
|
||||
"confirm": "Change plan",
|
||||
"failed": "Failed to change plan",
|
||||
"notAllowed": "This plan change is not available",
|
||||
"paymentMethodRequired": "A payment method is required to change plans",
|
||||
"paymentPageBlocked": "Couldn't open the payment page — please try again",
|
||||
"memberRemovalFailed": "Couldn't remove {email} from the team — some members may already be removed and your plan was not changed",
|
||||
"failedAfterMemberRemoval": "Team members were removed, but the plan change didn't complete — please try again or contact support"
|
||||
},
|
||||
"partnerNodesBalance": "\"Partner Nodes\" Credit Balance",
|
||||
"partnerNodesDescription": "For running commercial/proprietary models",
|
||||
"totalCredits": "Total credits",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- A popover that shows current user information and actions -->
|
||||
<template>
|
||||
<div
|
||||
class="current-user-popover -m-3 w-80 rounded-lg border border-border-default bg-base-background p-2 shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
|
||||
class="current-user-popover -m-3 w-fit max-w-96 min-w-80 rounded-lg border border-border-default bg-base-background p-2 shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
<!-- User Info Section -->
|
||||
<div class="mb-4 flex flex-col items-center px-0 py-3">
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import DowngradeRemoveMembersDialogContent from './DowngradeRemoveMembersDialogContent.vue'
|
||||
|
||||
const mockCloseDialog = vi.fn()
|
||||
const mockToastAdd = vi.fn()
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: () => ({
|
||||
add: mockToastAdd
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({
|
||||
closeDialog: mockCloseDialog
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
messages: {
|
||||
en: {
|
||||
g: { cancel: 'Cancel', close: 'Close', unknownError: 'Unknown error' },
|
||||
subscription: {
|
||||
downgrade: {
|
||||
title: 'Change to {plan} plan?',
|
||||
body: 'All other members of this workspace will be immediately removed.',
|
||||
confirmationPhrase: 'I understand',
|
||||
confirmationPrompt: 'Type "{phrase}" to confirm.',
|
||||
confirm: 'Change plan',
|
||||
failed: 'Failed to change plan'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function mountComponent(props: Record<string, unknown> = {}) {
|
||||
const user = userEvent.setup()
|
||||
const onConfirm = vi.fn().mockResolvedValue(undefined)
|
||||
render(DowngradeRemoveMembersDialogContent, {
|
||||
props: {
|
||||
planName: 'Founder',
|
||||
planSlug: 'founder-monthly',
|
||||
onConfirm,
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
return { user, onConfirm }
|
||||
}
|
||||
|
||||
const getPhraseInput = () => screen.getByRole('textbox')
|
||||
const getChangePlanButton = () =>
|
||||
screen.getByRole('button', { name: 'Change plan' })
|
||||
const getCancelButton = () => screen.getByRole('button', { name: 'Cancel' })
|
||||
|
||||
describe('DowngradeRemoveMembersDialogContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('disables Change plan until the exact phrase is typed', async () => {
|
||||
const { user } = mountComponent()
|
||||
expect(getChangePlanButton()).toBeDisabled()
|
||||
|
||||
await user.type(getPhraseInput(), 'I understan')
|
||||
expect(getChangePlanButton()).toBeDisabled()
|
||||
|
||||
await user.type(getPhraseInput(), 'd')
|
||||
expect(getChangePlanButton()).toBeEnabled()
|
||||
})
|
||||
|
||||
it('keeps Change plan disabled for a case-mismatched phrase', async () => {
|
||||
const { user } = mountComponent()
|
||||
await user.type(getPhraseInput(), 'i understand')
|
||||
expect(getChangePlanButton()).toBeDisabled()
|
||||
})
|
||||
|
||||
it('invokes onConfirm with the plan slug and closes when confirmed', async () => {
|
||||
const { user, onConfirm } = mountComponent()
|
||||
await user.type(getPhraseInput(), 'I understand')
|
||||
await user.click(getChangePlanButton())
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith('founder-monthly')
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({
|
||||
key: 'downgrade-remove-members'
|
||||
})
|
||||
})
|
||||
|
||||
it('closes without calling onConfirm when cancelled', async () => {
|
||||
const { user, onConfirm } = mountComponent()
|
||||
await user.type(getPhraseInput(), 'I understand')
|
||||
await user.click(getCancelButton())
|
||||
|
||||
expect(onConfirm).not.toHaveBeenCalled()
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({
|
||||
key: 'downgrade-remove-members'
|
||||
})
|
||||
})
|
||||
|
||||
it('shows an error toast and stays open when onConfirm rejects', async () => {
|
||||
const onConfirm = vi.fn().mockRejectedValue(new Error('boom'))
|
||||
const { user } = mountComponent({ onConfirm })
|
||||
await user.type(getPhraseInput(), 'I understand')
|
||||
await user.click(getChangePlanButton())
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'error' })
|
||||
)
|
||||
expect(mockCloseDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[400px] flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||
{{ $t('subscription.downgrade.title', { plan: planName }) }}
|
||||
</h2>
|
||||
<button
|
||||
class="focus-visible:ring-secondary-foreground cursor-pointer rounded-sm border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:ring-1 focus-visible:outline-none"
|
||||
:aria-label="$t('g.close')"
|
||||
:disabled="isLoading"
|
||||
@click="onClose"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('subscription.downgrade.body') }}
|
||||
</p>
|
||||
<label class="flex flex-col gap-2 text-sm text-muted-foreground">
|
||||
{{ $t('subscription.downgrade.confirmationPrompt', { phrase }) }}
|
||||
<Input
|
||||
v-model="typedValue"
|
||||
type="text"
|
||||
:placeholder="phrase"
|
||||
:disabled="isLoading"
|
||||
autofocus
|
||||
@keyup.enter="onConfirmDowngrade"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 p-4">
|
||||
<Button variant="muted-textonly" :disabled="isLoading" @click="onClose">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
:disabled="!isConfirmed"
|
||||
:loading="isLoading"
|
||||
@click="onConfirmDowngrade"
|
||||
>
|
||||
{{ $t('subscription.downgrade.confirm') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Input from '@/components/ui/input/Input.vue'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { planName, planSlug, onConfirm } = defineProps<{
|
||||
planName: string
|
||||
planSlug: string
|
||||
onConfirm: (planSlug: string) => Promise<void>
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
const toast = useToast()
|
||||
|
||||
const phrase = t('subscription.downgrade.confirmationPhrase')
|
||||
|
||||
const typedValue = ref('')
|
||||
const isLoading = ref(false)
|
||||
|
||||
const isConfirmed = computed(() => typedValue.value === phrase)
|
||||
|
||||
function onClose() {
|
||||
if (isLoading.value) return
|
||||
dialogStore.closeDialog({ key: 'downgrade-remove-members' })
|
||||
}
|
||||
|
||||
async function onConfirmDowngrade() {
|
||||
if (!isConfirmed.value || isLoading.value) return
|
||||
isLoading.value = true
|
||||
try {
|
||||
await onConfirm(planSlug)
|
||||
dialogStore.closeDialog({ key: 'downgrade-remove-members' })
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('subscription.downgrade.failed'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError')
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,348 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { WorkspaceMember } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
import { useDowngradeToPersonal } from './useDowngradeToPersonal'
|
||||
|
||||
const mockMembers = ref<WorkspaceMember[]>([])
|
||||
const mockUserEmail = ref<string | null>(null)
|
||||
const mockRemoveMember = vi.hoisted(() => vi.fn())
|
||||
const mockFetchMembers = vi.hoisted(() => vi.fn())
|
||||
const mockSubscribe = vi.hoisted(() => vi.fn())
|
||||
const mockPreviewSubscribe = vi.hoisted(() => vi.fn())
|
||||
const mockStartOperation = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('pinia', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...(actual as object),
|
||||
storeToRefs: (store: Record<string, unknown>) => store
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
members: mockMembers,
|
||||
removeMember: mockRemoveMember,
|
||||
fetchMembers: mockFetchMembers
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/billingOperationStore', () => ({
|
||||
useBillingOperationStore: () => ({
|
||||
startOperation: mockStartOperation
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
subscribe: mockSubscribe,
|
||||
previewSubscribe: mockPreviewSubscribe
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({
|
||||
userEmail: mockUserEmail
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) =>
|
||||
params ? `${key} ${JSON.stringify(params)}` : key
|
||||
}))
|
||||
|
||||
vi.mock('@/config/comfyApi', () => ({
|
||||
getComfyPlatformBaseUrl: () => 'https://platform.test'
|
||||
}))
|
||||
|
||||
function createMember(
|
||||
overrides: Partial<WorkspaceMember> = {}
|
||||
): WorkspaceMember {
|
||||
return {
|
||||
id: 'member-1',
|
||||
name: 'Member One',
|
||||
email: 'member1@example.com',
|
||||
joinDate: new Date('2025-01-15'),
|
||||
role: 'member',
|
||||
isOriginalOwner: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function teamWithOwnerAnd(...memberIds: string[]) {
|
||||
return [
|
||||
createMember({
|
||||
id: 'owner',
|
||||
role: 'owner',
|
||||
email: 'owner@example.com',
|
||||
isOriginalOwner: true
|
||||
}),
|
||||
...memberIds.map((id) => createMember({ id, email: `${id}@example.com` }))
|
||||
]
|
||||
}
|
||||
|
||||
describe('useDowngradeToPersonal', () => {
|
||||
let windowOpen: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
mockMembers.value = []
|
||||
mockUserEmail.value = null
|
||||
mockPreviewSubscribe.mockResolvedValue({ allowed: true })
|
||||
mockSubscribe.mockResolvedValue({
|
||||
billing_op_id: 'op-1',
|
||||
status: 'subscribed'
|
||||
})
|
||||
windowOpen = vi.spyOn(window, 'open').mockReturnValue({} as Window)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
windowOpen.mockRestore()
|
||||
})
|
||||
|
||||
describe('removableMembers / hasOtherMembers', () => {
|
||||
it('protects only the original owner, removing promoted owners and members', () => {
|
||||
mockMembers.value = [
|
||||
createMember({ id: 'creator', role: 'owner', isOriginalOwner: true }),
|
||||
createMember({
|
||||
id: 'promoted-owner',
|
||||
role: 'owner',
|
||||
isOriginalOwner: false
|
||||
}),
|
||||
createMember({ id: 'member', role: 'member', isOriginalOwner: false })
|
||||
]
|
||||
const { removableMembers, hasOtherMembers } = useDowngradeToPersonal()
|
||||
expect(removableMembers.value.map((m) => m.id)).toEqual([
|
||||
'promoted-owner',
|
||||
'member'
|
||||
])
|
||||
expect(hasOtherMembers.value).toBe(true)
|
||||
})
|
||||
|
||||
it('reports no other members when only the original owner is present', () => {
|
||||
mockMembers.value = teamWithOwnerAnd()
|
||||
const { removableMembers, hasOtherMembers } = useDowngradeToPersonal()
|
||||
expect(removableMembers.value).toEqual([])
|
||||
expect(hasOtherMembers.value).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to protecting owners and the current user when the flag is absent', () => {
|
||||
mockUserEmail.value = 'me@example.com'
|
||||
mockMembers.value = [
|
||||
createMember({
|
||||
id: 'owner',
|
||||
role: 'owner',
|
||||
email: 'owner@example.com',
|
||||
isOriginalOwner: false
|
||||
}),
|
||||
createMember({
|
||||
id: 'me',
|
||||
role: 'member',
|
||||
email: 'me@example.com',
|
||||
isOriginalOwner: false
|
||||
}),
|
||||
createMember({
|
||||
id: 'plain',
|
||||
role: 'member',
|
||||
email: 'plain@example.com',
|
||||
isOriginalOwner: false
|
||||
})
|
||||
]
|
||||
const { removableMembers } = useDowngradeToPersonal()
|
||||
expect(removableMembers.value.map((m) => m.id)).toEqual(['plain'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('downgradeToPersonal', () => {
|
||||
it('removes every non-creator member then initiates the tier change', async () => {
|
||||
mockMembers.value = teamWithOwnerAnd('m1', 'm2')
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await downgradeToPersonal('founder-monthly')
|
||||
|
||||
expect(mockRemoveMember).toHaveBeenCalledTimes(2)
|
||||
expect(mockRemoveMember).toHaveBeenCalledWith('m1')
|
||||
expect(mockRemoveMember).toHaveBeenCalledWith('m2')
|
||||
expect(mockRemoveMember).not.toHaveBeenCalledWith('owner')
|
||||
expect(mockSubscribe).toHaveBeenCalledWith(
|
||||
'founder-monthly',
|
||||
'https://platform.test/payment/success',
|
||||
'https://platform.test/payment/failed'
|
||||
)
|
||||
expect(mockStartOperation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never removes the original owner', async () => {
|
||||
mockMembers.value = [
|
||||
createMember({ id: 'me', role: 'owner', isOriginalOwner: true })
|
||||
]
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await downgradeToPersonal('founder-monthly')
|
||||
|
||||
expect(mockRemoveMember).not.toHaveBeenCalled()
|
||||
expect(mockSubscribe).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('validates the transition before removing, then removes, then subscribes', async () => {
|
||||
mockMembers.value = teamWithOwnerAnd('m1')
|
||||
const calls: string[] = []
|
||||
mockPreviewSubscribe.mockImplementation(() => {
|
||||
calls.push('preview')
|
||||
return Promise.resolve({ allowed: true })
|
||||
})
|
||||
mockRemoveMember.mockImplementation(() => {
|
||||
calls.push('remove')
|
||||
return Promise.resolve()
|
||||
})
|
||||
mockSubscribe.mockImplementation(() => {
|
||||
calls.push('subscribe')
|
||||
return Promise.resolve({ billing_op_id: 'op-1', status: 'subscribed' })
|
||||
})
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await downgradeToPersonal('founder-monthly')
|
||||
|
||||
expect(calls).toEqual(['preview', 'remove', 'subscribe'])
|
||||
})
|
||||
|
||||
it('throws the BE reason and removes nobody when the transition is disallowed', async () => {
|
||||
mockMembers.value = teamWithOwnerAnd('m1')
|
||||
mockPreviewSubscribe.mockResolvedValue({
|
||||
allowed: false,
|
||||
reason: 'Outstanding balance'
|
||||
})
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await expect(downgradeToPersonal('founder-monthly')).rejects.toThrow(
|
||||
'Outstanding balance'
|
||||
)
|
||||
expect(mockRemoveMember).not.toHaveBeenCalled()
|
||||
expect(mockSubscribe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the payment-method page and polls when subscribe needs a payment method', async () => {
|
||||
mockMembers.value = teamWithOwnerAnd('m1')
|
||||
mockSubscribe.mockResolvedValue({
|
||||
billing_op_id: 'op-2',
|
||||
status: 'needs_payment_method',
|
||||
payment_method_url: 'https://pay.test/method'
|
||||
})
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await downgradeToPersonal('founder-monthly')
|
||||
|
||||
expect(windowOpen).toHaveBeenCalledWith(
|
||||
'https://pay.test/method',
|
||||
'_blank'
|
||||
)
|
||||
expect(mockStartOperation).toHaveBeenCalledWith('op-2', 'subscription')
|
||||
})
|
||||
|
||||
it('falls back to the generic message when the transition is disallowed without a reason', async () => {
|
||||
mockMembers.value = teamWithOwnerAnd('m1')
|
||||
mockPreviewSubscribe.mockResolvedValue({ allowed: false })
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await expect(downgradeToPersonal('founder-monthly')).rejects.toThrow(
|
||||
'subscription.downgrade.notAllowed'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws and skips polling when the payment tab is popup-blocked', async () => {
|
||||
mockMembers.value = teamWithOwnerAnd('m1')
|
||||
mockSubscribe.mockResolvedValue({
|
||||
billing_op_id: 'op-5',
|
||||
status: 'needs_payment_method',
|
||||
payment_method_url: 'https://pay.test/method'
|
||||
})
|
||||
windowOpen.mockReturnValue(null)
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await expect(downgradeToPersonal('founder-monthly')).rejects.toThrow(
|
||||
'subscription.downgrade.paymentPageBlocked'
|
||||
)
|
||||
expect(mockStartOperation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws when a payment method is needed but no url is provided', async () => {
|
||||
mockMembers.value = teamWithOwnerAnd('m1')
|
||||
mockSubscribe.mockResolvedValue({
|
||||
billing_op_id: 'op-3',
|
||||
status: 'needs_payment_method'
|
||||
})
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await expect(downgradeToPersonal('founder-monthly')).rejects.toThrow(
|
||||
'subscription.downgrade.paymentMethodRequired'
|
||||
)
|
||||
expect(mockStartOperation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('polls without opening a tab when the payment is pending', async () => {
|
||||
mockMembers.value = teamWithOwnerAnd('m1')
|
||||
mockSubscribe.mockResolvedValue({
|
||||
billing_op_id: 'op-4',
|
||||
status: 'pending_payment'
|
||||
})
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await downgradeToPersonal('founder-monthly')
|
||||
|
||||
expect(windowOpen).not.toHaveBeenCalled()
|
||||
expect(mockStartOperation).toHaveBeenCalledWith('op-4', 'subscription')
|
||||
})
|
||||
|
||||
it('reports the generic failure when subscribe fails and no members were removed', async () => {
|
||||
mockMembers.value = teamWithOwnerAnd()
|
||||
mockSubscribe.mockResolvedValue(undefined)
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await expect(downgradeToPersonal('founder-monthly')).rejects.toThrow(
|
||||
/^subscription\.downgrade\.failed$/
|
||||
)
|
||||
})
|
||||
|
||||
it('reports members were already removed when subscribe fails after removal', async () => {
|
||||
mockMembers.value = teamWithOwnerAnd('m1')
|
||||
mockSubscribe.mockResolvedValue(undefined)
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await expect(downgradeToPersonal('founder-monthly')).rejects.toThrow(
|
||||
'subscription.downgrade.failedAfterMemberRemoval'
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces which member failed and skips the plan change when removal throws', async () => {
|
||||
mockMembers.value = teamWithOwnerAnd('m1', 'm2')
|
||||
mockRemoveMember.mockImplementation((id: string) =>
|
||||
id === 'm2' ? Promise.reject(new Error('network')) : Promise.resolve()
|
||||
)
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await expect(downgradeToPersonal('founder-monthly')).rejects.toThrow(
|
||||
'm2@example.com'
|
||||
)
|
||||
expect(mockRemoveMember).toHaveBeenCalledWith('m1')
|
||||
expect(mockSubscribe).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('refreshMembers', () => {
|
||||
it('refetches members so a stale empty list cannot skip the confirm gate', async () => {
|
||||
mockMembers.value = []
|
||||
mockFetchMembers.mockImplementation(() => {
|
||||
mockMembers.value = teamWithOwnerAnd('m1')
|
||||
return Promise.resolve(mockMembers.value)
|
||||
})
|
||||
const { refreshMembers, hasOtherMembers } = useDowngradeToPersonal()
|
||||
expect(hasOtherMembers.value).toBe(false)
|
||||
|
||||
await refreshMembers()
|
||||
|
||||
expect(hasOtherMembers.value).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
101
src/platform/workspace/composables/useDowngradeToPersonal.ts
Normal file
101
src/platform/workspace/composables/useDowngradeToPersonal.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
|
||||
import { t } from '@/i18n'
|
||||
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
/**
|
||||
* Team-plan downgrade to personal: validate via `previewSubscribe`, remove
|
||||
* every member except the original owner, then initiate the tier change.
|
||||
* BE seam (BE-1337): removal email and an atomic downgrade endpoint are
|
||||
* BE-owned; until then the FE orchestrates the two steps non-atomically.
|
||||
*/
|
||||
export function useDowngradeToPersonal() {
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const { members } = storeToRefs(workspaceStore)
|
||||
const { subscribe, previewSubscribe } = useBillingContext()
|
||||
const billingOperationStore = useBillingOperationStore()
|
||||
const { userEmail } = useCurrentUser()
|
||||
|
||||
const removableMembers = computed(() => {
|
||||
const hasFlag = members.value.some((m) => m.isOriginalOwner)
|
||||
if (hasFlag) return members.value.filter((m) => !m.isOriginalOwner)
|
||||
const email = userEmail.value?.toLowerCase() ?? null
|
||||
return members.value.filter(
|
||||
(m) => m.role !== 'owner' && m.email.toLowerCase() !== email
|
||||
)
|
||||
})
|
||||
|
||||
const hasOtherMembers = computed(() => removableMembers.value.length > 0)
|
||||
|
||||
async function refreshMembers(): Promise<void> {
|
||||
await workspaceStore.fetchMembers()
|
||||
}
|
||||
|
||||
async function downgradeToPersonal(planSlug: string): Promise<void> {
|
||||
const preview = await previewSubscribe(planSlug)
|
||||
if (!preview?.allowed) {
|
||||
throw new Error(preview?.reason || t('subscription.downgrade.notAllowed'))
|
||||
}
|
||||
|
||||
const membersToRemove = removableMembers.value
|
||||
for (const member of membersToRemove) {
|
||||
try {
|
||||
await workspaceStore.removeMember(member.id)
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
t('subscription.downgrade.memberRemovalFailed', {
|
||||
email: member.email
|
||||
}),
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const response = await subscribe(
|
||||
planSlug,
|
||||
`${getComfyPlatformBaseUrl()}/payment/success`,
|
||||
`${getComfyPlatformBaseUrl()}/payment/failed`
|
||||
)
|
||||
if (!response) {
|
||||
throw new Error(
|
||||
membersToRemove.length > 0
|
||||
? t('subscription.downgrade.failedAfterMemberRemoval')
|
||||
: t('subscription.downgrade.failed')
|
||||
)
|
||||
}
|
||||
|
||||
if (response.status === 'needs_payment_method') {
|
||||
if (!response.payment_method_url) {
|
||||
throw new Error(t('subscription.downgrade.paymentMethodRequired'))
|
||||
}
|
||||
const paymentTab = window.open(response.payment_method_url, '_blank')
|
||||
if (!paymentTab) {
|
||||
throw new Error(t('subscription.downgrade.paymentPageBlocked'))
|
||||
}
|
||||
void billingOperationStore.startOperation(
|
||||
response.billing_op_id,
|
||||
'subscription'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (response.status === 'pending_payment') {
|
||||
void billingOperationStore.startOperation(
|
||||
response.billing_op_id,
|
||||
'subscription'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
removableMembers,
|
||||
hasOtherMembers,
|
||||
refreshMembers,
|
||||
downgradeToPersonal
|
||||
}
|
||||
}
|
||||
@@ -274,6 +274,27 @@ describe('useWorkspaceBilling', () => {
|
||||
expect(billing.balance.value?.amountMicros).toBe(5_000_000)
|
||||
})
|
||||
|
||||
it('returns the successful response when the post-subscribe refresh fails', async () => {
|
||||
mockWorkspaceApi.subscribe.mockResolvedValue({
|
||||
billing_op_id: 'op-1',
|
||||
status: 'subscribed'
|
||||
})
|
||||
mockWorkspaceApi.getBillingStatus.mockRejectedValue(
|
||||
new Error('refresh down')
|
||||
)
|
||||
mockWorkspaceApi.getBillingBalance.mockResolvedValue(positiveBalance)
|
||||
|
||||
const billing = setupBilling()
|
||||
|
||||
await expect(billing.subscribe('pro')).resolves.toStrictEqual({
|
||||
billing_op_id: 'op-1',
|
||||
status: 'subscribed'
|
||||
})
|
||||
expect(billing.error.value).toBe(
|
||||
'Subscription succeeded, but billing state refresh failed'
|
||||
)
|
||||
})
|
||||
|
||||
it('propagates error and records message when subscribe fails', async () => {
|
||||
mockWorkspaceApi.subscribe.mockRejectedValue(new Error('denied'))
|
||||
|
||||
|
||||
@@ -146,8 +146,18 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
|
||||
cancelUrl
|
||||
)
|
||||
|
||||
// Refresh status and balance after subscription
|
||||
await Promise.all([fetchStatus(), fetchBalance()])
|
||||
// Refresh is non-fatal: the subscribe write already succeeded, so a failed
|
||||
// refresh must not reject and prompt a retry of an active subscription.
|
||||
const [statusResult, balanceResult] = await Promise.allSettled([
|
||||
fetchStatus(),
|
||||
fetchBalance()
|
||||
])
|
||||
if (
|
||||
statusResult.status === 'rejected' ||
|
||||
balanceResult.status === 'rejected'
|
||||
) {
|
||||
error.value = 'Subscription succeeded, but billing state refresh failed'
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { computed, provide, ref, toRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -150,6 +151,17 @@ function handleIsOpenUpdate(isOpen: boolean) {
|
||||
void outputMediaAssets.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
const handleApproachEnd = useDebounceFn(async () => {
|
||||
if (
|
||||
outputMediaAssets.hasMore.value &&
|
||||
!outputMediaAssets.loading.value &&
|
||||
!outputMediaAssets.isLoadingMore.value
|
||||
) {
|
||||
await outputMediaAssets.loadMore()
|
||||
}
|
||||
}, 300)
|
||||
|
||||
const isUploading = ref(false)
|
||||
async function updateFiles(files: File[]) {
|
||||
isUploading.value = true
|
||||
@@ -179,10 +191,12 @@ async function updateFiles(files: File[]) {
|
||||
:base-model-options
|
||||
:is-uploading
|
||||
v-bind="combinedProps"
|
||||
:loading-more="outputMediaAssets.isLoadingMore.value"
|
||||
class="w-full"
|
||||
@update:selected="updateSelectedItems"
|
||||
@update:files="updateFiles"
|
||||
@update:is-open="handleIsOpenUpdate"
|
||||
@approach-end="handleApproachEnd"
|
||||
/>
|
||||
</WidgetLayoutField>
|
||||
</template>
|
||||
|
||||
@@ -37,6 +37,7 @@ interface Props {
|
||||
ownershipOptions?: OwnershipFilterOption[]
|
||||
showBaseModelFilter?: boolean
|
||||
baseModelOptions?: FilterOption[]
|
||||
loadingMore?: boolean
|
||||
isSelected?: (
|
||||
selected: Set<string>,
|
||||
item: FormDropdownItem,
|
||||
@@ -64,11 +65,16 @@ const {
|
||||
ownershipOptions,
|
||||
showBaseModelFilter,
|
||||
baseModelOptions,
|
||||
loadingMore = false,
|
||||
isSelected = (selected, item, _index) => selected.has(item.id),
|
||||
searcher = defaultSearcher,
|
||||
items
|
||||
} = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'approach-end'): void
|
||||
}>()
|
||||
|
||||
const placeholderText = computed(
|
||||
() => placeholder ?? t('widgets.uploadSelect.placeholder')
|
||||
)
|
||||
@@ -316,9 +322,11 @@ function handleSearchEnter() {
|
||||
:candidate-label
|
||||
:is-selected="internalIsSelected"
|
||||
:max-selectable
|
||||
:loading-more="loadingMore"
|
||||
@close="closeDropdown"
|
||||
@search-enter="handleSearchEnter"
|
||||
@item-click="handleSelection"
|
||||
@approach-end="emit('approach-end')"
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
@@ -7,8 +8,9 @@ import type { FormDropdownItem, LayoutMode } from './types'
|
||||
const VirtualGridStub = {
|
||||
name: 'VirtualGrid',
|
||||
props: ['items', 'maxColumns', 'itemHeight', 'scrollerHeight'],
|
||||
emits: ['approach-end'],
|
||||
template:
|
||||
'<div data-testid="virtual-grid" :data-items="JSON.stringify(items)" :data-max-columns="maxColumns" />'
|
||||
'<div data-testid="virtual-grid" :data-items="JSON.stringify(items)" :data-max-columns="maxColumns" @click="$emit(\'approach-end\')" />'
|
||||
}
|
||||
|
||||
function createItem(id: string, name: string): FormDropdownItem {
|
||||
@@ -93,6 +95,31 @@ describe('FormDropdownMenu', () => {
|
||||
expect(virtualGrid.getAttribute('data-max-columns')).toBe('1')
|
||||
})
|
||||
|
||||
it('forwards approach-end from the virtual grid', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { emitted } = render(FormDropdownMenu, {
|
||||
props: defaultProps,
|
||||
global: globalConfig
|
||||
})
|
||||
|
||||
await user.click(screen.getByTestId('virtual-grid'))
|
||||
|
||||
expect(emitted()['approach-end']).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows the loading-more row only while loadingMore is set', async () => {
|
||||
const { rerender } = render(FormDropdownMenu, {
|
||||
props: { ...defaultProps, loadingMore: true },
|
||||
global: globalConfig
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('form-dropdown-loading-more')).toBeTruthy()
|
||||
|
||||
await rerender({ ...defaultProps, loadingMore: false })
|
||||
|
||||
expect(screen.queryByTestId('form-dropdown-loading-more')).toBeNull()
|
||||
})
|
||||
|
||||
it('has data-capture-wheel="true" on the root element', () => {
|
||||
render(FormDropdownMenu, {
|
||||
props: defaultProps,
|
||||
|
||||
@@ -27,6 +27,7 @@ interface Props {
|
||||
baseModelOptions?: FilterOption[]
|
||||
candidateIndex?: number
|
||||
candidateLabel?: string
|
||||
loadingMore?: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -39,11 +40,13 @@ const {
|
||||
showBaseModelFilter,
|
||||
baseModelOptions,
|
||||
candidateIndex = -1,
|
||||
candidateLabel
|
||||
candidateLabel,
|
||||
loadingMore = false
|
||||
} = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'item-click', item: FormDropdownItem, index: number): void
|
||||
(e: 'search-enter'): void
|
||||
(e: 'approach-end'): void
|
||||
}>()
|
||||
|
||||
const filterSelected = defineModel<string>('filterSelected')
|
||||
@@ -158,6 +161,7 @@ const onWheel = (event: WheelEvent) => {
|
||||
:default-item-width="layoutConfig.itemWidth"
|
||||
:buffer-rows="2"
|
||||
class="mt-2 min-h-0 flex-1"
|
||||
@approach-end="emit('approach-end')"
|
||||
>
|
||||
<template #item="{ item, index }">
|
||||
<FormDropdownMenuItem
|
||||
@@ -172,5 +176,15 @@ const onWheel = (event: WheelEvent) => {
|
||||
/>
|
||||
</template>
|
||||
</VirtualGrid>
|
||||
<div
|
||||
v-if="loadingMore"
|
||||
class="flex items-center justify-center py-2"
|
||||
data-testid="form-dropdown-loading-more"
|
||||
>
|
||||
<i
|
||||
:aria-label="$t('g.loading')"
|
||||
class="icon-[lucide--loader] size-6 animate-spin text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
94
src/scripts/api.cancel.test.ts
Normal file
94
src/scripts/api.cancel.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
// Tests for api.cancelJob and api.cancelJobs; fetchApi is stubbed.
|
||||
const okResponse = () => ({ ok: true, status: 200 }) as Response
|
||||
const errorResponse = (status: number, body = '') =>
|
||||
({
|
||||
ok: false,
|
||||
status,
|
||||
text: () => Promise.resolve(body)
|
||||
}) as unknown as Response
|
||||
|
||||
describe('api jobs-namespace cancel', () => {
|
||||
let fetchApiSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
fetchApiSpy = vi.spyOn(api, 'fetchApi').mockResolvedValue(okResponse())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('cancelJob (single)', () => {
|
||||
it('POSTs to the single-job cancel endpoint', async () => {
|
||||
await api.cancelJob('abc-123')
|
||||
|
||||
expect(fetchApiSpy).toHaveBeenCalledWith('/jobs/abc-123/cancel', {
|
||||
method: 'POST'
|
||||
})
|
||||
})
|
||||
|
||||
it('encodes the job id in the path', async () => {
|
||||
await api.cancelJob('a/b c')
|
||||
|
||||
expect(fetchApiSpy).toHaveBeenCalledWith('/jobs/a%2Fb%20c/cancel', {
|
||||
method: 'POST'
|
||||
})
|
||||
})
|
||||
|
||||
it('throws when the request fails', async () => {
|
||||
fetchApiSpy.mockResolvedValueOnce(errorResponse(500))
|
||||
|
||||
await expect(api.cancelJob('abc-123')).rejects.toThrow(
|
||||
'Failed to cancel job abc-123: 500'
|
||||
)
|
||||
})
|
||||
|
||||
it('includes the response body in the error when present', async () => {
|
||||
fetchApiSpy.mockResolvedValueOnce(errorResponse(404, 'job not found'))
|
||||
|
||||
await expect(api.cancelJob('abc-123')).rejects.toThrow(
|
||||
'Failed to cancel job abc-123: 404 — job not found'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelJobs (batch)', () => {
|
||||
it('POSTs the job_ids array to the batch cancel endpoint', async () => {
|
||||
await api.cancelJobs(['id-1', 'id-2'])
|
||||
|
||||
expect(fetchApiSpy).toHaveBeenCalledWith('/jobs/cancel', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ job_ids: ['id-1', 'id-2'] })
|
||||
})
|
||||
})
|
||||
|
||||
it('does not call the API for an empty list', async () => {
|
||||
await api.cancelJobs([])
|
||||
|
||||
expect(fetchApiSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws when the batch request fails', async () => {
|
||||
fetchApiSpy.mockResolvedValueOnce(errorResponse(500))
|
||||
|
||||
await expect(api.cancelJobs(['id-1'])).rejects.toThrow(
|
||||
'Failed to cancel jobs: 500'
|
||||
)
|
||||
})
|
||||
|
||||
it('includes the response body in the error when present', async () => {
|
||||
fetchApiSpy.mockResolvedValueOnce(errorResponse(422, 'invalid job ids'))
|
||||
|
||||
await expect(api.cancelJobs(['id-1'])).rejects.toThrow(
|
||||
'Failed to cancel jobs: 422 — invalid job ids'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1110,6 +1110,52 @@ export class ComfyApi extends EventTarget {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels a single job by id via `POST /api/jobs/{job_id}/cancel` (idempotent:
|
||||
* already-terminal jobs are a no-op). Requires runtime parity — not every
|
||||
* runtime exposes this endpoint yet; do not merge callers before parity lands.
|
||||
*
|
||||
* @param {string} jobId The id of the job to cancel
|
||||
*/
|
||||
async cancelJob(jobId: string) {
|
||||
const res = await this.fetchApi(
|
||||
`/jobs/${encodeURIComponent(jobId)}/cancel`,
|
||||
{
|
||||
method: 'POST'
|
||||
}
|
||||
)
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '')
|
||||
throw new Error(
|
||||
`Failed to cancel job ${jobId}: ${res.status}${body ? ` — ${body}` : ''}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels multiple jobs in a single request via `POST /api/jobs/cancel` with
|
||||
* body `{ job_ids: [...] }`. Already-terminal jobs are no-ops. Same runtime
|
||||
* parity requirement as {@link cancelJob}.
|
||||
*
|
||||
* @param {string[]} jobIds The ids of the jobs to cancel
|
||||
*/
|
||||
async cancelJobs(jobIds: string[]) {
|
||||
if (!jobIds.length) return
|
||||
const res = await this.fetchApi('/jobs/cancel', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ job_ids: jobIds })
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '')
|
||||
throw new Error(
|
||||
`Failed to cancel jobs: ${res.status}${body ? ` — ${body}` : ''}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets user configuration data and where data should be stored
|
||||
*/
|
||||
|
||||
124
src/services/dialogService.downgrade.test.ts
Normal file
124
src/services/dialogService.downgrade.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* showDowngradeToPersonalDialog must refresh members before the no-members
|
||||
* fast path and stay non-dismissable (ESC derives from `closable` in
|
||||
* dialogStore); fast-path failures must toast.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const showDialog = vi.hoisted(() => vi.fn())
|
||||
const toastAdd = vi.hoisted(() => vi.fn())
|
||||
const refreshMembers = vi.hoisted(() => vi.fn())
|
||||
const downgradeToPersonal = vi.hoisted(() => vi.fn())
|
||||
const hasOtherMembers = vi.hoisted(() => ({ value: false }))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({ showDialog })
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackEvent: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: { value: true },
|
||||
isFreeTier: { value: false },
|
||||
type: { value: 'legacy' }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({ add: toastAdd })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useDowngradeToPersonal', () => ({
|
||||
useDowngradeToPersonal: () => ({
|
||||
hasOtherMembers,
|
||||
refreshMembers,
|
||||
downgradeToPersonal
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workspace/components/dialogs/DowngradeRemoveMembersDialogContent.vue',
|
||||
() => ({ default: { name: 'DowngradeRemoveMembersDialogContent' } })
|
||||
)
|
||||
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
describe('showDowngradeToPersonalDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
hasOtherMembers.value = false
|
||||
refreshMembers.mockResolvedValue(undefined)
|
||||
downgradeToPersonal.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
const options = { planName: 'Standard', planSlug: 'standard-monthly' }
|
||||
|
||||
it('refreshes members before deciding the no-members fast path', async () => {
|
||||
const calls: string[] = []
|
||||
refreshMembers.mockImplementation(() => {
|
||||
calls.push('refresh')
|
||||
return Promise.resolve()
|
||||
})
|
||||
downgradeToPersonal.mockImplementation(() => {
|
||||
calls.push('downgrade')
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
await useDialogService().showDowngradeToPersonalDialog(options)
|
||||
|
||||
expect(calls).toEqual(['refresh', 'downgrade'])
|
||||
expect(downgradeToPersonal).toHaveBeenCalledWith('standard-monthly')
|
||||
expect(showDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows a non-dismissable confirm dialog when other members exist', async () => {
|
||||
hasOtherMembers.value = true
|
||||
|
||||
await useDialogService().showDowngradeToPersonalDialog(options)
|
||||
|
||||
expect(downgradeToPersonal).not.toHaveBeenCalled()
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.key).toBe('downgrade-remove-members')
|
||||
expect(args.props.onConfirm).toBe(downgradeToPersonal)
|
||||
expect(args.dialogComponentProps.closable).toBe(false)
|
||||
expect(args.dialogComponentProps.dismissableMask).toBe(false)
|
||||
})
|
||||
|
||||
it('toasts and does not rethrow when the fast-path downgrade fails', async () => {
|
||||
downgradeToPersonal.mockRejectedValue(new Error('Outstanding balance'))
|
||||
|
||||
await useDialogService().showDowngradeToPersonalDialog(options)
|
||||
|
||||
expect(toastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
detail: 'Outstanding balance'
|
||||
})
|
||||
)
|
||||
expect(showDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('toasts and aborts when the member refresh fails', async () => {
|
||||
hasOtherMembers.value = true
|
||||
refreshMembers.mockRejectedValue(new Error('network'))
|
||||
|
||||
await useDialogService().showDowngradeToPersonalDialog(options)
|
||||
|
||||
expect(toastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'error', detail: 'network' })
|
||||
)
|
||||
expect(showDialog).not.toHaveBeenCalled()
|
||||
expect(downgradeToPersonal).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,7 @@ import { t } from '@/i18n'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type {
|
||||
DialogComponentProps,
|
||||
@@ -607,6 +608,53 @@ export const useDialogService = () => {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Downgrade a team plan to a personal plan (FE-977). Skips the type-"I
|
||||
* understand" confirm dialog when the workspace has no other members;
|
||||
* failures on that path surface as an error toast.
|
||||
*/
|
||||
async function showDowngradeToPersonalDialog(options: {
|
||||
planName: string
|
||||
planSlug: string
|
||||
}) {
|
||||
const { useDowngradeToPersonal } =
|
||||
await import('@/platform/workspace/composables/useDowngradeToPersonal')
|
||||
const { hasOtherMembers, refreshMembers, downgradeToPersonal } =
|
||||
useDowngradeToPersonal()
|
||||
|
||||
try {
|
||||
await refreshMembers()
|
||||
if (!hasOtherMembers.value) {
|
||||
await downgradeToPersonal(options.planSlug)
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: t('subscription.downgrade.failed'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError')
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const { default: component } =
|
||||
await import('@/platform/workspace/components/dialogs/DowngradeRemoveMembersDialogContent.vue')
|
||||
return dialogStore.showDialog({
|
||||
key: 'downgrade-remove-members',
|
||||
component,
|
||||
props: {
|
||||
planName: options.planName,
|
||||
planSlug: options.planSlug,
|
||||
onConfirm: downgradeToPersonal
|
||||
},
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogProps,
|
||||
closable: false,
|
||||
dismissableMask: false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Shows one-time cloud notification modal for macOS desktop users. */
|
||||
async function showCloudNotification(): Promise<void> {
|
||||
const { default: component } = await lazyCloudNotificationContent()
|
||||
@@ -668,6 +716,7 @@ export const useDialogService = () => {
|
||||
showInviteMemberDialog,
|
||||
showInviteMemberUpsellDialog,
|
||||
showBillingComingSoonDialog,
|
||||
showCancelSubscriptionDialog
|
||||
showCancelSubscriptionDialog,
|
||||
showDowngradeToPersonalDialog
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@ import { nextTick, watch } from 'vue'
|
||||
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type {
|
||||
AssetItem,
|
||||
AssetResponse
|
||||
} from '@/platform/assets/schemas/assetSchema'
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
|
||||
@@ -25,6 +28,7 @@ vi.mock('@/scripts/api', () => ({
|
||||
vi.mock('@/platform/assets/services/assetService', () => ({
|
||||
assetService: {
|
||||
getAssetsByTag: vi.fn(),
|
||||
getAssetsPageByTag: vi.fn(),
|
||||
getAllAssetsByTag: vi.fn(),
|
||||
getAssetsForNodeType: vi.fn(),
|
||||
invalidateInputAssetsIncludingPublic: vi.fn(),
|
||||
@@ -1517,32 +1521,64 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
|
||||
tags: ['output']
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
const makePage = (
|
||||
assets: AssetItem[],
|
||||
{
|
||||
hasMore = false,
|
||||
nextCursor
|
||||
}: { hasMore?: boolean; nextCursor?: string } = {}
|
||||
): AssetResponse => ({
|
||||
assets,
|
||||
total: assets.length,
|
||||
has_more: hasMore,
|
||||
...(nextCursor === undefined ? {} : { next_cursor: nextCursor })
|
||||
})
|
||||
|
||||
it('fetches outputs via getAssetsByTag with the output tag and page size', async () => {
|
||||
vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce([
|
||||
makeAsset('a1', 'image1.png', 'hash1.png'),
|
||||
makeAsset('a2', 'image2.png', 'hash2.png')
|
||||
])
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
it('fetches the first page via getAssetsPageByTag with the output tag and page size', async () => {
|
||||
vi.mocked(assetService.getAssetsPageByTag).mockResolvedValueOnce(
|
||||
makePage([
|
||||
makeAsset('a1', 'image1.png', 'hash1.png'),
|
||||
makeAsset('a2', 'image2.png', 'hash2.png')
|
||||
])
|
||||
)
|
||||
|
||||
const store = useAssetsStore()
|
||||
await store.updateFlatOutputs()
|
||||
|
||||
expect(assetService.getAssetsByTag).toHaveBeenCalledWith(
|
||||
expect(assetService.getAssetsPageByTag).toHaveBeenCalledWith(
|
||||
'output',
|
||||
true,
|
||||
expect.objectContaining({ limit: FLAT_OUTPUT_PAGE_SIZE, offset: 0 })
|
||||
{
|
||||
limit: FLAT_OUTPUT_PAGE_SIZE,
|
||||
offset: 0
|
||||
}
|
||||
)
|
||||
expect(store.flatOutputAssets.map((a) => a.id)).toEqual(['a1', 'a2'])
|
||||
})
|
||||
|
||||
it('marks hasMore=false when the page is short', async () => {
|
||||
vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce([
|
||||
makeAsset('a1', 'one.png')
|
||||
])
|
||||
it('trusts server has_more over page size for a short page', async () => {
|
||||
vi.mocked(assetService.getAssetsPageByTag).mockResolvedValueOnce(
|
||||
makePage([makeAsset('a1', 'one.png')], { hasMore: true })
|
||||
)
|
||||
|
||||
const store = useAssetsStore()
|
||||
await store.updateFlatOutputs()
|
||||
|
||||
expect(store.flatOutputHasMore).toBe(true)
|
||||
})
|
||||
|
||||
it('marks hasMore=false when the server reports the last page', async () => {
|
||||
const fullPage = Array.from({ length: FLAT_OUTPUT_PAGE_SIZE }, (_, i) =>
|
||||
makeAsset(`a${i}`, `f${i}.png`)
|
||||
)
|
||||
vi.mocked(assetService.getAssetsPageByTag).mockResolvedValueOnce(
|
||||
makePage(fullPage, { hasMore: false })
|
||||
)
|
||||
|
||||
const store = useAssetsStore()
|
||||
await store.updateFlatOutputs()
|
||||
@@ -1550,16 +1586,78 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
|
||||
expect(store.flatOutputHasMore).toBe(false)
|
||||
})
|
||||
|
||||
it('marks hasMore=true when a full page is returned', async () => {
|
||||
const fullPage = Array.from({ length: FLAT_OUTPUT_PAGE_SIZE }, (_, i) =>
|
||||
makeAsset(`a${i}`, `f${i}.png`)
|
||||
it('threads the minted cursor into after on loadMore and omits offset', async () => {
|
||||
vi.mocked(assetService.getAssetsPageByTag)
|
||||
.mockResolvedValueOnce(
|
||||
makePage([makeAsset('a1', 'f1.png')], {
|
||||
hasMore: true,
|
||||
nextCursor: 'cursor-1'
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(makePage([makeAsset('a2', 'f2.png')]))
|
||||
|
||||
const store = useAssetsStore()
|
||||
await store.updateFlatOutputs()
|
||||
await store.loadMoreFlatOutputs()
|
||||
|
||||
expect(assetService.getAssetsPageByTag).toHaveBeenLastCalledWith(
|
||||
'output',
|
||||
true,
|
||||
{ limit: FLAT_OUTPUT_PAGE_SIZE, after: 'cursor-1' }
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to offset paging when the server mints no cursor', async () => {
|
||||
vi.mocked(assetService.getAssetsPageByTag)
|
||||
.mockResolvedValueOnce(
|
||||
makePage([makeAsset('a1', 'f1.png'), makeAsset('a2', 'f2.png')], {
|
||||
hasMore: true
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(makePage([makeAsset('a3', 'f3.png')]))
|
||||
|
||||
const store = useAssetsStore()
|
||||
await store.updateFlatOutputs()
|
||||
await store.loadMoreFlatOutputs()
|
||||
|
||||
expect(assetService.getAssetsPageByTag).toHaveBeenLastCalledWith(
|
||||
'output',
|
||||
true,
|
||||
{ limit: FLAT_OUTPUT_PAGE_SIZE, offset: 2 }
|
||||
)
|
||||
})
|
||||
|
||||
it('stops when the server returns a non-advancing cursor', async () => {
|
||||
vi.mocked(assetService.getAssetsPageByTag)
|
||||
.mockResolvedValueOnce(
|
||||
makePage([makeAsset('a1', 'f1.png')], {
|
||||
hasMore: true,
|
||||
nextCursor: 'stuck'
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
makePage([makeAsset('a2', 'f2.png')], {
|
||||
hasMore: true,
|
||||
nextCursor: 'stuck'
|
||||
})
|
||||
)
|
||||
|
||||
const store = useAssetsStore()
|
||||
await store.updateFlatOutputs()
|
||||
await store.loadMoreFlatOutputs()
|
||||
|
||||
expect(store.flatOutputHasMore).toBe(false)
|
||||
})
|
||||
|
||||
it('treats an empty page as terminal even when has_more is true', async () => {
|
||||
vi.mocked(assetService.getAssetsPageByTag).mockResolvedValueOnce(
|
||||
makePage([], { hasMore: true })
|
||||
)
|
||||
vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce(fullPage)
|
||||
|
||||
const store = useAssetsStore()
|
||||
await store.updateFlatOutputs()
|
||||
|
||||
expect(store.flatOutputHasMore).toBe(true)
|
||||
expect(store.flatOutputHasMore).toBe(false)
|
||||
})
|
||||
|
||||
it('appends and dedupes on loadMoreFlatOutputs', async () => {
|
||||
@@ -1570,9 +1668,9 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
|
||||
makeAsset('a0', 'duplicate.png'),
|
||||
makeAsset('newId', 'new.png')
|
||||
]
|
||||
vi.mocked(assetService.getAssetsByTag)
|
||||
.mockResolvedValueOnce(firstPage)
|
||||
.mockResolvedValueOnce(secondPage)
|
||||
vi.mocked(assetService.getAssetsPageByTag)
|
||||
.mockResolvedValueOnce(makePage(firstPage, { hasMore: true }))
|
||||
.mockResolvedValueOnce(makePage(secondPage))
|
||||
|
||||
const store = useAssetsStore()
|
||||
await store.updateFlatOutputs()
|
||||
@@ -1582,9 +1680,9 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
|
||||
expect(store.flatOutputAssets.at(-1)?.id).toBe('newId')
|
||||
})
|
||||
|
||||
it('records error and clears media on initial-fetch failure', async () => {
|
||||
it('records error and resolves to an empty list on initial-fetch failure', async () => {
|
||||
const err = new Error('network down')
|
||||
vi.mocked(assetService.getAssetsByTag).mockRejectedValueOnce(err)
|
||||
vi.mocked(assetService.getAssetsPageByTag).mockRejectedValueOnce(err)
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
@@ -1599,37 +1697,105 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('refresh resets pagination', async () => {
|
||||
vi.mocked(assetService.getAssetsByTag)
|
||||
it('preserves the cursor for retry when loadMore fails', async () => {
|
||||
const err = new Error('network down')
|
||||
vi.mocked(assetService.getAssetsPageByTag)
|
||||
.mockResolvedValueOnce(
|
||||
Array.from({ length: FLAT_OUTPUT_PAGE_SIZE }, (_, i) =>
|
||||
makeAsset(`a${i}`, `f${i}.png`)
|
||||
)
|
||||
makePage([makeAsset('a1', 'f1.png')], {
|
||||
hasMore: true,
|
||||
nextCursor: 'cursor-1'
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce([makeAsset('fresh', 'fresh.png')])
|
||||
.mockRejectedValueOnce(err)
|
||||
.mockResolvedValueOnce(makePage([makeAsset('a2', 'f2.png')]))
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
const store = useAssetsStore()
|
||||
await store.updateFlatOutputs()
|
||||
await store.loadMoreFlatOutputs()
|
||||
|
||||
expect(store.flatOutputError).toBe(err)
|
||||
expect(store.flatOutputAssets.map((a) => a.id)).toEqual(['a1'])
|
||||
expect(store.flatOutputHasMore).toBe(true)
|
||||
|
||||
await store.loadMoreFlatOutputs()
|
||||
|
||||
expect(assetService.getAssetsPageByTag).toHaveBeenLastCalledWith(
|
||||
'output',
|
||||
true,
|
||||
{ limit: FLAT_OUTPUT_PAGE_SIZE, after: 'cursor-1' }
|
||||
)
|
||||
} finally {
|
||||
consoleSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('restarts from the head when loadMore follows a failed refresh', async () => {
|
||||
vi.mocked(assetService.getAssetsPageByTag)
|
||||
.mockResolvedValueOnce(
|
||||
makePage([makeAsset('a1', 'f1.png')], {
|
||||
hasMore: true,
|
||||
nextCursor: 'cursor-1'
|
||||
})
|
||||
)
|
||||
.mockRejectedValueOnce(new Error('network down'))
|
||||
.mockResolvedValueOnce(makePage([makeAsset('a2', 'f2.png')]))
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
const store = useAssetsStore()
|
||||
await store.updateFlatOutputs()
|
||||
await store.updateFlatOutputs()
|
||||
await store.loadMoreFlatOutputs()
|
||||
|
||||
expect(assetService.getAssetsPageByTag).toHaveBeenLastCalledWith(
|
||||
'output',
|
||||
true,
|
||||
{ limit: FLAT_OUTPUT_PAGE_SIZE, offset: 0 }
|
||||
)
|
||||
} finally {
|
||||
consoleSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('refresh resets pagination', async () => {
|
||||
vi.mocked(assetService.getAssetsPageByTag)
|
||||
.mockResolvedValueOnce(
|
||||
makePage([makeAsset('a1', 'f1.png')], {
|
||||
hasMore: true,
|
||||
nextCursor: 'cursor-1'
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(makePage([makeAsset('fresh', 'fresh.png')]))
|
||||
|
||||
const store = useAssetsStore()
|
||||
await store.updateFlatOutputs()
|
||||
await store.updateFlatOutputs()
|
||||
|
||||
expect(assetService.getAssetsPageByTag).toHaveBeenLastCalledWith(
|
||||
'output',
|
||||
true,
|
||||
{ limit: FLAT_OUTPUT_PAGE_SIZE, offset: 0 }
|
||||
)
|
||||
expect(store.flatOutputAssets.map((a) => a.id)).toEqual(['fresh'])
|
||||
expect(store.flatOutputHasMore).toBe(false)
|
||||
})
|
||||
|
||||
it('dedupes concurrent fetches into a single request', async () => {
|
||||
let resolvePage!: (assets: AssetItem[]) => void
|
||||
const pagePromise = new Promise<AssetItem[]>((res) => {
|
||||
let resolvePage!: (page: AssetResponse) => void
|
||||
const pagePromise = new Promise<AssetResponse>((res) => {
|
||||
resolvePage = res
|
||||
})
|
||||
vi.mocked(assetService.getAssetsByTag).mockReturnValueOnce(pagePromise)
|
||||
vi.mocked(assetService.getAssetsPageByTag).mockReturnValueOnce(pagePromise)
|
||||
|
||||
const store = useAssetsStore()
|
||||
const p1 = store.updateFlatOutputs()
|
||||
const p2 = store.updateFlatOutputs()
|
||||
|
||||
expect(vi.mocked(assetService.getAssetsByTag)).toHaveBeenCalledTimes(1)
|
||||
expect(vi.mocked(assetService.getAssetsPageByTag)).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolvePage([makeAsset('shared-1', 'shared.png', 'h.png')])
|
||||
resolvePage(makePage([makeAsset('shared-1', 'shared.png', 'h.png')]))
|
||||
await Promise.all([p1, p2])
|
||||
|
||||
expect(store.flatOutputAssets.map((x) => x.id)).toEqual(['shared-1'])
|
||||
|
||||
@@ -267,6 +267,7 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
const flatOutputHasMore = ref(true)
|
||||
const flatOutputIsLoadingMore = ref(false)
|
||||
const flatOutputSeenIds = new Set<string>()
|
||||
let flatOutputNextCursor: string | undefined
|
||||
let flatOutputInFlight: Promise<AssetItem[]> | null = null
|
||||
|
||||
async function fetchFlatOutputs(loadMore: boolean): Promise<AssetItem[]> {
|
||||
@@ -278,26 +279,36 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
} else {
|
||||
flatOutputLoading.value = true
|
||||
flatOutputOffset.value = 0
|
||||
flatOutputNextCursor = undefined
|
||||
flatOutputHasMore.value = true
|
||||
flatOutputSeenIds.clear()
|
||||
}
|
||||
flatOutputError.value = null
|
||||
|
||||
flatOutputInFlight = (async () => {
|
||||
const requestedAfter = loadMore ? flatOutputNextCursor : undefined
|
||||
try {
|
||||
const page = await assetService.getAssetsByTag(OUTPUT_TAG, true, {
|
||||
const page = await assetService.getAssetsPageByTag(OUTPUT_TAG, true, {
|
||||
limit: FLAT_OUTPUT_PAGE_SIZE,
|
||||
offset: flatOutputOffset.value
|
||||
...(requestedAfter
|
||||
? { after: requestedAfter }
|
||||
: { offset: flatOutputOffset.value })
|
||||
})
|
||||
const batch = page.assets
|
||||
const fresh = loadMore
|
||||
? page.filter((asset) => !flatOutputSeenIds.has(asset.id))
|
||||
: page
|
||||
? batch.filter((asset) => !flatOutputSeenIds.has(asset.id))
|
||||
: batch
|
||||
for (const asset of fresh) flatOutputSeenIds.add(asset.id)
|
||||
flatOutputAssets.value = loadMore
|
||||
? [...flatOutputAssets.value, ...fresh]
|
||||
: page
|
||||
flatOutputOffset.value += page.length
|
||||
flatOutputHasMore.value = page.length === FLAT_OUTPUT_PAGE_SIZE
|
||||
: batch
|
||||
flatOutputOffset.value += batch.length
|
||||
const nextCursor = page.next_cursor || undefined
|
||||
const cursorStuck =
|
||||
nextCursor !== undefined && nextCursor === requestedAfter
|
||||
flatOutputNextCursor = cursorStuck ? undefined : nextCursor
|
||||
flatOutputHasMore.value =
|
||||
fresh.length > 0 && page.has_more && !cursorStuck
|
||||
return flatOutputAssets.value
|
||||
} catch (err) {
|
||||
flatOutputError.value = err
|
||||
|
||||
Reference in New Issue
Block a user